diff --git a/AIRecognizerEnhancer/ARCHITECTURE.md b/AIRecognizerEnhancer/ARCHITECTURE.md new file mode 100644 index 0000000..314622b --- /dev/null +++ b/AIRecognizerEnhancer/ARCHITECTURE.md @@ -0,0 +1,83 @@ +# AI识别增强架构草案 + +`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。 + +## 设计目标 + +- 摆脱外部 AI Gateway 的强依赖 +- 直接使用 MoviePilot 已启用的 LLM 配置 +- 输出结构化识别结果,而不是只回传一段自由文本 + +## 模块分层 + +### 1. hooks + +负责接住识别失败事件和后续整理事件。 + +### 2. llm + +负责封装对 MP 当前 LLM 的调用: + +- 标准提示词 +- 结构化返回约束 +- 超时与错误兜底 + +### 3. normalize + +负责把 AI 输出转换成可继续进入 MP 整理链路的数据: + +- 标题 +- 年份 +- 类型 +- 季 +- 集 +- 置信度 + +### 4. actions + +负责根据结果执行后续动作: + +- 二次识别 +- 二次整理 +- 记录失败样本 + +## 首期配置模型 + +- `enabled` +- `notify` +- `debug` +- `confidence_threshold` +- `request_timeout` +- `max_retries` +- `save_failed_samples` + +## 二期规划 + +- 生成自定义识别词建议 +- 失败样本聚合分析 +- 提供给 MP Agent / Skill 直接调起 + +## 首个里程碑 + +第一个可用版本只追求: + +1. 原生识别失败后自动触发本地 LLM 判断 +2. 拿到结构化结果后自动二次整理 +3. 能明确记录“成功 / 放弃 / 失败原因” + +## 当前实现状态 + +- 已接住 `ChainEventType.NameRecognize` +- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出 +- 已提供手动调试接口用于验证标题识别结果 +- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议 +- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers` +- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制 +- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本 +- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除 +- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队 +- 已支持失败样本批量复查:可批量重跑并按结果批量出队 +- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库 +- 已支持低 token 精简摘要输出,适合作为智能体批处理入口 +- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地 +- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升 diff --git a/AIRecognizerEnhancer/README.md b/AIRecognizerEnhancer/README.md new file mode 100644 index 0000000..e10d001 --- /dev/null +++ b/AIRecognizerEnhancer/README.md @@ -0,0 +1,101 @@ +# AI识别增强 + +`AI识别增强` 用来补强 MoviePilot 原生整理链里的识别阶段。 + +它的核心思路很简单: + +- 复用 MoviePilot 当前已经启用的 LLM 配置 +- 在原生识别失败或置信度不足时,做一次本地结构化识别兜底 +- 把结果回写给 MoviePilot,继续走原生二次识别和后续整理链 + +## 适合什么场景 + +- 文件名比较脏,混有压制组、分辨率、语言、站点标记 +- 同一部剧经常出现英文名、别名、原名、翻译名混用 +- 网盘挂载、手动整理、历史资源补录时,原生识别偶尔不稳定 +- 你想把失败样本沉淀下来,后面持续优化 `CustomIdentifiers` + +## 和 MoviePilot 原版智能体的区别 + +MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次”的能力。 + +这和 `AI识别增强` 有重叠,但定位不同: + +- **MP 原版智能体** + - 更偏“一次性补救” + - 适合偶发失败、想省事的场景 + +- **AI识别增强** + - 更偏“识别失败治理层” + - 除了补救当前这次,还能: + - 保存失败样本 + - 汇总样本洞察 + - 生成 `CustomIdentifiers` 建议 + - 写入识别词 + - 重放 / 复查 / 批量出队 + +一句话区分: + +- 原版智能体:自动接管一次 +- `AI识别增强`:把失败样本沉淀下来,长期减少同类失败 + +## 当前能力 + +- 监听 `ChainEventType.NameRecognize` +- 用当前 LLM 结构化判断标题、年份、类型、季集 +- 回写 `name / year / season / episode` +- 交回 MoviePilot 原生链路继续二次识别 +- 保存低置信度失败样本 +- 提供失败样本工作清单、洞察、重放、删除和清空能力 +- 生成并应用 `CustomIdentifiers` 建议 + +## 主要接口 + +- `GET /api/v1/plugin/AIRecognizerEnhancer/health` + - 查看插件状态、LLM 提供方、模型、阈值和超时配置 +- `POST /api/v1/plugin/AIRecognizerEnhancer/recognize` + - 对单个标题做一次本地结构化识别测试 +- `GET /api/v1/plugin/AIRecognizerEnhancer/failed_samples` + - 查看最近保存的失败样本 +- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_worklist` + - 返回适合继续处理的失败样本摘要列表 +- `GET /api/v1/plugin/AIRecognizerEnhancer/sample_insights` + - 汇总失败原因、重复问题和优先处理样本 +- `POST /api/v1/plugin/AIRecognizerEnhancer/replay_failed_sample` + - 用当前识别词和当前识别器重放复查某条失败样本 +- `POST /api/v1/plugin/AIRecognizerEnhancer/suggest_identifiers_from_sample` + - 直接基于失败样本生成识别词建议 +- `POST /api/v1/plugin/AIRecognizerEnhancer/apply_suggested_identifier` + - 把建议规则写入系统 `CustomIdentifiers` + +其余批量接口和清理接口可以按需要继续使用,详细路径以插件 `get_api()` 暴露结果为准。 + +## 配置建议 + +- 先确认 MoviePilot 本身已经配置好可用的 LLM +- 建议保持“保存失败样本”开启 +- 如果你经常处理历史资源或网盘资源,建议定期查看: + - `failed_samples` + - `sample_worklist` + - `sample_insights` + +## 已验证情况 + +当前版本:`0.1.12` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +这版已经验证过: + +- 最新版 MoviePilot 下可以正常加载 +- 正常中文标题识别可用 +- 英文别名、韩文原名、中文别名可识别回标准媒体信息 +- 低置信度标题会落失败样本 +- `replay_failed_sample` 复查链可用 + +## 说明 + +- 这个插件不依赖外部 AI Gateway 回调链 +- 重点是增强识别,不负责替代 MoviePilot 全部整理流程 +- 如果你只是偶发整理失败,原版智能体可能已经够用 +- 如果你长期受命名混乱困扰,这个插件更有价值 diff --git a/AIRecognizerEnhancer/__init__.py b/AIRecognizerEnhancer/__init__.py new file mode 100644 index 0000000..eee252e --- /dev/null +++ b/AIRecognizerEnhancer/__init__.py @@ -0,0 +1,2043 @@ +import hmac +import asyncio +import inspect +import json +import re +import threading +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import Request +from langchain_core.prompts import ChatPromptTemplate +from pydantic import BaseModel, Field + +from app.chain.media import MediaChain +from app.core.config import settings +from app.core.event import eventmanager +from app.core.meta.words import WordsMatcher +from app.core.metainfo import MetaInfo +from app.db.systemconfig_oper import SystemConfigOper +try: + from app.helper.llm import LLMHelper +except ImportError: # MoviePilot 新版已迁移到 app.agent.llm + from app.agent.llm import LLMHelper +from app.log import logger +from app.plugins import _PluginBase +from app.schemas.types import ChainEventType, MediaType, SystemConfigKey + + +class AIRecognitionGuess(BaseModel): + name: str = Field(default="", description="标准化后的影视标题;无法判断时返回空字符串") + year: str = Field(default="", description="四位年份;无法判断时返回空字符串") + media_type: str = Field(default="unknown", description="movie、tv 或 unknown") + season: int = Field(default=0, description="剧集季号,电影填 0") + episode: int = Field(default=0, description="剧集集号,电影或未知填 0") + confidence: float = Field(default=0.0, description="0 到 1 之间的置信度") + reason: str = Field(default="", description="简短说明为什么这样判断") + + +class IdentifierSuggestion(BaseModel): + comment: str = Field(default="", description="可选注释,不带 #") + rule: str = Field(default="", description="一条 MoviePilot 自定义识别词规则") + confidence: float = Field(default=0.0, description="0 到 1 之间的置信度") + reason: str = Field(default="", description="为什么建议这条规则") + + +class IdentifierSuggestionBundle(BaseModel): + summary: str = Field(default="", description="整体建议摘要") + suggestions: List[IdentifierSuggestion] = Field(default_factory=list, description="建议规则列表") + + +class AIRecognizerEnhancer(_PluginBase): + plugin_name = "AI识别增强" + plugin_desc = "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/airecognizerenhancer.png" + plugin_version = "0.1.12" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "arrecognizerenhancer_" + plugin_order = 41 + auth_level = 1 + + _enabled = False + _debug = False + _confidence_threshold = 0.65 + _request_timeout = 25 + _max_retries = 2 + _save_failed_samples = True + _max_failed_samples = 200 + _auto_remove_applied_sample = True + _systemconfig: Optional[SystemConfigOper] = None + + def init_plugin(self, config: Optional[Dict[str, Any]] = None): + config = config or {} + self._enabled = bool(config.get("enabled", False)) + self._debug = bool(config.get("debug", False)) + self._confidence_threshold = self._safe_float(config.get("confidence_threshold"), 0.65) + self._request_timeout = self._safe_int(config.get("request_timeout"), 25) + self._max_retries = max(1, min(5, self._safe_int(config.get("max_retries"), 2))) + self._save_failed_samples = bool(config.get("save_failed_samples", True)) + self._max_failed_samples = max(20, min(1000, self._safe_int(config.get("max_failed_samples"), 200))) + self._auto_remove_applied_sample = bool(config.get("auto_remove_applied_sample", True)) + self._systemconfig = SystemConfigOper() + self._register_events() + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + try: + eventmanager.disable_event_handler(self.on_chain_name_recognize) + except Exception: + pass + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _safe_float(value: Any, default: float) -> float: + try: + return float(value) + except Exception: + return default + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + for key in ("apikey", "api_key"): + token = str(body.get(key) or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = str(getattr(settings, "API_TOKEN", "") or "").strip() + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + def _register_events(self) -> None: + try: + eventmanager.register(ChainEventType.NameRecognize)(self.on_chain_name_recognize) + if self._enabled: + eventmanager.enable_event_handler(self.on_chain_name_recognize) + else: + eventmanager.disable_event_handler(self.on_chain_name_recognize) + except Exception as exc: + logger.warning(f"[AI识别增强] 注册链式识别事件失败: {exc}") + + @staticmethod + def _extract_title_path(event_data: Any) -> Tuple[str, str]: + title = "" + path = "" + if isinstance(event_data, dict): + title = ( + event_data.get("title") + or event_data.get("name") + or event_data.get("org_string") + or "" + ) + path = ( + event_data.get("path") + or event_data.get("file_path") + or event_data.get("org_string") + or "" + ) + else: + title = ( + getattr(event_data, "title", "") + or getattr(event_data, "name", "") + or getattr(event_data, "org_string", "") + or "" + ) + path = ( + getattr(event_data, "path", "") + or getattr(event_data, "file_path", "") + or getattr(event_data, "org_string", "") + or "" + ) + return str(title or "").strip(), str(path or "").strip() + + def _build_meta_hint(self, raw_text: str) -> Dict[str, Any]: + try: + meta = MetaInfo(raw_text) + except Exception: + return {} + return { + "name": getattr(meta, "name", "") or "", + "year": getattr(meta, "year", "") or "", + "type": getattr(getattr(meta, "type", None), "to_agent", lambda: None)() or "", + "season": getattr(meta, "begin_season", None) or 0, + "episode": getattr(meta, "begin_episode", None) or 0, + "org_string": getattr(meta, "org_string", "") or "", + } + + @staticmethod + def _clean_guess_name(name: str) -> str: + text = str(name or "").strip() + if not text: + return "" + text = text.split("/")[0].strip().replace(".", " ") + return " ".join(text.split()) + + def _normalize_guess(self, guess: AIRecognitionGuess) -> AIRecognitionGuess: + name = self._clean_guess_name(guess.name) + year = str(guess.year or "").strip() + if not (len(year) == 4 and year.isdigit()): + year = "" + media_type = str(guess.media_type or "unknown").strip().lower() + if media_type not in {"movie", "tv"}: + media_type = "unknown" + season = max(0, self._safe_int(guess.season, 0)) + episode = max(0, self._safe_int(guess.episode, 0)) + confidence = min(1.0, max(0.0, self._safe_float(guess.confidence, 0.0))) + reason = str(guess.reason or "").strip() + return AIRecognitionGuess( + name=name, + year=year, + media_type=media_type, + season=season, + episode=episode, + confidence=confidence, + reason=reason, + ) + + def _sample_path(self) -> Path: + return self.get_data_path() / "failed_samples.jsonl" + + @staticmethod + def _sample_identity(payload: Dict[str, Any]) -> str: + return json.dumps( + { + "title": str(payload.get("title") or "").strip(), + "path": str(payload.get("path") or "").strip(), + "reason": str(payload.get("reason") or "").strip(), + }, + ensure_ascii=False, + sort_keys=True, + ) + + def _write_failed_samples(self, rows: List[Dict[str, Any]]) -> None: + sample_path = self._sample_path() + sample_path.parent.mkdir(parents=True, exist_ok=True) + trimmed = rows[-self._max_failed_samples:] + with sample_path.open("w", encoding="utf-8") as f: + for row in trimmed: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + def _record_failed_sample(self, payload: Dict[str, Any]) -> None: + if not self._save_failed_samples: + return + try: + rows = self._read_failed_samples(limit=1000) + rows.reverse() + identity = self._sample_identity(payload) + filtered = [row for row in rows if self._sample_identity(row) != identity] + filtered.append(payload) + self._write_failed_samples(filtered) + except Exception as exc: + logger.warning(f"[AI识别增强] 写入失败样本失败: {exc}") + + def _read_failed_samples(self, limit: int = 20) -> List[Dict[str, Any]]: + sample_path = self._sample_path() + if not sample_path.exists(): + return [] + rows: List[Dict[str, Any]] = [] + try: + with sample_path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except Exception: + continue + except Exception as exc: + logger.warning(f"[AI识别增强] 读取失败样本失败: {exc}") + return [] + if limit > 0: + rows = rows[-limit:] + rows.reverse() + return rows + + def _clear_failed_samples(self) -> int: + rows = self._read_failed_samples(limit=1000) + sample_path = self._sample_path() + if sample_path.exists(): + sample_path.unlink() + return len(rows) + + def _remove_failed_sample(self, sample_index: Optional[Any], limit: int = 1000) -> Dict[str, Any]: + rows = self._read_failed_samples(limit=max(1, min(limit, 1000))) + if not rows: + return {"removed": False, "message": "暂无失败样本", "removed_count": 0} + index = self._safe_int(sample_index, 0) + if index < 0: + index = 0 + if index >= len(rows): + return { + "removed": False, + "message": f"失败样本索引超出范围,当前共有 {len(rows)} 条", + "removed_count": 0, + } + removed_sample = dict(rows[index] or {}) + del rows[index] + if rows: + rows.reverse() + self._write_failed_samples(rows) + else: + self._clear_failed_samples() + return { + "removed": True, + "message": "success", + "removed_count": 1, + "remaining_count": len(rows), + "removed_sample": removed_sample, + "removed_sample_index": index, + } + + def _remove_failed_samples(self, sample_indexes: List[Any], limit: int = 1000) -> Dict[str, Any]: + rows = self._read_failed_samples(limit=max(1, min(limit, 1000))) + if not rows: + return {"removed": False, "message": "暂无失败样本", "removed_count": 0, "remaining_count": 0} + normalized_indexes = sorted( + {self._safe_int(index, -1) for index in (sample_indexes or []) if self._safe_int(index, -1) >= 0}, + reverse=True, + ) + valid_indexes = [index for index in normalized_indexes if index < len(rows)] + if not valid_indexes: + return { + "removed": False, + "message": "没有可移除的有效样本索引", + "removed_count": 0, + "remaining_count": len(rows), + } + removed_samples: List[Dict[str, Any]] = [] + for index in valid_indexes: + removed_samples.append(dict(rows[index] or {})) + del rows[index] + if rows: + rows.reverse() + self._write_failed_samples(rows) + else: + self._clear_failed_samples() + removed_samples.reverse() + return { + "removed": True, + "message": "success", + "removed_count": len(valid_indexes), + "remaining_count": len(rows), + "removed_sample_indexes": sorted(valid_indexes), + "removed_samples": removed_samples, + } + + def _resolve_failed_sample( + self, + sample_index: Optional[Any] = None, + limit: int = 100, + ) -> Tuple[Optional[int], Optional[Dict[str, Any]], str]: + samples = self._read_failed_samples(limit=max(1, min(limit, 200))) + if not samples: + return None, None, "暂无失败样本" + index = self._safe_int(sample_index, 0) + if index < 0: + index = 0 + if index >= len(samples): + return None, None, f"失败样本索引超出范围,当前共有 {len(samples)} 条" + row = dict(samples[index] or {}) + row["sample_index"] = index + return index, row, "" + + def _select_failed_sample_indexes( + self, + sample_indexes: Optional[List[Any]] = None, + limit: int = 10, + pool_limit: int = 200, + ) -> Tuple[List[int], List[Dict[str, Any]], str]: + current_samples = self._inject_sample_indices(self._read_failed_samples(limit=max(1, min(pool_limit, 1000)))) + if not current_samples: + return [], [], "暂无失败样本" + if isinstance(sample_indexes, list) and sample_indexes: + selected_indexes: List[int] = [] + seen = set() + for raw in sample_indexes: + idx = self._safe_int(raw, -1) + if idx < 0 or idx >= len(current_samples) or idx in seen: + continue + seen.add(idx) + selected_indexes.append(idx) + else: + selected_indexes = [int(sample.get("sample_index", 0)) for sample in current_samples[: max(1, min(limit, 50))]] + if not selected_indexes: + return [], current_samples, "没有可处理的有效样本索引" + return selected_indexes, current_samples, "" + + def _inject_sample_indices(self, samples: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + indexed: List[Dict[str, Any]] = [] + for idx, sample in enumerate(samples): + row = dict(sample or {}) + row["sample_index"] = idx + indexed.append(row) + return indexed + + def _summarize_sample(self, sample: Dict[str, Any]) -> Dict[str, Any]: + sample = dict(sample or {}) + guess = sample.get("guess") or {} + verified = sample.get("verified_media_info") or {} + inferred_target = { + "name": verified.get("title") or guess.get("name") or "", + "year": verified.get("year") or guess.get("year") or "", + "media_type": self._normalize_media_type(verified.get("type") or guess.get("media_type")), + "season": self._safe_int(guess.get("season"), 0), + "episode": self._safe_int(guess.get("episode"), 0), + "tmdb_id": self._safe_int(verified.get("tmdb_id"), 0), + } + return { + "sample_index": sample.get("sample_index"), + "title": sample.get("title"), + "path": sample.get("path"), + "reason": sample.get("reason"), + "guess_name": guess.get("name"), + "guess_confidence": self._safe_float(guess.get("confidence"), 0.0), + "verified_title": verified.get("title"), + "verified_year": verified.get("year"), + "verified_tmdb_id": verified.get("tmdb_id"), + "inferred_target": inferred_target, + "can_auto_suggest": bool(inferred_target["name"]), + } + + def _target_from_sample(self, sample: Dict[str, Any]) -> Dict[str, Any]: + summary = self._summarize_sample(sample) + return summary.get("inferred_target") or {} + + @staticmethod + def _normalize_reason_tag(reason: Any) -> str: + text = str(reason or "").strip() + if not text: + return "unknown" + if ":" in text: + return text.split(":", 1)[0].strip() or "unknown" + return text + + @staticmethod + def _sample_group_key(summary: Dict[str, Any]) -> str: + target = summary.get("inferred_target") or {} + title = ( + str(target.get("name") or "").strip() + or str(summary.get("verified_title") or "").strip() + or str(summary.get("guess_name") or "").strip() + or str(summary.get("title") or "").strip() + ) + media_type = str(target.get("media_type") or "unknown").strip().lower() + season = int(target.get("season") or 0) + episode = int(target.get("episode") or 0) + return json.dumps( + { + "title": title.lower(), + "media_type": media_type, + "season": season, + "episode": episode, + }, + ensure_ascii=False, + sort_keys=True, + ) + + @staticmethod + def _sample_display_name(summary: Dict[str, Any]) -> str: + target = summary.get("inferred_target") or {} + title = ( + str(target.get("name") or "").strip() + or str(summary.get("verified_title") or "").strip() + or str(summary.get("guess_name") or "").strip() + or str(summary.get("title") or "").strip() + ) + if not title: + return "未命名样本" + media_type = str(target.get("media_type") or "").strip().lower() + season = int(target.get("season") or 0) + episode = int(target.get("episode") or 0) + suffix = "" + if media_type == "tv" and (season or episode): + suffix = f" S{season:02d}E{episode:02d}" + return f"{title}{suffix}" + + def _build_sample_insights(self, samples: List[Dict[str, Any]], top: int = 10) -> Dict[str, Any]: + summaries = [self._summarize_sample(sample) for sample in samples] + reason_counter = Counter() + title_counter = Counter() + group_counter = Counter() + for summary in summaries: + reason_counter[self._normalize_reason_tag(summary.get("reason"))] += 1 + title_counter[self._sample_display_name(summary)] += 1 + group_counter[self._sample_group_key(summary)] += 1 + + actionable: List[Dict[str, Any]] = [] + for summary in summaries: + duplicate_count = group_counter[self._sample_group_key(summary)] + priority_reasons: List[str] = [] + score = 0 + if duplicate_count >= 2: + score += min(duplicate_count, 5) + priority_reasons.append(f"同类样本重复出现 {duplicate_count} 次") + if summary.get("verified_tmdb_id"): + score += 3 + priority_reasons.append("已有 TMDB 命中") + if summary.get("can_auto_suggest"): + score += 2 + priority_reasons.append("可直接生成识别词") + confidence = self._safe_float(summary.get("guess_confidence"), 0.0) + if 0 < confidence < self._confidence_threshold: + gap = round(self._confidence_threshold - confidence, 2) + score += 1 + priority_reasons.append(f"距注入阈值还差 {gap}") + row = dict(summary) + row["duplicate_count"] = duplicate_count + row["priority_score"] = score + row["priority_reasons"] = priority_reasons + actionable.append(row) + + actionable.sort( + key=lambda item: ( + -int(item.get("priority_score") or 0), + -int(item.get("duplicate_count") or 0), + -self._safe_float(item.get("guess_confidence"), 0.0), + int(item.get("sample_index") or 0), + ) + ) + + repeated_groups = [ + {"title": name, "count": count} + for name, count in title_counter.most_common(top) + if count >= 2 + ] + + return { + "total_count": len(summaries), + "reason_counts": [ + {"reason": reason, "count": count} + for reason, count in reason_counter.most_common(top) + ], + "top_titles": [ + {"title": title, "count": count} + for title, count in title_counter.most_common(top) + ], + "repeated_groups": repeated_groups, + "priority_samples": actionable[:top], + } + + def _render_sample_brief(self, samples: List[Dict[str, Any]], top: int = 5) -> str: + summaries = [self._summarize_sample(sample) for sample in samples[: max(1, min(top, 20))]] + if not summaries: + return "当前没有失败样本。" + lines = [f"失败样本 {len(samples)} 条,展示前 {len(summaries)} 条:"] + for summary in summaries: + label = self._sample_display_name(summary) + confidence = round(self._safe_float(summary.get("guess_confidence"), 0.0), 2) + can_suggest = "可建议" if summary.get("can_auto_suggest") else "需人工" + lines.append(f"{summary.get('sample_index')}. {label} | 置信度 {confidence} | {can_suggest}") + lines.append("下一步:可直接调用批量建议或批量复查接口。") + return "\n".join(lines) + + @staticmethod + def _render_batch_results_brief( + action_name: str, + requested_count: int, + success_count: int, + failed_count: int, + results: List[Dict[str, Any]], + ) -> str: + lines = [f"{action_name}:共处理 {requested_count} 条,成功 {success_count},失败 {failed_count}。"] + for item in results[:10]: + idx = item.get("sample_index") + if item.get("success"): + label = ( + ((item.get("source_sample") or {}).get("title")) + or ((item.get("target") or {}).get("name")) + or "样本" + ) + lines.append(f"{idx}. 成功 | {label}") + else: + lines.append(f"{idx}. 失败 | {item.get('message', '未知错误')}") + return "\n".join(lines) + + def _build_body_from_sample(self, body: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]], str]: + body = dict(body or {}) + title = str(body.get("title") or "").strip() + path = str(body.get("path") or "").strip() + sample_requested = body.get("use_latest_sample") or body.get("sample_index") is not None + if title or path: + return body, None, "" + if not sample_requested: + return body, None, "" + + sample_index, sample, message = self._resolve_failed_sample(body.get("sample_index"), limit=100) + if not sample: + return body, None, message + body["title"] = str(sample.get("title") or "").strip() + body["path"] = str(sample.get("path") or "").strip() + verified = sample.get("verified_media_info") or {} + guess = sample.get("guess") or {} + if not body.get("desired_name"): + body["desired_name"] = verified.get("title") or guess.get("name") or "" + if not body.get("desired_year"): + body["desired_year"] = verified.get("year") or guess.get("year") or "" + if not body.get("desired_media_type"): + body["desired_media_type"] = self._normalize_media_type( + verified.get("type") or guess.get("media_type") + ) + if body.get("desired_season") is None: + body["desired_season"] = guess.get("season") or 0 + if body.get("desired_episode") is None: + body["desired_episode"] = guess.get("episode") or 0 + if body.get("desired_tmdb_id") is None: + body["desired_tmdb_id"] = verified.get("tmdb_id") or 0 + body["sample_index"] = sample_index + return body, sample, "" + + def _build_prompt(self) -> ChatPromptTemplate: + return ChatPromptTemplate.from_messages( + [ + ( + "system", + """你是 MoviePilot 的影视文件名识别增强助手。 + +你的任务不是搜索 TMDB,也不是编造结果,而是根据文件名、路径和已有解析提示,尽量提炼出更适合 MoviePilot 二次识别的结构化信息。 + +规则: +1. 只依据输入内容推断,不要臆造不存在的信息。 +2. 如果不确定,请返回空标题,并把 media_type 设为 unknown,confidence 降低。 +3. title/name 只保留作品名,不要包含分辨率、制作组、音频编码、网盘标记等噪音。 +4. year 只有在比较确定时才给四位年份。 +5. 电影 season/episode 必须为 0。 +6. 剧集如果能确定季集就填写,否则保持 0。 +7. media_type 只能是 movie、tv、unknown。 +8. confidence 范围为 0 到 1。 +""", + ), + ( + "human", + """原始标题: +{title} + +原始路径: +{path} + +MoviePilot 当前基础解析提示: +{meta_hint} +""", + ), + ] + ) + + def _build_identifier_prompt(self) -> ChatPromptTemplate: + return ChatPromptTemplate.from_messages( + [ + ( + "system", + """你是 MoviePilot 自定义识别词规则助手。 + +你的任务是根据错误标题、当前解析结果和目标结果,生成尽量窄作用域、可直接用于 MoviePilot CustomIdentifiers 的规则。 + +支持格式只有四种: +1. 屏蔽词 +2. 替换词:被替换词 => 替换词 +3. 集偏移:前定位词 <> 后定位词 >> EP±N +4. 组合规则:被替换词 => 替换词 && 前定位词 <> 后定位词 >> EP±N + +硬性要求: +1. 运算符两侧必须保留空格: => 、 <> 、 >> 、 && +2. 优先生成窄作用域规则,尽量带发布组、年份、季集、分辨率等锚点 +3. 不要生成过宽的裸屏蔽词,比如 1080p、WEB-DL、字幕 +4. 如果需要强制绑 TMDB,可使用 {{[tmdbid=xxx;type=tv/movies;s=1;e=14]}} 这种替换词 +5. comment 不带 #,rule 里不要再包 markdown 或代码块 +6. 如果没有把握,请返回空 suggestions +""", + ), + ( + "human", + """原始标题: +{title} + +原始路径: +{path} + +MoviePilot 当前基础解析: +{meta_hint} + +AI 识别增强结果: +{guess} + +二次校验到的媒体信息摘要: +{verified_summary} + +希望修正成的目标结果: +{target} +""", + ), + ] + ) + + @staticmethod + def _run_async_compatible(value: Any) -> Any: + """ + 兼容 MoviePilot 新版 `LLMHelper.get_llm()` 的异步返回。 + 在同步上下文直接 asyncio.run;如果当前线程已有事件循环,则开一个短线程执行。 + """ + if not inspect.isawaitable(value): + return value + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(value) + + result: Dict[str, Any] = {} + error: Dict[str, BaseException] = {} + + def _worker() -> None: + try: + result["value"] = asyncio.run(value) + except BaseException as exc: # noqa: BLE001 + error["exc"] = exc + + thread = threading.Thread(target=_worker, daemon=True) + thread.start() + thread.join() + if "exc" in error: + raise error["exc"] + return result.get("value") + + def _get_llm(self): + llm = LLMHelper.get_llm(streaming=False) + return self._run_async_compatible(llm) + + def _invoke_llm(self, title: str, path: str) -> AIRecognitionGuess: + raw_text = path or title + meta_hint = self._build_meta_hint(raw_text) + llm = self._get_llm() + prompt = self._build_prompt() + chain = ( + prompt + | llm.with_structured_output(AIRecognitionGuess).with_retry(stop_after_attempt=self._max_retries) + ) + result: AIRecognitionGuess = chain.invoke( + { + "title": title, + "path": path, + "meta_hint": meta_hint, + }, + config={"configurable": {"timeout": self._request_timeout}}, + ) + return self._normalize_guess(result) + + @staticmethod + def _normalize_media_type(value: Any) -> str: + if value == MediaType.MOVIE: + return "movie" + if value == MediaType.TV: + return "tv" + text = str(value or "").strip().lower() + if text in {"movie", "movies", "电影"}: + return "movie" + if text in {"tv", "电视剧", "剧集"}: + return "tv" + return "unknown" + + def _build_target(self, body: Dict[str, Any], result: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + body = body or {} + result = result or {} + guess = result.get("guess") or {} + verified = result.get("verified_media_info") or {} + verified_type = self._normalize_media_type(verified.get("type")) + target = { + "name": str(body.get("desired_name") or verified.get("title") or guess.get("name") or "").strip(), + "year": str(body.get("desired_year") or verified.get("year") or guess.get("year") or "").strip(), + "media_type": self._normalize_media_type( + body.get("desired_media_type") or verified_type or guess.get("media_type") + ), + "season": self._safe_int( + body.get("desired_season"), + self._safe_int(guess.get("season"), 0), + ), + "episode": self._safe_int( + body.get("desired_episode"), + self._safe_int(guess.get("episode"), 0), + ), + "tmdb_id": self._safe_int(body.get("desired_tmdb_id") or verified.get("tmdb_id"), 0), + } + if len(target["year"]) != 4 or not target["year"].isdigit(): + target["year"] = "" + return target + + @staticmethod + def _compact_verified_summary(verified: Optional[Dict[str, Any]]) -> Dict[str, Any]: + verified = verified or {} + return { + "title": verified.get("title"), + "year": verified.get("year"), + "type": verified.get("type"), + "tmdb_id": verified.get("tmdb_id"), + "title_year": verified.get("title_year"), + "season_years": verified.get("season_years"), + "seasons": verified.get("seasons"), + "names": (verified.get("names") or [])[:8], + } + + @staticmethod + def _normalize_identifier_line(value: Any) -> str: + return " ".join(str(value or "").strip().split()) + + def _validate_identifier_rule(self, rule: str) -> bool: + rule = self._normalize_identifier_line(rule) + if not rule or rule.startswith("#"): + return False + if " => " in rule and " && " in rule and " >> " in rule and " <> " in rule: + return True + if " => " in rule: + return True + if " >> " in rule and " <> " in rule: + return True + return len(rule) >= 4 + + def _enrich_identifier_rule(self, rule: str, target: Dict[str, Any]) -> str: + rule = self._normalize_identifier_line(rule) + target_name = str((target or {}).get("name") or "").strip() + if not target_name or " => " not in rule: + return rule + left, right = rule.split(" => ", 1) + suffix = "" + replace_part = right + if " && " in right: + replace_part, extra = right.split(" && ", 1) + suffix = f" && {extra}" + if replace_part.startswith("{["): + replace_part = f"{target_name}{replace_part}" + return f"{left} => {replace_part}{suffix}" + + @staticmethod + def _clean_comment_line(comment: str) -> str: + text = str(comment or "").strip() + if not text: + return "" + return f"#{text.lstrip('#').strip()}" + + def _preview_custom_words(self, title: str, custom_words: List[str], target: Dict[str, Any]) -> Dict[str, Any]: + prepared_title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words) + meta = MetaInfo(title=title, custom_words=custom_words) + preview = { + "prepared_title": prepared_title, + "applied_words": apply_words or [], + "applied": bool(apply_words), + "name": getattr(meta, "name", "") or "", + "year": getattr(meta, "year", "") or "", + "media_type": self._normalize_media_type(getattr(meta, "type", None)), + "season": getattr(meta, "begin_season", None) or 0, + "episode": getattr(meta, "begin_episode", None) or 0, + } + if target: + matched = True + if target.get("name"): + matched = matched and (preview["name"].strip().lower() == str(target["name"]).strip().lower()) + if target.get("year"): + matched = matched and (preview["year"] == target["year"]) + if target.get("media_type") and target.get("media_type") != "unknown": + matched = matched and (preview["media_type"] == target["media_type"]) + if target.get("season"): + matched = matched and (preview["season"] == target["season"]) + if target.get("episode"): + matched = matched and (preview["episode"] == target["episode"]) + preview["matched_target"] = matched + return preview + + def _preview_identifier_rule(self, title: str, rule: str, target: Dict[str, Any]) -> Dict[str, Any]: + preview = self._preview_custom_words(title=title, custom_words=[rule], target=target) + preview["applied"] = rule in (preview.get("applied_words") or []) + return preview + + def _preview_current_identifiers(self, title: str, target: Dict[str, Any]) -> Dict[str, Any]: + custom_words = self._get_custom_identifiers() + preview = self._preview_custom_words(title=title, custom_words=custom_words, target=target) + preview["custom_identifier_count"] = len(custom_words) + preview["applied_count"] = len(preview.get("applied_words") or []) + return preview + + @staticmethod + def _match_recognize_result_to_target(result: Dict[str, Any], target: Dict[str, Any]) -> bool: + if not target: + return bool(result.get("success")) + guess = result.get("guess") or {} + matched = True + if target.get("name"): + matched = matched and (str(guess.get("name") or "").strip().lower() == str(target.get("name") or "").strip().lower()) + if target.get("year"): + matched = matched and (str(guess.get("year") or "") == str(target.get("year") or "")) + if target.get("media_type") and target.get("media_type") != "unknown": + matched = matched and (str(guess.get("media_type") or "unknown") == str(target.get("media_type") or "unknown")) + if target.get("season"): + matched = matched and (int(guess.get("season") or 0) == int(target.get("season") or 0)) + if target.get("episode"): + matched = matched and (int(guess.get("episode") or 0) == int(target.get("episode") or 0)) + return bool(result.get("success")) and matched + + def _replay_failed_sample(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + sample_index, sample, message = self._resolve_failed_sample( + body.get("sample_index"), + limit=1000, + ) + if not sample: + return {"success": False, "message": message} + title = str(sample.get("title") or "").strip() + path = str(sample.get("path") or "").strip() + target = self._target_from_sample(sample) + identifier_preview = self._preview_current_identifiers(title=title, target=target) + recognize_result = self._recognize(title=title, path=path, record_failed_sample=False) + resolved_by_identifiers = bool(identifier_preview.get("applied")) and bool(identifier_preview.get("matched_target")) + resolved_by_recognizer = self._match_recognize_result_to_target(recognize_result, target) + resolved = resolved_by_identifiers or resolved_by_recognizer + removal_result = None + if resolved and bool(body.get("remove_if_resolved")): + removal_result = self._remove_failed_sample(sample_index, limit=1000) + return { + "success": True, + "message": "success", + "data": { + "source_sample_index": sample_index, + "source_sample": sample, + "target": target, + "identifier_preview": identifier_preview, + "recognize_result": recognize_result, + "resolved_by_identifiers": resolved_by_identifiers, + "resolved_by_recognizer": resolved_by_recognizer, + "resolved": resolved, + "sample_removed": bool(removal_result and removal_result.get("removed")), + "sample_removal_result": removal_result, + }, + } + + def _replay_failed_samples(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + limit = max(1, min(self._safe_int(body.get("limit"), 10), 50)) + selected_indexes, _, message = self._select_failed_sample_indexes( + sample_indexes=body.get("sample_indexes"), + limit=limit, + pool_limit=200, + ) + if not selected_indexes: + return {"success": False, "message": message} + + replay_results: List[Dict[str, Any]] = [] + resolved_indexes: List[int] = [] + for sample_index in selected_indexes: + replay = self._replay_failed_sample( + { + "sample_index": sample_index, + "remove_if_resolved": False, + } + ) + if not replay.get("success"): + replay_results.append( + { + "sample_index": sample_index, + "success": False, + "message": replay.get("message", "复查失败"), + } + ) + continue + data = replay.get("data") or {} + replay_results.append( + { + "sample_index": sample_index, + "success": True, + "resolved": bool(data.get("resolved")), + "resolved_by_identifiers": bool(data.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(data.get("resolved_by_recognizer")), + "source_sample": data.get("source_sample"), + "target": data.get("target"), + "identifier_preview": data.get("identifier_preview"), + "recognize_result": data.get("recognize_result"), + } + ) + if data.get("resolved"): + resolved_indexes.append(sample_index) + + removal_result = None + if body.get("remove_if_resolved") and resolved_indexes: + removal_result = self._remove_failed_samples(resolved_indexes, limit=1000) + + success_count = sum(1 for item in replay_results if item.get("success")) + resolved_count = sum(1 for item in replay_results if item.get("resolved")) + unresolved_count = success_count - resolved_count + failed_count = len(replay_results) - success_count + return { + "success": True, + "message": "success", + "data": { + "requested_count": len(selected_indexes), + "success_count": success_count, + "resolved_count": resolved_count, + "unresolved_count": unresolved_count, + "failed_count": failed_count, + "sample_removed_count": int((removal_result or {}).get("removed_count") or 0), + "sample_removal_result": removal_result, + "results": replay_results, + }, + } + + def _suggest_identifiers_for_failed_samples(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + limit = max(1, min(self._safe_int(body.get("limit"), 5), 20)) + selected_indexes, _, message = self._select_failed_sample_indexes( + sample_indexes=body.get("sample_indexes"), + limit=limit, + pool_limit=200, + ) + if not selected_indexes: + return {"success": False, "message": message} + + results: List[Dict[str, Any]] = [] + success_count = 0 + for sample_index in selected_indexes: + suggest_body = dict(body) + suggest_body.pop("sample_indexes", None) + suggest_body["sample_index"] = sample_index + suggest_body["use_latest_sample"] = False + suggested = self._suggest_identifiers(suggest_body) + if suggested.get("success"): + success_count += 1 + data = suggested.get("data") or {} + results.append( + { + "sample_index": sample_index, + "success": True, + "summary": data.get("summary"), + "source_sample": data.get("source_sample"), + "target": data.get("target"), + "suggestions": data.get("suggestions") or [], + } + ) + else: + results.append( + { + "sample_index": sample_index, + "success": False, + "message": suggested.get("message", "建议生成失败"), + "data": suggested.get("data"), + } + ) + return { + "success": True, + "message": "success", + "data": { + "requested_count": len(selected_indexes), + "success_count": success_count, + "failed_count": len(selected_indexes) - success_count, + "brief": self._render_batch_results_brief( + action_name="批量建议", + requested_count=len(selected_indexes), + success_count=success_count, + failed_count=len(selected_indexes) - success_count, + results=results, + ), + "results": results, + }, + } + + def _apply_suggested_identifier_internal(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + if body.get("title") is None and body.get("path") is None: + body["use_latest_sample"] = True if body.get("use_latest_sample") is None else body.get("use_latest_sample") + suggested = self._suggest_identifiers(body) + if not suggested.get("success"): + return suggested + data = suggested.get("data") or {} + suggestions = data.get("suggestions") or [] + suggestion_index = self._safe_int(body.get("suggestion_index"), 0) + if suggestion_index < 0: + suggestion_index = 0 + if suggestion_index >= len(suggestions): + return {"success": False, "message": f"建议索引超出范围,当前共有 {len(suggestions)} 条"} + chosen = suggestions[suggestion_index] + applied = self._append_custom_identifiers(chosen.get("lines") or []) + should_remove_sample = bool( + self._auto_remove_applied_sample if body.get("remove_sample") is None else body.get("remove_sample") + ) + removal_result = None + source_sample = data.get("source_sample") or {} + if should_remove_sample and source_sample.get("sample_index") is not None: + removal_result = self._remove_failed_sample(source_sample.get("sample_index"), limit=1000) + return { + "success": True, + "message": "success", + "data": { + "chosen_suggestion": chosen, + "apply_result": applied, + "source_sample_index": source_sample.get("sample_index"), + "source_sample": source_sample, + "sample_removed": bool(removal_result and removal_result.get("removed")), + "sample_removal_result": removal_result, + "target": data.get("target"), + }, + } + + def _apply_suggested_identifiers_for_failed_samples(self, body: Dict[str, Any]) -> Dict[str, Any]: + body = dict(body or {}) + limit = max(1, min(self._safe_int(body.get("limit"), 5), 20)) + selected_indexes, _, message = self._select_failed_sample_indexes( + sample_indexes=body.get("sample_indexes"), + limit=limit, + pool_limit=200, + ) + if not selected_indexes: + return {"success": False, "message": message} + + results: List[Dict[str, Any]] = [] + success_count = 0 + removable_indexes: List[int] = [] + should_remove_samples = bool( + self._auto_remove_applied_sample if body.get("remove_sample") is None else body.get("remove_sample") + ) + for sample_index in selected_indexes: + apply_body = dict(body) + apply_body.pop("sample_indexes", None) + apply_body["sample_index"] = sample_index + apply_body["use_latest_sample"] = False + apply_body["remove_sample"] = False + applied = self._apply_suggested_identifier_internal(apply_body) + if applied.get("success"): + success_count += 1 + data = applied.get("data") or {} + if should_remove_samples: + removable_indexes.append(sample_index) + results.append( + { + "sample_index": sample_index, + "success": True, + "source_sample": data.get("source_sample"), + "target": data.get("target"), + "chosen_suggestion": data.get("chosen_suggestion"), + "apply_result": data.get("apply_result"), + "sample_removed": False, + } + ) + else: + results.append( + { + "sample_index": sample_index, + "success": False, + "message": applied.get("message", "写入失败"), + "data": applied.get("data"), + } + ) + removal_result = None + if should_remove_samples and removable_indexes: + removal_result = self._remove_failed_samples(removable_indexes, limit=1000) + removed_index_set = set((removal_result or {}).get("removed_sample_indexes") or []) + for item in results: + if item.get("success"): + item["sample_removed"] = item.get("sample_index") in removed_index_set + return { + "success": True, + "message": "success", + "data": { + "requested_count": len(selected_indexes), + "success_count": success_count, + "failed_count": len(selected_indexes) - success_count, + "sample_removed_count": int((removal_result or {}).get("removed_count") or 0), + "sample_removal_result": removal_result, + "brief": self._render_batch_results_brief( + action_name="批量写入", + requested_count=len(selected_indexes), + success_count=success_count, + failed_count=len(selected_indexes) - success_count, + results=results, + ), + "results": results, + }, + } + + def _build_exact_identifier_fallback(self, title: str, target: Dict[str, Any]) -> Optional[Dict[str, Any]]: + target_name = str((target or {}).get("name") or "").strip() + tmdb_id = self._safe_int((target or {}).get("tmdb_id"), 0) + media_type = self._normalize_media_type((target or {}).get("media_type")) + if not title or not target_name or not tmdb_id or media_type == "unknown": + return None + replace = target_name + target_year = str((target or {}).get("year") or "").strip() + if len(target_year) == 4 and target_year.isdigit(): + replace += f".{target_year}" + replace += f"{{[tmdbid={tmdb_id};type={'tv' if media_type == 'tv' else 'movie'}" + if media_type == "tv" and self._safe_int(target.get("season"), 0): + replace += f";s={self._safe_int(target.get('season'), 0)}" + if media_type == "tv" and self._safe_int(target.get("episode"), 0): + replace += f";e={self._safe_int(target.get('episode'), 0)}" + replace += "]}" + rule = f"{re.escape(title)} => {replace}" + preview = self._preview_identifier_rule(title=title, rule=rule, target=target) + if not preview.get("applied"): + return None + return { + "comment": "当 AI 建议无法稳定通过本地预演时,使用精确标题绑定规则直接固定到目标 TMDB 与季集", + "comment_line": "#当 AI 建议无法稳定通过本地预演时,使用精确标题绑定规则直接固定到目标 TMDB 与季集", + "rule": rule, + "confidence": 0.95, + "reason": "精确匹配当前标题并强制绑定目标 TMDB / 季集,作用域最窄,稳定性最高。", + "preview": preview, + "lines": [ + "#当 AI 建议无法稳定通过本地预演时,使用精确标题绑定规则直接固定到目标 TMDB 与季集", + rule, + ], + } + + def _invoke_identifier_llm( + self, + title: str, + path: str, + result: Dict[str, Any], + target: Dict[str, Any], + ) -> IdentifierSuggestionBundle: + llm = self._get_llm() + prompt = self._build_identifier_prompt() + chain = ( + prompt + | llm.with_structured_output(IdentifierSuggestionBundle).with_retry( + stop_after_attempt=self._max_retries + ) + ) + bundle: IdentifierSuggestionBundle = chain.invoke( + { + "title": title, + "path": path, + "meta_hint": self._build_meta_hint(path or title), + "guess": result.get("guess") or {}, + "verified_summary": self._compact_verified_summary(result.get("verified_media_info")), + "target": target, + }, + config={"configurable": {"timeout": self._request_timeout}}, + ) + return bundle + + def _suggest_identifiers(self, body: Dict[str, Any]) -> Dict[str, Any]: + body, source_sample, sample_message = self._build_body_from_sample(body) + if sample_message: + return {"success": False, "message": sample_message} + title = str(body.get("title") or "").strip() + path = str(body.get("path") or "").strip() + if not title and path: + title = Path(path).name + if not title: + return {"success": False, "message": "标题为空"} + + result = self._recognize(title=title, path=path, record_failed_sample=False) + target = self._build_target(body, result=result) + invoke_error = "" + try: + bundle = self._invoke_identifier_llm(title=title, path=path, result=result, target=target) + except Exception as exc: + bundle = IdentifierSuggestionBundle( + summary="识别词建议模型暂不可用,已自动回退到精确规则兜底。", + suggestions=[], + ) + invoke_error = str(exc) + + cleaned: List[Dict[str, Any]] = [] + for item in bundle.suggestions: + rule = self._enrich_identifier_rule(item.rule, target=target) + if not self._validate_identifier_rule(rule): + continue + comment_line = self._clean_comment_line(item.comment) + preview = self._preview_identifier_rule(title=title, rule=rule, target=target) + if not preview.get("applied"): + continue + if target and any(target.values()) and preview.get("matched_target") is False: + continue + cleaned.append( + { + "comment": item.comment.strip(), + "comment_line": comment_line, + "rule": rule, + "confidence": min(1.0, max(0.0, self._safe_float(item.confidence, 0.0))), + "reason": str(item.reason or "").strip(), + "preview": preview, + "lines": [line for line in [comment_line, rule] if line], + } + ) + + if not cleaned: + fallback = self._build_exact_identifier_fallback(title=title, target=target) + if fallback: + if invoke_error: + fallback["reason"] = f"{fallback.get('reason', '')} 当前识别词建议模型不可用,已自动切到精确规则兜底。".strip() + cleaned.append(fallback) + + if not cleaned: + return { + "success": False, + "message": f"识别词建议生成失败: {invoke_error}" if invoke_error else "没有生成可直接使用的识别词规则", + "data": { + "summary": bundle.summary, + "target": target, + "recognize_result": result, + }, + } + return { + "success": True, + "message": "success", + "data": { + "summary": bundle.summary, + "source_sample_index": (source_sample or {}).get("sample_index"), + "source_sample": source_sample, + "target": target, + "recognize_result": result, + "suggestions": cleaned, + }, + } + + def _get_custom_identifiers(self) -> List[str]: + if not self._systemconfig: + self._systemconfig = SystemConfigOper() + return self._systemconfig.get(SystemConfigKey.CustomIdentifiers) or [] + + def _append_custom_identifiers(self, lines: List[str]) -> Dict[str, Any]: + existing = self._get_custom_identifiers() + added: List[str] = [] + for line in lines: + normalized = str(line or "").rstrip() + if not normalized: + continue + if normalized in existing or normalized in added: + continue + added.append(normalized) + if added: + merged = existing + added + self._systemconfig.set(SystemConfigKey.CustomIdentifiers, merged) + return { + "added": added, + "added_count": len(added), + "total_count": len(self._get_custom_identifiers()), + } + + def _verify_guess(self, title: str, path: str, guess: AIRecognitionGuess) -> Optional[Dict[str, Any]]: + if not guess.name: + return None + try: + raw_text = path or title or guess.name + meta = MetaInfo(raw_text) + meta.name = guess.name + meta.year = guess.year or None + meta.begin_season = guess.season or None + meta.begin_episode = guess.episode or None + if guess.media_type == "tv" or meta.begin_season or meta.begin_episode: + meta.type = MediaType.TV + elif guess.media_type == "movie": + meta.type = MediaType.MOVIE + mediainfo = MediaChain().recognize_media(meta=meta, cache=False) + if not mediainfo: + return None + return mediainfo.to_dict() + except Exception as exc: + if self._debug: + logger.warning(f"[AI识别增强] 二次校验失败: {exc}") + return None + + def _recognize(self, title: str, path: str = "", record_failed_sample: bool = True) -> Dict[str, Any]: + title = str(title or "").strip() + path = str(path or "").strip() + if not title and path: + title = Path(path).name + if not title: + return {"success": False, "message": "标题为空"} + try: + guess = self._invoke_llm(title, path) + except Exception as exc: + if record_failed_sample: + self._record_failed_sample( + { + "title": title, + "path": path, + "meta_hint": self._build_meta_hint(path or title), + "reason": f"llm_error:{exc}", + } + ) + return {"success": False, "message": f"LLM 调用失败: {exc}"} + + verified = self._verify_guess(title, path, guess) + passed = bool(guess.name and guess.confidence >= self._confidence_threshold) + if not passed and record_failed_sample: + self._record_failed_sample( + { + "title": title, + "path": path, + "meta_hint": self._build_meta_hint(path or title), + "guess": guess.model_dump(), + "verified_media_info": self._compact_verified_summary(verified), + "reason": "low_confidence_or_empty_name", + } + ) + return { + "success": passed, + "message": "success" if passed else "识别结果置信度不足,已放弃注入", + "guess": guess.model_dump(), + "verified_media_info": verified, + } + + def on_chain_name_recognize(self, event) -> None: + if not self._enabled: + return + event_data = getattr(event, "event_data", None) or {} + title, path = self._extract_title_path(event_data) + if not title and not path: + return + result = self._recognize(title=title, path=path) + if not result.get("success"): + if self._debug: + logger.info(f"[AI识别增强] 跳过注入: {title or path} - {result.get('message')}") + return + guess = result.get("guess") or {} + if isinstance(event_data, dict): + if event_data.get("source_plugin"): + if self._debug: + logger.info(f"[AI识别增强] 已有插件处理识别结果,跳过覆盖: {event_data.get('source_plugin')}") + return + event_data["name"] = guess.get("name", "") + event_data["year"] = guess.get("year", "") + event_data["season"] = guess.get("season", 0) + event_data["episode"] = guess.get("episode", 0) + event_data["source_plugin"] = "AIRecognizerEnhancer" + event_data["confidence"] = guess.get("confidence", 0) + event_data["reason"] = guess.get("reason", "") + + async def api_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + llm_ready = bool(getattr(settings, "LLM_API_KEY", None)) + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "llm_ready": llm_ready, + "llm_provider": getattr(settings, "LLM_PROVIDER", ""), + "llm_model": getattr(settings, "LLM_MODEL", ""), + "confidence_threshold": self._confidence_threshold, + "request_timeout": self._request_timeout, + }, + } + + async def api_recognize(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + title = str(body.get("title") or "").strip() + path = str(body.get("path") or "").strip() + result = self._recognize(title=title, path=path) + return { + "success": result.get("success", False), + "message": result.get("message", ""), + "data": { + "guess": result.get("guess"), + "verified_media_info": result.get("verified_media_info"), + }, + } + + async def api_failed_samples(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + limit = max(1, min(limit, 100)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=limit)) + return { + "success": True, + "data": { + "count": len(samples), + "samples": samples, + }, + } + + async def api_sample_worklist(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + limit = max(1, min(limit, 100)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=limit)) + worklist = [self._summarize_sample(sample) for sample in samples] + return { + "success": True, + "data": { + "count": len(worklist), + "samples": worklist, + }, + } + + async def api_sample_insights(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 50) + limit = max(1, min(limit, 200)) + top = self._safe_int(request.query_params.get("top"), 10) + top = max(1, min(top, 20)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=limit)) + insights = self._build_sample_insights(samples, top=top) + return { + "success": True, + "data": insights, + } + + async def api_sample_brief(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 5) + limit = max(1, min(limit, 20)) + samples = self._inject_sample_indices(self._read_failed_samples(limit=100)) + return { + "success": True, + "data": { + "count": len(samples), + "text": self._render_sample_brief(samples, top=limit), + }, + } + + async def api_suggest_identifiers(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._suggest_identifiers(body) + + async def api_apply_identifiers(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + identifiers = body.get("identifiers") or [] + if not isinstance(identifiers, list): + return {"success": False, "message": "identifiers 必须是数组"} + result = self._append_custom_identifiers([str(line or "") for line in identifiers]) + return { + "success": True, + "message": "success", + "data": result, + } + + async def api_clear_failed_samples(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + cleared = self._clear_failed_samples() + return { + "success": True, + "message": "success", + "data": { + "cleared_count": cleared, + }, + } + + async def api_remove_failed_sample(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + result = self._remove_failed_sample(body.get("sample_index"), limit=1000) + if not result.get("removed"): + return {"success": False, "message": result.get("message", "移除失败"), "data": result} + return { + "success": True, + "message": "success", + "data": result, + } + + async def api_replay_failed_sample(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._replay_failed_sample(body) + + async def api_replay_failed_samples(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._replay_failed_samples(body) + + async def api_suggest_identifiers_from_sample(self, request: Request): + body = await request.json() + body["use_latest_sample"] = True if body.get("use_latest_sample") is None else body.get("use_latest_sample") + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + if body.get("sample_index") is None and body.get("use_latest_sample") is False: + body["use_latest_sample"] = True + return self._suggest_identifiers(body) + + async def api_suggest_identifiers_for_failed_samples(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._suggest_identifiers_for_failed_samples(body) + + async def api_apply_suggested_identifier(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._apply_suggested_identifier_internal(body) + + async def api_apply_suggested_identifiers_for_failed_samples(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + return self._apply_suggested_identifiers_for_failed_samples(body) + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/health", + "endpoint": self.api_health, + "methods": ["GET"], + "summary": "检查 AI识别增强 的运行状态", + }, + { + "path": "/recognize", + "endpoint": self.api_recognize, + "methods": ["POST"], + "summary": "用当前 LLM 对失败标题做一次本地结构化识别测试", + }, + { + "path": "/failed_samples", + "endpoint": self.api_failed_samples, + "methods": ["GET"], + "summary": "查看最近保存的低置信度失败样本", + }, + { + "path": "/sample_worklist", + "endpoint": self.api_sample_worklist, + "methods": ["GET"], + "summary": "返回适合智能体使用的失败样本摘要列表", + }, + { + "path": "/sample_insights", + "endpoint": self.api_sample_insights, + "methods": ["GET"], + "summary": "汇总失败样本原因、重复问题和优先处理样本", + }, + { + "path": "/sample_brief", + "endpoint": self.api_sample_brief, + "methods": ["GET"], + "summary": "返回适合智能体低 token 消费的失败样本精简摘要", + }, + { + "path": "/suggest_identifiers", + "endpoint": self.api_suggest_identifiers, + "methods": ["POST"], + "summary": "根据标题和目标结果生成 MoviePilot 自定义识别词建议", + }, + { + "path": "/suggest_identifiers_from_sample", + "endpoint": self.api_suggest_identifiers_from_sample, + "methods": ["POST"], + "summary": "直接基于最近失败样本或指定样本生成自定义识别词建议", + }, + { + "path": "/suggest_identifiers_for_failed_samples", + "endpoint": self.api_suggest_identifiers_for_failed_samples, + "methods": ["POST"], + "summary": "批量为失败样本生成自定义识别词建议", + }, + { + "path": "/apply_identifiers", + "endpoint": self.api_apply_identifiers, + "methods": ["POST"], + "summary": "将确认后的自定义识别词追加写入系统 CustomIdentifiers", + }, + { + "path": "/clear_failed_samples", + "endpoint": self.api_clear_failed_samples, + "methods": ["POST"], + "summary": "清空失败样本文件", + }, + { + "path": "/remove_failed_sample", + "endpoint": self.api_remove_failed_sample, + "methods": ["POST"], + "summary": "按索引移除单条失败样本", + }, + { + "path": "/replay_failed_sample", + "endpoint": self.api_replay_failed_sample, + "methods": ["POST"], + "summary": "按当前识别词和当前识别器复查某条失败样本,并可在确认修复后自动出队", + }, + { + "path": "/replay_failed_samples", + "endpoint": self.api_replay_failed_samples, + "methods": ["POST"], + "summary": "批量复查失败样本,并可在确认修复后批量出队", + }, + { + "path": "/apply_suggested_identifier", + "endpoint": self.api_apply_suggested_identifier, + "methods": ["POST"], + "summary": "直接把最近失败样本或指定样本生成的建议规则写入 CustomIdentifiers,并按需移除该样本", + }, + { + "path": "/apply_suggested_identifiers_for_failed_samples", + "endpoint": self.api_apply_suggested_identifiers_for_failed_samples, + "methods": ["POST"], + "summary": "批量把失败样本生成的建议规则写入 CustomIdentifiers,并按需移除对应样本", + }, + ] + + def get_page(self) -> List[dict]: + llm_ready = bool(getattr(settings, "LLM_API_KEY", None)) + failed_samples_count = len(self._read_failed_samples(limit=200)) + custom_identifiers_count = len(self._get_custom_identifiers()) + llm_provider = getattr(settings, "LLM_PROVIDER", "—") + llm_model = getattr(settings, "LLM_MODEL", "—") + + def stat_card(title: str, value: Any, subtitle: str = "") -> dict: + content = [ + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mb-1"}, + "text": title, + }, + { + "component": "div", + "props": {"class": "text-h6 font-weight-bold"}, + "text": str(value), + }, + ] + if subtitle: + content.append( + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mt-1"}, + "text": subtitle, + } + ) + return { + "component": "VCard", + "props": {"variant": "tonal", "class": "pa-4 h-100"}, + "content": content, + } + + return [ + { + "component": "VContainer", + "props": {"fluid": True, "class": "pa-0"}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "class": "mb-4", + "title": "本地 LLM 识别兜底", + "text": "复用 MoviePilot 当前 LLM 配置,在原生识别失败时做结构化兜底,并把结果交回 MoviePilot 继续二次识别。", + }, + }, + { + "component": "VRow", + "props": {"dense": True, "class": "mb-2"}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("当前状态", "已启用" if self._enabled else "未启用")], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("LLM 可用", "是" if llm_ready else "否", f"{llm_provider} / {llm_model}")], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("失败样本", f"{failed_samples_count} 条", f"上限 {self._max_failed_samples} 条")], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [stat_card("自定义识别词", f"{custom_identifiers_count} 条", "系统 CustomIdentifiers")], + }, + ], + }, + { + "component": "VRow", + "props": {"dense": True}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"variant": "outlined", "class": "pa-4 h-100"}, + "content": [ + { + "component": "div", + "props": {"class": "text-subtitle-1 font-weight-bold mb-2"}, + "text": "识别兜底", + }, + { + "component": "div", + "props": {"class": "text-body-2 text-medium-emphasis"}, + "text": "在 Chain NameRecognize 阶段回写 name / year / season / episode,供 MoviePilot 继续原生二次识别。", + }, + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mt-3"}, + "text": f"置信度阈值:{self._confidence_threshold};请求超时:{self._request_timeout} 秒", + }, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "props": {"variant": "outlined", "class": "pa-4 h-100"}, + "content": [ + { + "component": "div", + "props": {"class": "text-subtitle-1 font-weight-bold mb-2"}, + "text": "识别词闭环", + }, + { + "component": "div", + "props": {"class": "text-body-2 text-medium-emphasis"}, + "text": "失败样本可生成 CustomIdentifiers 建议,并按需追加写入系统配置。", + }, + { + "component": "div", + "props": {"class": "text-caption text-medium-emphasis mt-3"}, + "text": f"写入后自动移除样本:{'是' if self._auto_remove_applied_sample else '否'}", + }, + ], + } + ], + }, + ], + }, + ], + } + ] + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + return "vuetify", None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + form = [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "当前版本已改为直接复用 MoviePilot 当前启用的 LLM 配置,在原生识别失败后做本地结构化兜底。", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "enabled", "label": "启用 AI识别增强"}, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "debug", "label": "调试模式"}, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "save_failed_samples", "label": "保存低置信度样本"}, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "confidence_threshold", + "label": "置信度阈值", + "type": "number", + "hint": "低于该值的结果不注入 MoviePilot,默认 0.65", + "persistent-hint": True, + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "request_timeout", + "label": "LLM 请求超时(秒)", + "type": "number", + "hint": "默认 25 秒", + "persistent-hint": True, + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "max_retries", + "label": "结构化输出重试次数", + "type": "number", + "hint": "默认 2 次", + "persistent-hint": True, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "max_failed_samples", + "label": "失败样本保留上限", + "type": "number", + "hint": "默认保留最近 200 条,并对重复样本自动去重", + "persistent-hint": True, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "auto_remove_applied_sample", + "label": "写入识别词后自动移除对应失败样本", + }, + } + ], + } + ], + }, + ], + } + ] + return form, { + "enabled": False, + "debug": False, + "confidence_threshold": 0.65, + "request_timeout": 25, + "max_retries": 2, + "save_failed_samples": True, + "max_failed_samples": 200, + "auto_remove_applied_sample": True, + } diff --git a/AgentResourceOfficer/ARCHITECTURE.md b/AgentResourceOfficer/ARCHITECTURE.md new file mode 100644 index 0000000..8734421 --- /dev/null +++ b/AgentResourceOfficer/ARCHITECTURE.md @@ -0,0 +1,223 @@ +# Agent影视助手架构草案 + +`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。 + +## 设计目标 + +- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口” +- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务 +- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层 + +## 模块分层 + +### 1. adapters + +负责不同外部入口和外部平台接入: + +- `feishu` +- `hdhive` +- `quark` +- `pansou` +- 后续 `agent_tool` + +原则: + +- 只负责协议和输入输出转换 +- 不负责复杂业务编排 + +### 2. services + +负责核心业务能力: + +- `search_service` +- `unlock_service` +- `transfer_service` +- `signin_service` +- `user_service` + +原则: + +- 统一返回结构 +- 尽量不感知飞书、页面、CLI 等具体入口 + +### 3. session + +负责交互上下文: + +- 搜索候选缓存 +- 翻页状态 +- 选择上下文 +- 详情/审查补充信息(已支持候选页按需补主演) + +原则: + +- 入口层共享同一套会话数据 +- 后续优先支持内存 + 轻量持久化 + +### 4. models + +负责统一数据模型: + +- 搜索候选 +- 资源条目 +- 解锁结果 +- 转存结果 +- 用户信息 + +目标: + +- 减少旧插件之间字段名不一致的问题 + +## 首期配置模型 + +### 基础 + +- `enabled` +- `notify` +- `debug` + +### 影巢 + +- `hdhive_base_url` +- `hdhive_api_key` +- `hdhive_default_path` +- `hdhive_candidate_page_size` + +### 夸克 + +- `quark_cookie` +- `quark_default_path` +- `quark_timeout` +- `quark_auto_import_cookiecloud` + +### 飞书 + +- `feishu_enabled` +- `feishu_app_id` +- `feishu_app_secret` +- `feishu_verification_token` +- `feishu_allow_all` +- `feishu_allowed_chat_ids` +- `feishu_allowed_user_ids` + +### 智能体 / 工具层预留 + +- `agent_tools_enabled` +- `tool_debug` + +## 迁移映射 + +### 从 `QuarkShareSaver` + +优先迁入: + +- 分享链接解析 +- 目录创建 +- 转存执行 +- CookieCloud 自动导入 + +当前已开始拆出: + +- `services/quark_transfer.py` + +### 从 `P115StrmHelper` 协同层 + +当前已开始拆出: + +- `services/p115_transfer.py` + +### 从 `HdhiveOpenApi` + +随后迁入: + +- 搜索 +- 候选解析 +- 解锁 +- 用户信息 +- 配额 +- 分享管理 + +当前已开始拆出: + +- `services/hdhive_openapi.py` + +### 从 `HDHiveDailySign` + +补入: + +- 普通签到 +- 赌狗签到 +- 自动登录与状态记录 + +### 从 `FeishuCommandBridgeLong` + +最后收口: + +- 飞书长连接入口 +- 自然语言别名解析 +- 搜索/选择会话衔接 + +## 暂不迁入的内容 + +- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手` + +> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。 + +## P115StrmHelper 兼容补丁 + +新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。 + +仓库提供了幂等补丁脚本: + +```bash +MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh +``` + +补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。 + +## 115 轻量直转层 + +`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录: + +- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置 +- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive` +- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper` +- 直转失败时回退 `P115StrmHelper` 的分享转存主流程 + +这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。 +这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。 + +## 首个里程碑 + +第一个可用版本只追求三件事: + +1. 夸克分享链接直接转存 +2. 影巢搜索并解锁 +3. 飞书调用同一套执行服务 + +当前进度: + +- 已拆出夸克执行服务 +- 已拆出影巢基础 OpenAPI 服务 +- 已拆出 115 转存执行服务 +- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick) +- 主插件已具备: + - 夸克健康检查 + - 夸克转存 + - 影巢健康检查 + - 影巢搜索 + - 影巢关键词候选搜索 + - 影巢解锁 + - 115 依赖健康检查 + - 115 分享转存 + - 影巢解锁后自动路由到夸克执行层 + - 影巢解锁后自动路由到 115 执行层 + - 影巢会话搜索与按编号继续选择 + - 盘搜搜索与按编号继续执行 +- 统一智能入口对直链、盘搜、影巢三类输入的会话分流 +- 原生 Agent Tool 直接发起和轮询 115 扫码登录 +- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录` +- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行 +- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话 +- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API +- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令 diff --git a/AgentResourceOfficer/README.md b/AgentResourceOfficer/README.md new file mode 100644 index 0000000..59122a0 --- /dev/null +++ b/AgentResourceOfficer/README.md @@ -0,0 +1,212 @@ +# Agent影视助手 + +`Agent影视助手` 是这个仓库的主线插件,重点解决一件事: + +把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。 + +当前版本:`0.2.68` + +当前 helper 版本:`0.1.46` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +如果你是第一次用这个仓库,先把这个插件跑通就够了。 + +--- + +## 适合谁 + +- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。 +- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。 +- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。 +- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。 +- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。 + +--- + +## 两种主要用法 + +### 1. 不使用外部智能体,只用飞书命令入口 + +如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。 + +配好后,直接在飞书里发: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。 + +### 2. 使用外部智能体 + +如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。 + +外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。 + +重点文档: + +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` + +### MCP 和 Skill 怎么分工 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。 + +- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。 +- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情和 Cookie 修复。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +--- + +## 核心命令 + +### 搜索 + +| 命令 | 作用 | +|---|---| +| `搜索 <片名>` | 默认走盘搜 | +| `盘搜搜索 <片名>` | 只看盘搜 | +| `影巢搜索 <片名>` | 只看影巢 | +| `云盘搜索 <片名>` | 盘搜 + 影巢 | +| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 | + +### 转存 / 下载 + +| 命令 | 作用 | +|---|---| +| `转存 <片名>` | 默认等同 `115转存 <片名>` | +| `115转存 <片名>` | 搜索后优先转存到 115 | +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先生成下载计划 | + +注意: + +- `转存 <片名>` 默认是 115,不会自动改成夸克。 +- 只有明确说 `夸克转存 <片名>` 才走夸克。 +- `下载 <片名>` 是 PT 下载,不是云盘转存。 +- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。 +- 真正下载、转存、解锁、清空目录这类写入动作,都应先经过明确确认。 + +### 选择 / 翻页 + +```text +1 +1详情 +下载1 +n +``` + +- `1`:继续处理当前第 1 条结果。 +- `1详情`:查看第 1 条详情。 +- `下载1`:给第 1 条 PT 结果生成下载计划。 +- `n`:下一页。 + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 主要能力 + +### 云盘资源 + +- 盘搜搜索 +- 影巢搜索 / 解锁 +- 115 转存 +- 夸克转存 +- 云盘更新检查 +- 编号选择、详情、翻页 +- 智能建议与候选推荐 + +### MoviePilot 原生能力 + +- MP / PT 搜索 +- PT 下载计划 +- 订阅 +- 下载任务 +- 下载历史 +- 入库历史 +- 站点状态 / 下载器状态 +- 热门探索 / 推荐 + +### 账号与修复 + +- 115 扫码登录 / 状态检查 +- 影巢签到 / 签到日志 +- 影巢 Cookie 修复 +- 夸克 Cookie 修复 + +Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。 + +--- + +## 和旧插件的关系 + +`Agent影视助手` 是把旧的分散能力收成一条主线。 + +| 旧插件 | 主要用途 | 现在建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 | +| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 | + +旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。 + +--- + +## 新手最容易踩的坑 + +### 外部智能体乱改命令 + +常见错误: + +- 把 `云盘搜索` 偷换成 `盘搜搜索` +- 把 `下载` 当成云盘转存 +- 把 `15详情` 当成 `选择 15` +- 重排插件返回的编号 + +解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说: + +```text +校准影视技能 +``` + +### 跨机器地址填错 + +如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址: + +```text +ARO_BASE_URL=http://你的NAS地址:3000 +``` + +不要填 `127.0.0.1`,那只代表智能体自己这台机器。 + +### 夸克失败不一定是 Cookie 失效 + +分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。 + +--- + +## 进一步阅读 + +- [插件安装说明](../docs/PLUGIN_INSTALL.md) +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` diff --git a/AgentResourceOfficer/__init__.py b/AgentResourceOfficer/__init__.py new file mode 100644 index 0000000..53ffc7d --- /dev/null +++ b/AgentResourceOfficer/__init__.py @@ -0,0 +1,26967 @@ +import asyncio +import concurrent.futures +import copy +import hmac +import json +import os +import re +import threading +import time +import uuid +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen + +from fastapi import Request +try: + from apscheduler.triggers.cron import CronTrigger +except Exception: + CronTrigger = None +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.log import logger +except Exception: + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None +try: + from app.agent.tools.manager import moviepilot_tool_manager +except Exception: + moviepilot_tool_manager = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None +try: + from app import schemas as app_schemas +except Exception: + app_schemas = None +from app.plugins import _PluginBase + +from .services.hdhive_openapi import HDHiveOpenApiService +from .services.p115_transfer import P115TransferService +from .services.quark_transfer import QuarkTransferService +from .feishu_channel import FeishuChannel +from .agenttool import ( + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantMaintainTool, + AssistantPickTool, + AssistantPlansClearTool, + AssistantPlansTool, + AssistantPulseTool, + AssistantReadinessTool, + AssistantRecoverTool, + AssistantRequestTemplatesTool, + AssistantRouteTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSelfcheckTool, + AssistantStartupTool, + AssistantToolboxTool, + AssistantWorkflowTool, + FeishuChannelHealthTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + P115CancelPendingTool, + P115PendingTool, + P115QRCodeCheckTool, + P115QRCodeStartTool, + P115ResumePendingTool, + P115StatusTool, + ShareRouteTool, +) + + +class _JsonRequestShim: + def __init__(self, request: Request, body: Dict[str, Any], method: str = "POST") -> None: + self.method = str(method or "POST").upper() + self.headers = request.headers + self.query_params = request.query_params + self._body = body + + async def json(self) -> Dict[str, Any]: + return self._body + + +class _RequestContextShim: + def __init__(self, headers: Optional[Dict[str, Any]] = None, query_params: Optional[Dict[str, Any]] = None) -> None: + default_headers = dict(headers or {}) + if settings is not None and not default_headers.get("Authorization"): + token = str(getattr(settings, "API_TOKEN", "") or "").strip() + if token: + default_headers["Authorization"] = f"Bearer {token}" + self.headers = default_headers + self.query_params = query_params or {} + + +class AgentResourceOfficer(_PluginBase): + plugin_name = "Agent影视助手" + plugin_desc = "统一承接影巢搜索/解锁、115 转存、夸克转存、飞书入口与智能体接口的资源工作流主插件。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png" + plugin_version = "0.2.68" + request_templates_schema_version = "request_templates.v1" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "agentresourceofficer_" + plugin_order = 40 + auth_level = 1 + + _enabled = False + _notify = True + _debug = False + _quark_cookie = "" + _quark_default_path = "/飞书" + _quark_timeout = 30 + _quark_auto_import_cookiecloud = True + _pansou_base_url = "http://127.0.0.1:805" + _pansou_timeout = 20 + _hdhive_api_key = "" + _hdhive_base_url = "https://hdhive.com" + _hdhive_timeout = 30 + _hdhive_default_path = "/待整理" + _assistant_result_page_size = 10 + _assistant_cloud_result_page_size = 20 + _hdhive_candidate_page_size = 20 + _hdhive_resource_enabled = True + _hdhive_max_unlock_points = 20 + _hdhive_checkin_enabled = False + _hdhive_checkin_gambler_mode = False + _hdhive_checkin_once = False + _hdhive_checkin_cron = "0 8 * * *" + _hdhive_checkin_cookie = "" + _hdhive_checkin_auto_login = True + _hdhive_checkin_username = "" + _hdhive_checkin_password = "" + _p115_default_path = "/待整理" + _p115_client_type = "alipaymini" + _p115_cookie = "" + _p115_prefer_direct = True + _mp_download_save_path = "" + _assistant_default_pt_min_seeders = 3 + _assistant_default_auto_ingest_enabled = False + _assistant_default_auto_ingest_score_threshold = 90 + _assistant_default_confirm_score_threshold = 70 + _feishu_enabled = False + _feishu_allow_all = False + _feishu_reply_enabled = True + _feishu_reply_receive_id_type = "chat_id" + _feishu_app_id = "" + _feishu_app_secret = "" + _feishu_verification_token = "" + _feishu_allowed_chat_ids: List[str] = [] + _feishu_allowed_user_ids: List[str] = [] + _feishu_command_whitelist: List[str] = [] + _feishu_command_aliases = "" + _feishu_command_mode = "resource_officer" + + _quark_service: Optional[QuarkTransferService] = None + _hdhive_service: Optional[HDHiveOpenApiService] = None + _p115_service: Optional[P115TransferService] = None + _feishu_channel: Optional[FeishuChannel] = None + _session_cache: Dict[str, Dict[str, Any]] = {} + _session_lock = threading.RLock() + _agent_tools_reloaded = False + _candidate_actor_cache: Dict[str, List[str]] = {} + _candidate_actor_cache_lock = threading.Lock() + _session_store_key = "assistant_session_cache" + _session_retention_seconds = 7 * 24 * 60 * 60 + _execution_history_store_key = "assistant_execution_history" + _execution_history_limit = 100 + _execution_history: List[Dict[str, Any]] = [] + _workflow_plan_store_key = "assistant_workflow_plans" + _workflow_plan_limit = 50 + _workflow_plans: Dict[str, Dict[str, Any]] = {} + _assistant_preferences_store_key = "assistant_preferences" + _assistant_preferences_limit = 100 + _assistant_preferences: Dict[str, Dict[str, Any]] = {} + _hdhive_checkin_history_store_key = "hdhive_checkin_history" + _hdhive_checkin_history_limit = 60 + _hdhive_checkin_history: List[Dict[str, Any]] = [] + _agent_tools_reload_lock = threading.Lock() + _agent_tools_reload_version = "" + _agent_tools_reload_at = 0.0 + + @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 _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_display_datetime(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.startswith("🕒"): + return text + match = re.match(r"^\d{4}[/-](\d{2}[/-]\d{2})(?:\b|$)", text) + if match: + text = match.group(1).replace("-", "/") + return f"🕒{text}" + + @staticmethod + def _normalize_search_prefix(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) + raw = re.sub(r"\s+", " ", raw).strip() + for pattern in ( + r"^(?:mp|pt)\s*搜索\s*(.*)$", + r"^原生\s*搜索\s*(.*)$", + ): + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return "mp", match.group(1).strip() + mappings = [ + ("更新检查", "update"), + ("更新搜索", "update"), + ("查更新", "update"), + ("更新", "update"), + ("资源决策", "smart_decision"), + ("智能决策", "smart_decision"), + ("智能执行", "smart_execute"), + ("智能搜执行", "smart_execute"), + ("智能计划", "smart_plan"), + ("智能搜计划", "smart_plan"), + ("云盘搜索", "cloud"), + ("云盘搜", "cloud"), + ("智能搜索", "smart"), + ("智能搜", "smart"), + ("MP搜索", "mp"), + ("MP 搜索", "mp"), + ("PT搜索", "mp"), + ("PT 搜索", "mp"), + ("pt搜索", "mp"), + ("pt 搜索", "mp"), + ("原生搜索", "mp"), + ("原生 搜索", "mp"), + ("搜索资源", "pansou"), + ("找资源", "pansou"), + ("搜索", "pansou"), + ("找", "pansou"), + ("1搜索", "pansou"), + ("2搜索", "hdhive"), + ("影巢搜索", "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 + if raw.startswith("检查 "): + remain = raw[len("检查"):].strip() + if remain: + return "update", remain + return "", raw + + @staticmethod + def _extract_smart_decision_intent(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + patterns = [ + ("execute_now", [ + r"(?:直接|立即|马上|立刻)?确认执行$", + r"(?:直接|立即|马上|立刻)?确认$", + r"(?:直接|立即|马上|立刻)?执行$", + r"(?:直接|立即|马上|立刻)?下载$", + r"(?:直接|立即|马上|立刻)?转存$", + r"(?:直接|立即|马上|立刻)?解锁$", + r"(?:直接|立即|马上|立刻)?处理$", + ]), + ("make_plan", [ + r"(?:先)?生成计划$", + r"(?:先)?做计划$", + r"(?:先)?出计划$", + r"(?:先)?计划$", + r"(?:先)?待确认计划$", + ]), + ("show_detail", [ + r"详情$", + r"(?:先)?看详情$", + r"(?:先)?看一下$", + r"(?:先)?看看$", + r"(?:只)?看推荐$", + r"(?:只)?看结果$", + r"(?:先)?推荐一下$", + ]), + ] + compact = re.sub(r"\s+", "", raw) + for intent, tokens in patterns: + for token in tokens: + if re.search(token, compact, flags=re.IGNORECASE): + cleaned = re.sub(token, "", compact, flags=re.IGNORECASE).strip() + return cleaned or raw, intent + return raw, "" + + @classmethod + def _extract_mp_result_filter_intent(cls, text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + episode_patterns = [ + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*s\d{1,2}\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*(\d{1,3})\s*集\s*(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*([零〇一二两三四五六七八九十]{1,4})\s*集\s*(?:资源|结果)?", + ] + for pattern in episode_patterns: + match = re.search(pattern, raw, flags=re.IGNORECASE) + if not match: + continue + raw_episode = match.group(1) + if str(raw_episode).isdigit(): + episode = int(raw_episode) + else: + episode = cls._parse_simple_cjk_number(str(raw_episode)) or 0 + if episode <= 0: + continue + cleaned = (raw[:match.start()] + " " + raw[match.end():]).strip(" ::,,。") + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned or raw, f"episode:{episode}" + patterns = [ + r"(?:给我|帮我|只看|看看|看一下)?最新(?:一)?集(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下)?最新(?:两|2)集(?:资源|结果)?", + r"只(?:要|看)(?:当前)?最新", + ] + cleaned = raw + matched = False + for pattern in patterns: + new_value = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE) + if new_value != cleaned: + matched = True + cleaned = new_value + cleaned = re.sub(r"\s+", " ", cleaned).strip(" ::,,。") + return (cleaned or raw, "latest_episode") if matched else (raw, "") + + async def _assistant_smart_decision_followup_detail( + self, + request, + *, + session: str, + cache_key: str, + keyword: str, + compact: bool = False, + apikey: str = "", + media_type: str = "", + year: str = "", + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + ): + decision_result = await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + ) + state = self._load_session(cache_key) + if not isinstance(state, dict) or self._clean_text(state.get("kind")) != "assistant_smart_search": + return decision_result + return await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": apikey, + }) + ) + + @staticmethod + def _match_command_prefix(raw: str, prefixes: List[str]) -> Optional[Tuple[str, str]]: + text = str(raw or "").strip() + for prefix in prefixes: + if not text.startswith(prefix): + continue + remain = text[len(prefix):] + if not remain: + return prefix, "" + return prefix, remain.lstrip(" ::").strip() + return None + + @staticmethod + def _normalize_mp_recommend_request(value: Any, default_source: str = "tmdb_trending") -> Tuple[str, str]: + raw = str(value or "").strip() + compact = re.sub(r"[\s,。?!!?,、::]+", "", raw).lower() + allowed_sources = { + "tmdb_trending", + "tmdb_movies", + "tmdb_tvs", + "douban_hot", + "douban_movie_hot", + "douban_tv_hot", + "douban_showing", + "douban_movie_showing", + "douban_movie_top250", + "douban_tv_animation", + "bangumi_calendar", + } + if compact in allowed_sources: + if compact == "douban_showing": + return "douban_movie_showing", "movie" + return compact, "all" + source_aliases = { + "trending": ("tmdb_trending", "all"), + "tmdb": ("tmdb_trending", "all"), + "tmdb热门": ("tmdb_trending", "all"), + "tmdb电影": ("tmdb_movies", "movie"), + "tmdb剧集": ("tmdb_tvs", "tv"), + "tmdb电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "豆瓣电影": ("douban_movie_hot", "movie"), + "豆瓣热门电影": ("douban_movie_hot", "movie"), + "豆瓣热映": ("douban_movie_showing", "movie"), + "豆瓣电视剧": ("douban_tv_hot", "tv"), + "豆瓣剧集": ("douban_tv_hot", "tv"), + "豆瓣top250": ("douban_movie_top250", "movie"), + "top250": ("douban_movie_top250", "movie"), + "douban_showing": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "热映": ("douban_movie_showing", "movie"), + "院线": ("douban_movie_showing", "movie"), + "bangumi": ("bangumi_calendar", "tv"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "每日放送": ("bangumi_calendar", "tv"), + "动画番剧": ("bangumi_calendar", "tv"), + } + if compact in source_aliases: + return source_aliases[compact] + if "top250" in compact: + return "douban_movie_top250", "movie" + if any(token in compact for token in ["bangumi", "番剧", "每日放送", "今日放送", "今日动画"]): + return "bangumi_calendar", "tv" + if any(token in compact for token in ["正在热映", "热映", "院线"]): + return "douban_movie_showing", "movie" + if "豆瓣" in compact: + if "动画" in compact: + return "douban_tv_animation", "tv" + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "douban_tv_hot", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "douban_movie_hot", "movie" + return "douban_hot", "all" + if "tmdb" in compact: + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "tmdb_movies", "movie" + return "tmdb_trending", "all" + if any(token in compact for token in ["电视剧", "剧集", "剧集推荐", "热门剧", "热门电视剧"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "影视电影", "热门电影"]): + return "tmdb_movies", "movie" + return default_source or "tmdb_trending", "all" + + @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_path(mapped) + + @staticmethod + def _normalize_pick_action(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {"继续决策", "继续资源决策", "decision_continue"}: + return "decision_continue" + if text in {"换影巢", "切换影巢", "走影巢", "用影巢", "decision_hdhive"}: + return "decision_hdhive" + if text in {"换盘搜", "切换盘搜", "走盘搜", "用盘搜", "decision_pansou"}: + return "decision_pansou" + if text in {"换pt", "换原生", "换mp", "切换pt", "切换原生", "切换mp", "走pt", "走原生", "走mp", "decision_mp_pt"}: + return "decision_mp_pt" + if text in {"保守一点", "更保守", "保守模式", "decision_conservative"}: + return "decision_conservative" + if text in {"激进一点", "更激进", "激进模式", "decision_aggressive"}: + return "decision_aggressive" + if text in {"只用夸克", "只有夸克", "仅夸克", "只要夸克", "decision_only_quark"}: + return "decision_only_quark" + if text in {"只用115", "只有115", "仅115", "只要115", "decision_only_115"}: + return "decision_only_115" + if text in {"两者都可", "云盘都可", "115和夸克都可", "115夸克都可", "decision_cloud_both"}: + return "decision_cloud_both" + if text in {"只走pt", "只走原生", "只用pt", "只用原生", "只走mp", "只用mp", "decision_only_mp_pt"}: + return "decision_only_mp_pt" + if text in {"只走盘搜", "只用盘搜", "decision_only_pansou"}: + return "decision_only_pansou" + if text in {"只走影巢", "只用影巢", "decision_only_hdhive"}: + return "decision_only_hdhive" + if text in {"不用盘搜", "关闭盘搜", "禁用盘搜", "decision_disable_pansou"}: + return "decision_disable_pansou" + if text in {"不用影巢", "关闭影巢", "禁用影巢", "decision_disable_hdhive"}: + return "decision_disable_hdhive" + if text in {"不用pt", "不用原生", "关闭pt", "关闭原生", "关闭mp", "decision_disable_mp_pt"}: + return "decision_disable_mp_pt" + if text in {"按保存偏好", "恢复偏好", "恢复默认偏好", "清除会话偏好", "decision_reset_preferences"}: + return "decision_reset_preferences" + if text in {"确认执行", "确认执行吧", "就执行吧", "直接来", "执行它", "确认处理", "确认转存", "确认解锁"}: + return "best_execute" + if text in {"先计划", "先做计划", "先生成计划", "先出计划", "还是先计划"}: + return "best_plan" + if text in {"先看详情", "先看推荐", "先看结果", "看最佳", "看看最佳", "先看一下"}: + return "best" + if text in {"best_execute", "execute_best", "执行最佳", "最佳执行", "立即执行最佳", "直接执行最佳"}: + return "best_execute" + if text in {"best_plan", "plan_best", "计划最佳", "最佳计划", "计划推荐", "推荐计划", "最优计划"}: + return "best_plan" + if text in {"detail", "details", "review", "详情", "审查"}: + return "detail" + if text in {"best", "best_result", "recommend_best", "最佳", "最佳片源", "推荐片源", "推荐下载", "最优"}: + return "best" + if text in {"plan", "dry_run", "make_plan", "计划", "生成计划", "计划选择", "计划处理", "转存计划", "解锁计划"}: + return "plan" + if text in {"n", "next", "next_page", "下一页", "下页"} or text.startswith("n "): + return "next_page" + return "" + + @staticmethod + def _normalize_smart_search_short_action(value: Any, *, state_kind: str = "") -> str: + if str(state_kind or "").strip() != "assistant_smart_search": + return "" + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return "best" + if compact in {"计划", "做计划", "plan"}: + return "best_plan" + if compact in {"确认", "执行", "确认吧", "执行吧", "确定执行", "execute", "run"}: + return "best_execute" + return "" + + @staticmethod + def _is_pending_plan_confirmation_text(value: Any) -> bool: + raw = str(value or "").strip() + if not raw: + return False + compact = re.sub(r"\s+", "", raw).lower() + if re.search(r"\d", compact): + return False + return compact in { + "确认", + "确认吧", + "确认执行", + "确认执行吧", + "执行", + "执行吧", + "execute", + "run", + "确定", + "确定执行", + "执行下载", + "确认下载", + "开始下载", + "下载吧", + } + + @classmethod + def _parse_pending_plan_numeric_confirmation(cls, value: Any) -> int: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + if not raw: + return 0 + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + # Only a bare number or explicit execution wording may confirm a saved plan. + # Keep "下载1" for generating/reviewing the PT download plan in the current + # search result; otherwise it can accidentally execute an older pending plan. + match = re.fullmatch(r"(?:执行|确认|执行计划|确认计划)?(\d+)", compact, flags=re.IGNORECASE) + if not match: + return 0 + return cls._safe_int(match.group(1), 0) + + def _is_pending_plan_numeric_confirmation(self, value: Any, plan: Optional[Dict[str, Any]]) -> bool: + index = self._parse_pending_plan_numeric_confirmation(value) + if index <= 0 or not isinstance(plan, dict): + return False + expected_choices: List[int] = [] + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + expected_choices.append(self._safe_int(execute_body.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(execute_body.get("choice") or execute_body.get("index"), 0)) + for action in plan.get("actions") or []: + if isinstance(action, dict): + expected_choices.append(self._safe_int(action.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(action.get("choice") or action.get("index"), 0)) + return index in {choice for choice in expected_choices if choice > 0} + + @classmethod + def _normalize_ai_reingest_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + result: Dict[str, Any] = {} + ai_session_kinds = { + "assistant_ai_failed_samples", + "assistant_ai_sample_worklist", + "assistant_ai_sample_insights", + "assistant_ai_replay", + } + saved_plan = dict((current_state.get("saved_plan") or {}).get("latest") or {}) + saved_workflow = cls._clean_text(saved_plan.get("workflow")) + has_pending_ai_replay_plan = ( + bool((current_state.get("saved_plan") or {}).get("has_pending")) + and saved_workflow == "ai_replay_failed_sample" + ) + if has_pending_ai_replay_plan and compact in {"确认", "确认吧", "执行", "执行吧", "确定", "确定执行", "run", "execute"}: + return {"action": "execute_plan"} + if kind not in ai_session_kinds: + return {} + keyword = cls._clean_text(current_state.get("keyword")) + if keyword and compact in {"诊断", "本地诊断", "重新诊断", "看诊断"}: + return {"action": "mp_local_diagnose", "keyword": keyword} + if keyword and compact in {"入库状态", "看入库状态", "状态"}: + return {"action": "mp_ingest_status", "keyword": keyword} + if compact in {"工作清单", "返回工作清单", "回工作清单"}: + return {"action": "ai_sample_worklist", "keyword": keyword} + if compact in {"失败样本", "返回失败样本", "回失败样本"}: + return {"action": "ai_failed_samples", "keyword": keyword} + if compact in {"样本洞察", "洞察", "返回样本洞察", "回样本洞察"}: + return {"action": "ai_sample_insights", "keyword": keyword} + raw = cls._clean_text(value) + replay_match = re.match(r"^\s*(重放|重识别|重跑)\s*(\d+)(?:\s+(.*))?$", raw) + if replay_match: + result = { + "action": "ai_replay_failed_sample", + "sample_index": replay_match.group(2), + "keyword": "", + "mode": "", + "remove_if_resolved": "true", + } + remain_text = cls._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + result["remove_if_resolved"] = "false" + return result + return {} + + @classmethod + def _normalize_mp_recommend_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:决策|资源决策|智能决策)\s*(\d+)\s*$", {"mode": "smart_decision"}), + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + (r"^\s*(?:确认|执行|直接执行)\s*(\d+)\s*$", {"mode": "smart_execute"}), + (r"^\s*(?:盘搜|ps)\s*(\d+)\s*$", {"mode": "pansou"}), + (r"^\s*(?:影巢|yc)\s*(\d+)\s*$", {"mode": "hdhive"}), + (r"^\s*(?:原生|mp|pt)\s*(\d+)\s*$", {"mode": "mp"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + selected_index = cls._safe_int(current_state.get("selected_index"), 0) + if selected_index <= 0: + items = current_state.get("items") if isinstance(current_state.get("items"), list) else [] + if items: + selected_index = 1 + if selected_index > 0: + no_index_aliases = { + "详情": {"action": "detail"}, + "查看详情": {"action": "detail"}, + "看详情": {"action": "detail"}, + "决策": {"mode": "smart_decision"}, + "资源决策": {"mode": "smart_decision"}, + "智能决策": {"mode": "smart_decision"}, + "计划": {"action": "plan"}, + "生成计划": {"action": "plan"}, + "先计划": {"action": "plan"}, + "确认": {"mode": "smart_execute"}, + "执行": {"mode": "smart_execute"}, + "直接执行": {"mode": "smart_execute"}, + "盘搜": {"mode": "pansou"}, + "ps": {"mode": "pansou"}, + "影巢": {"mode": "hdhive"}, + "yc": {"mode": "hdhive"}, + "原生": {"mode": "mp"}, + "mp": {"mode": "mp"}, + "pt": {"mode": "mp"}, + } + shortcut = no_index_aliases.get(raw) + if shortcut: + return {"index": selected_index, **shortcut} + return {} + + @classmethod + def _normalize_mp_recommend_followup( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + followup_aliases = { + "电影": ("tmdb_movies", "movie"), + "热门电影": ("tmdb_movies", "movie"), + "电视剧": ("tmdb_tvs", "tv"), + "热门电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "热映": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "tmdb": ("tmdb_trending", "all"), + "热门": ("tmdb_trending", "all"), + } + source_pair = followup_aliases.get(compact) + if not source_pair: + return {} + source_name, media_type = source_pair + return {"action": "mp_recommendations", "keyword": source_name, "type": media_type} + + @classmethod + def _normalize_recommend_handoff_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact not in {"回推荐", "回榜单", "返回推荐", "返回榜单", "推荐", "榜单"}: + switch_mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp", + "mp": "mp", + } + switch_mode = switch_mode_aliases.get(compact) + if not switch_mode: + return {} + return {"action": "switch_recommend_handoff_source", "mode": switch_mode} + return {"action": "return_to_recommend"} + + @classmethod + def _normalize_recommend_handoff_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact in {"决策", "资源决策", "智能决策"}: + return {"action": "return_to_smart_decision"} + if kind in {"assistant_pansou", "assistant_mp"}: + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return {"route_action": "best"} + if compact in {"计划", "做计划", "plan"}: + return {"route_action": "best_plan"} + if compact in {"确认", "确认吧", "确认执行", "执行", "执行吧", "确定执行", "run", "execute"}: + return {"action": "confirm_recommend_handoff"} + return {} + + @classmethod + def _normalize_recommend_source_compound_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_mp_recommend", "assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + source_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "原生": "mp", + "mp": "mp", + } + action_aliases = { + "详情": "best", + "看详情": "best", + "计划": "best_plan", + "确认": "best_execute", + "执行": "best_execute", + } + for alias, mode in source_aliases.items(): + if not compact.startswith(alias): + continue + remain = compact[len(alias):] + followup_action = action_aliases.get(remain) + if not followup_action: + continue + return { + "action": "recommend_source_compound", + "mode": mode, + "followup_action": followup_action, + } + return {} + + @classmethod + def _normalize_mp_recommend_direct_intent(cls, value: Any) -> Dict[str, Any]: + raw = cls._clean_text(value) + if not raw: + return {} + prefix_match = cls._match_command_prefix(raw, ["智能发现", "热门发现", "热门推荐", "推荐"]) + if not prefix_match: + return {} + _, remain = prefix_match + remain = cls._clean_text(remain) + if not remain: + return {} + suffix_aliases: List[Tuple[str, Dict[str, str]]] = [ + ("资源决策", {"mode": "smart_decision"}), + ("智能决策", {"mode": "smart_decision"}), + ("查看详情", {"action": "detail"}), + ("看详情", {"action": "detail"}), + ("确认执行", {"mode": "smart_execute"}), + ("直接执行", {"mode": "smart_execute"}), + ("生成计划", {"action": "plan"}), + ("先计划", {"action": "plan"}), + ("详情", {"action": "detail"}), + ("决策", {"mode": "smart_decision"}), + ("计划", {"action": "plan"}), + ("确认", {"mode": "smart_execute"}), + ("执行", {"mode": "smart_execute"}), + ("盘搜", {"mode": "pansou"}), + ("影巢", {"mode": "hdhive"}), + ("原生", {"mode": "mp"}), + ] + for suffix, action_payload in suffix_aliases: + separator = f" {suffix}" + if remain.endswith(separator): + keyword = cls._clean_text(remain[: -len(separator)]) + elif remain == suffix: + keyword = "" + else: + continue + if not keyword: + return {} + return {"keyword": keyword, "index": 1, **action_payload} + return {} + + @staticmethod + def _normalize_pick_mode(value: Any) -> str: + text = str(value or "").strip().lower() + compact = re.sub(r"\s+", "", text) + if not compact: + return "" + if any(token in compact for token in ["smart_execute", "smartexecute", "直接执行", "确认执行", "立即执行", "确认", "执行"]): + return "smart_execute" + if any(token in compact for token in ["smart_plan", "smartplan", "生成计划", "先计划", "计划"]): + return "smart_plan" + if any(token in compact for token in ["smart_decision", "smartdecision", "智能决策", "资源决策", "决策"]): + return "smart_decision" + if re.search(r"(^|[^a-z])smart($|[^a-z])", text): + return "smart_decision" + if any(token in compact for token in ["hdhive", "影巢", "影潮", "走影巢", "用影巢"]): + return "hdhive" + if re.search(r"(^|[^a-z])yc($|[^a-z])", text): + return "hdhive" + if any(token in compact for token in ["pansou", "盘搜", "走盘搜", "用盘搜"]): + return "pansou" + if re.search(r"(^|[^a-z])ps($|[^a-z])", text): + return "pansou" + if any(token in compact for token in ["mp", "原生", "moviepilot", "站点", "pt"]): + return "mp" + return "" + + @staticmethod + def _normalize_fullwidth_digits(value: Any) -> str: + return str(value or "").translate(str.maketrans("0123456789", "0123456789")) + + @staticmethod + def _parse_chinese_pick_number(value: Any) -> int: + text = str(value or "").strip() + if not text: + return 0 + text = text.replace("两", "二").replace("〇", "零") + digits = { + "零": 0, + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + if text.isdigit(): + return int(text) + if any(ch not in digits and ch not in {"十", "百"} for ch in text): + return 0 + if len(text) == 1: + return digits.get(text, 0) + total = 0 + remainder = text + if "百" in remainder: + left, _, remainder = remainder.partition("百") + hundreds = 1 if not left else digits.get(left, 0) + if hundreds <= 0: + return 0 + total += hundreds * 100 + if "十" in remainder: + left, _, right = remainder.partition("十") + tens = 1 if not left else digits.get(left, 0) + ones = 0 if not right else digits.get(right, 0) + if tens <= 0 or (right and ones <= 0): + return 0 + total += tens * 10 + ones + return total + if total and len(remainder) == 1: + return total + digits.get(remainder, 0) + return total + + @classmethod + def _normalize_pick_action_fragment(cls, value: Any) -> str: + compact = re.sub(r"[\s的地得一下]+", "", str(value or "").strip().lower()) + if not compact: + return "" + action = cls._normalize_pick_action(compact) + if action: + return action + if any(token in compact for token in ["详情", "明细", "查看", "看看", "看下", "看一下"]): + return "detail" + if "审查" in compact: + return "detail" + if "下载" in compact: + return "plan" + if any(token in compact for token in ["计划", "dryrun", "makeplan"]): + return "plan" + return "" + + @classmethod + def _parse_compact_pick_text(cls, value: Any) -> Tuple[int, str]: + raw = cls._normalize_fullwidth_digits(value) + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + if not compact: + return 0, "" + chinese_number = r"[零〇一二两三四五六七八九十百]+" + number_token = rf"(?:\d+|{chinese_number})" + + def number_value(token: str) -> int: + return cls._safe_int(token, 0) if token.isdigit() else cls._parse_chinese_pick_number(token) + + # Short forms: "16详情", "十六详情", "16", "十六". + match = re.fullmatch(rf"({number_token})(.*)", compact) + if match: + index = number_value(match.group(1)) + suffix = match.group(2) + action = cls._normalize_pick_action_fragment(suffix) + if index > 0 and (not suffix or action): + return index, action + + # Reversed short forms: "详情16", "详情十六", "计划16". + match = re.fullmatch(rf"(.+?)({number_token})", compact) + if match: + action = cls._normalize_pick_action_fragment(match.group(1)) + index = number_value(match.group(2)) + if index > 0 and action: + return index, action + + # Natural phrases: "我要看看 16 的详情", "帮我看第十六个详情". + natural_action = cls._normalize_pick_action_fragment(compact) + if natural_action: + digit_match = re.search(r"\d+", compact) + if digit_match: + return cls._safe_int(digit_match.group(0), 0), natural_action + for cn_match in re.finditer(chinese_number, compact): + token = cn_match.group(0) + # Avoid treating "看一下详情" as "pick 1 detail". + if len(token) == 1 and token in {"一", "二", "三"}: + continue + index = cls._parse_chinese_pick_number(token) + if index > 0: + return index, natural_action + return 0, "" + + @classmethod + def _parse_pick_text(cls, value: Any) -> Tuple[int, str, str, str]: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + action = cls._normalize_pick_action(raw) + if action: + return 0, "", action, "" + alias_pattern = r"^(?:/smart_pick|smart_pick|计划选择|计划处理|生成计划|转存计划|解锁计划|计划(?=\s*\d)|选择|选|继续|pick|plan|dry_run|make_plan)\s*" + alias_match = re.match(alias_pattern, raw, flags=re.IGNORECASE) + digit_match = re.match(r"^(\d+)(.*)$", raw) + compact_index, compact_action = cls._parse_compact_pick_text(raw) + if compact_index > 0 and not alias_match: + return compact_index, "", compact_action, "" + if not alias_match and not digit_match: + return 0, "", "", "" + if digit_match and not alias_match: + suffix = cls._clean_text(digit_match.group(2)) + if suffix and not ( + suffix.startswith("/") + or "=" in suffix + or cls._normalize_pick_mode(suffix) + or cls._normalize_pick_action(suffix) + or suffix.lower() in {"n", "next"} + ): + return 0, "", "", "" + text = re.sub(alias_pattern, "", raw, flags=re.IGNORECASE).strip() + if alias_match and cls._normalize_pick_action(alias_match.group(0).strip()) == "plan": + action = "plan" + index = 0 + path = "" + mode = "" + pick_action = action or "" + match = re.search(r"\d+", text) + if match: + index = cls._safe_int(match.group(0), 0) + elif text: + compact_index, compact_action = cls._parse_compact_pick_text(text) + if compact_index > 0: + index = compact_index + if compact_action: + pick_action = compact_action + for token in text.split(): + if "=" not in token: + if not pick_action: + pick_action = cls._normalize_pick_action(token) + continue + key, token_value = token.split("=", 1) + key = key.strip().lower() + token_value = token_value.strip() + if key in {"path", "dir", "目录", "位置"} and token_value: + path = cls._resolve_pan_path_value(token_value) + elif key in {"mode", "search_mode", "target", "方式", "来源", "渠道"} and token_value: + mode = cls._normalize_pick_mode(token_value) + if not mode: + mode = cls._normalize_pick_mode(text) + if not pick_action: + suffix = re.sub(r"\d+", " ", text, count=1).strip() + pick_action = cls._normalize_pick_action(suffix) + return index, path, pick_action, mode + + def init_plugin(self, config: Optional[Dict[str, Any]] = None): + config = config or {} + self._enabled = bool(config.get("enabled", False)) + self._notify = bool(config.get("notify", True)) + self._debug = bool(config.get("debug", False)) + self._quark_cookie = self._clean_text(config.get("quark_cookie")) + self._quark_default_path = self._normalize_path(config.get("quark_default_path") or "/飞书") + self._quark_timeout = self._safe_int(config.get("quark_timeout"), 30) + self._quark_auto_import_cookiecloud = bool(config.get("quark_auto_import_cookiecloud", True)) + self._pansou_base_url = self._clean_text(config.get("pansou_base_url") or "http://127.0.0.1:805").rstrip("/") + self._pansou_timeout = max(3, min(120, self._safe_int(config.get("pansou_timeout"), 20))) + self._hdhive_api_key = self._clean_text(config.get("hdhive_api_key")) + self._hdhive_base_url = self._clean_text(config.get("hdhive_base_url") or "https://hdhive.com").rstrip("/") + self._hdhive_timeout = self._safe_int(config.get("hdhive_timeout"), 30) + self._hdhive_default_path = self._normalize_path(config.get("hdhive_default_path") or "/待整理") + self._hdhive_candidate_page_size = max(5, min(20, self._safe_int(config.get("hdhive_candidate_page_size"), type(self)._hdhive_candidate_page_size))) + self._hdhive_resource_enabled = bool(config.get("hdhive_resource_enabled", True)) + self._hdhive_max_unlock_points = max(0, self._safe_int(config.get("hdhive_max_unlock_points"), 20)) + self._hdhive_checkin_enabled = bool(config.get("hdhive_checkin_enabled", False)) + self._hdhive_checkin_gambler_mode = bool(config.get("hdhive_checkin_gambler_mode", False)) + self._hdhive_checkin_once = bool(config.get("hdhive_checkin_once", False)) + self._hdhive_checkin_cron = self._clean_text(config.get("hdhive_checkin_cron") or "0 8 * * *") + self._hdhive_checkin_cookie = self._clean_text(config.get("hdhive_checkin_cookie")) + self._hdhive_checkin_auto_login = bool(config.get("hdhive_checkin_auto_login", True)) + self._hdhive_checkin_username = self._clean_text(config.get("hdhive_checkin_username")) + self._hdhive_checkin_password = self._clean_text(config.get("hdhive_checkin_password")) + self._p115_default_path = self._normalize_path(config.get("p115_default_path") or "/待整理") + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(config.get("p115_client_type")) + self._p115_cookie = self._clean_text(config.get("p115_cookie")) + self._p115_prefer_direct = bool(config.get("p115_prefer_direct", True)) + self._mp_download_save_path = self._clean_text(config.get("mp_download_save_path")) + self._assistant_default_pt_min_seeders = max(0, self._safe_int(config.get("assistant_default_pt_min_seeders"), 3)) + self._assistant_default_auto_ingest_enabled = bool(config.get("assistant_default_auto_ingest_enabled", False)) + self._assistant_default_auto_ingest_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_auto_ingest_score_threshold"), 90))) + self._assistant_default_confirm_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_confirm_score_threshold"), 70))) + self._feishu_enabled = bool(config.get("feishu_enabled", False)) + self._feishu_allow_all = bool(config.get("feishu_allow_all", False)) + self._feishu_reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self._feishu_reply_receive_id_type = self._clean_text(config.get("feishu_reply_receive_id_type") or "chat_id") + self._feishu_app_id = self._clean_text(config.get("feishu_app_id")) + self._feishu_app_secret = self._clean_text(config.get("feishu_app_secret")) + self._feishu_verification_token = self._clean_text(config.get("feishu_verification_token")) + self._feishu_allowed_chat_ids = FeishuChannel.split_lines(config.get("feishu_allowed_chat_ids")) + self._feishu_allowed_user_ids = FeishuChannel.split_lines(config.get("feishu_allowed_user_ids")) + self._feishu_command_whitelist = FeishuChannel.merge_command_whitelist( + FeishuChannel.split_commands(config.get("feishu_command_whitelist")) + ) + self._feishu_command_aliases = FeishuChannel.merge_command_aliases( + self._clean_text(config.get("feishu_command_aliases")) + ) + self._feishu_command_mode = self._clean_text(config.get("feishu_command_mode") or "resource_officer") + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + self._restore_persisted_sessions() + self._restore_execution_history() + self._restore_hdhive_checkin_history() + self._restore_workflow_plans() + self._restore_assistant_preferences() + self._agent_tools_reloaded = False + self._ensure_feishu_channel().configure(self._build_config()) + if self._enabled and self._feishu_enabled: + self._feishu_channel.start() + elif self._feishu_channel is not None: + self._feishu_channel.stop() + self._maybe_run_hdhive_checkin_once() + + def get_state(self) -> bool: + if self._enabled: + self._maybe_reload_agent_tools_once() + return self._enabled + + def get_agent_tools(self) -> List[type]: + return [ + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantPlansTool, + AssistantPlansClearTool, + AssistantRecoverTool, + AssistantPulseTool, + AssistantStartupTool, + AssistantMaintainTool, + AssistantToolboxTool, + AssistantRequestTemplatesTool, + AssistantSelfcheckTool, + AssistantReadinessTool, + FeishuChannelHealthTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantRouteTool, + AssistantPickTool, + AssistantWorkflowTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + ShareRouteTool, + P115QRCodeStartTool, + P115QRCodeCheckTool, + P115StatusTool, + P115PendingTool, + P115ResumePendingTool, + P115CancelPendingTool, + ] + + @staticmethod + def _reload_agent_tools() -> None: + if moviepilot_tool_manager is None: + return + try: + moviepilot_tool_manager._load_tools() + except Exception: + return + + def _maybe_reload_agent_tools_once(self) -> None: + if moviepilot_tool_manager is None: + return + now = time.time() + with self.__class__._agent_tools_reload_lock: + if ( + self.__class__._agent_tools_reload_version == self.plugin_version + and now - self.__class__._agent_tools_reload_at < 600 + ): + return + # Mark before reloading, because tool loading can query plugin states recursively. + self.__class__._agent_tools_reload_version = self.plugin_version + self.__class__._agent_tools_reload_at = now + self._reload_agent_tools() + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _parse_optional_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return None + + @classmethod + def _parse_bool_value(cls, value: Any, default: bool = False) -> bool: + parsed = cls._parse_optional_bool(value) + return bool(default) if parsed is None else bool(parsed) + + @staticmethod + def _normalize_path(value: Any) -> str: + return QuarkTransferService.normalize_path(value) + + @staticmethod + def _friendly_hdhive_error(message: str, capability: str) -> str: + text = str(message or "").strip() + lowered = text.lower() + if "premium" in lowered or "仅对 premium 用户开放" in text: + if capability == "checkin": + return "影巢 OpenAPI 签到当前需要 Premium 用户;普通用户可配置网页 Cookie 或账号密码启用网页签到兜底。" + return f"影巢 OpenAPI 的{capability}接口当前需要 Premium 用户。" + return text or f"影巢 {capability} 接口调用失败" + + @staticmethod + def _is_hdhive_premium_limited(message: str) -> bool: + text = str(message or "").strip() + lowered = text.lower() + return "premium" in lowered or "仅对 premium 用户开放" in text + + @staticmethod + def _read_json_file(path: Path) -> Optional[Dict[str, Any]]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + @classmethod + def _hdhive_daily_sign_config_paths(cls) -> List[Path]: + return [ + Path("/config/plugins/hdhivedailysign.json"), + Path("/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json"), + ] + + @classmethod + def _hdhive_daily_sign_user_info_paths(cls) -> List[Path]: + return [ + Path("/config/logs/plugins/hdhivedailysign_user_info.json"), + Path("/Applications/Dockge/moviepilotv2/config/logs/plugins/hdhivedailysign_user_info.json"), + ] + + @classmethod + def _load_hdhive_daily_sign_config(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_config_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _load_hdhive_daily_sign_user_info(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_user_info_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _build_hdhive_account_snapshot(cls, snapshot: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(snapshot, dict) or not snapshot: + return {} + return { + "id": snapshot.get("id"), + "nickname": snapshot.get("nickname"), + "username": snapshot.get("nickname"), + "avatar_url": snapshot.get("avatar_url"), + "created_at": snapshot.get("created_at"), + "is_vip": False, + "source": "hdhivedailysign_snapshot", + "user_meta": { + "points": snapshot.get("points"), + "signin_days_total": snapshot.get("signin_days_total"), + }, + "warnings_nums": snapshot.get("warnings_nums"), + } + + @classmethod + def _extract_hdhive_account_fields(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload if isinstance(payload, dict) else {} + meta = data.get("user_meta") if isinstance(data.get("user_meta"), dict) else {} + return { + "nickname": data.get("nickname") or data.get("username") or "—", + "points": meta.get("points", data.get("points", "—")), + "signin_days_total": meta.get("signin_days_total", data.get("signin_days_total", "—")), + "is_vip": bool(data.get("is_vip")), + } + + def _get_hdhive_fallback_cookie(self) -> str: + own_cookie = self._clean_text(self._hdhive_checkin_cookie) + if own_cookie: + return own_cookie + config = self._load_hdhive_daily_sign_config() + return self._clean_text(config.get("cookie")) + + def _refresh_hdhive_checkin_cookie(self) -> Tuple[bool, str, str]: + if not self._hdhive_checkin_auto_login: + return False, "", "未启用影巢自动登录刷新 Cookie" + service = self._ensure_hdhive_service() + # Playwright sync API cannot run inside MoviePilot's asyncio loop; keep login isolated. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + service.login_for_cookie, + username=self._hdhive_checkin_username, + password=self._hdhive_checkin_password, + ) + try: + login_ok, cookie_string, login_message = future.result(timeout=max(60, self._hdhive_timeout * 4)) + except Exception as exc: + return False, "", f"影巢自动登录超时或异常: {exc}" + if not login_ok or not cookie_string: + return False, "", login_message or "影巢自动登录失败" + self._hdhive_checkin_cookie = cookie_string + try: + self.update_config(self._build_config()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢自动登录已获取 Cookie,但保存配置失败:{exc}") + return True, cookie_string, login_message or "影巢自动登录成功" + + def _run_hdhive_checkin(self, *, is_gambler: Optional[bool] = None, trigger: str = "Agent影视助手") -> Dict[str, Any]: + if not self._hdhive_checkin_enabled: + return self._hdhive_checkin_disabled_response() + service = self._ensure_hdhive_service() + final_gambler_mode = self._hdhive_checkin_gambler_mode if is_gambler is None else bool(is_gambler) + checkin_ok, result, checkin_message = service.perform_checkin( + is_gambler=final_gambler_mode, + trigger=trigger, + ) + if checkin_ok: + final_result = {"success": True, "message": result.get("message") or "success", "data": result} + self._record_hdhive_checkin_history(trigger=trigger, is_gambler=final_gambler_mode, result=final_result) + return final_result + + raw_message = result.get("message") or checkin_message + checkin_status_code = self._safe_int(result.get("status_code"), 0) if isinstance(result, dict) else 0 + should_try_web_fallback = ( + self._is_hdhive_premium_limited(raw_message) + or checkin_status_code in (404, 405) + or "405 not allowed" in self._clean_text(raw_message).lower() + or " None: + if not self._enabled or not self._hdhive_checkin_once: + return + self._hdhive_checkin_once = False + try: + self.update_config(self._build_config({"hdhive_checkin_once": False})) + except Exception as exc: + logger.warning(f"[Agent影视助手] 重置“立即影巢签到”开关失败:{exc}") + + def _run_once() -> None: + try: + result = self._run_hdhive_checkin(trigger="Agent影视助手 插件页立即签到") + status = "成功" if result.get("success") else "失败" + logger.info(f"[Agent影视助手] 插件页立即影巢签到{status}: {result.get('message')}") + except Exception as exc: + logger.error(f"[Agent影视助手] 插件页立即影巢签到异常: {exc}") + + threading.Thread(target=_run_once, name="aro-hdhive-checkin-once", daemon=True).start() + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled or not self._hdhive_checkin_enabled or not self._hdhive_checkin_cron: + return [] + if CronTrigger is None: + logger.warning("[Agent影视助手] apscheduler 不可用,无法注册影巢定时签到") + return [] + try: + trigger = CronTrigger.from_crontab(self._hdhive_checkin_cron) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢签到 Cron 配置无效:{self._hdhive_checkin_cron} {exc}") + return [] + return [{ + "id": "agentresourceofficer_hdhive_checkin", + "name": "Agent影视助手影巢签到", + "trigger": trigger, + "func": self._scheduled_hdhive_checkin, + "kwargs": {}, + }] + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "debug": self._debug, + "quark_cookie": self._quark_cookie, + "quark_default_path": self._quark_default_path, + "quark_timeout": self._quark_timeout, + "quark_auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + "pansou_base_url": self._pansou_base_url, + "pansou_timeout": self._pansou_timeout, + "hdhive_api_key": self._hdhive_api_key, + "hdhive_base_url": self._hdhive_base_url, + "hdhive_timeout": self._hdhive_timeout, + "hdhive_default_path": self._hdhive_default_path, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "hdhive_checkin_once": self._hdhive_checkin_once, + "hdhive_checkin_cron": self._hdhive_checkin_cron, + "hdhive_checkin_cookie": self._hdhive_checkin_cookie, + "hdhive_checkin_auto_login": self._hdhive_checkin_auto_login, + "hdhive_checkin_username": self._hdhive_checkin_username, + "hdhive_checkin_password": self._hdhive_checkin_password, + "p115_default_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie": self._p115_cookie, + "p115_prefer_direct": self._p115_prefer_direct, + "mp_download_save_path": self._mp_download_save_path, + "assistant_default_pt_min_seeders": self._assistant_default_pt_min_seeders, + "assistant_default_auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "assistant_default_auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "assistant_default_confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "feishu_enabled": self._feishu_enabled, + "feishu_allow_all": self._feishu_allow_all, + "feishu_reply_enabled": self._feishu_reply_enabled, + "feishu_reply_receive_id_type": self._feishu_reply_receive_id_type, + "feishu_app_id": self._feishu_app_id, + "feishu_app_secret": self._feishu_app_secret, + "feishu_verification_token": self._feishu_verification_token, + "feishu_allowed_chat_ids": "\n".join(self._feishu_allowed_chat_ids), + "feishu_allowed_user_ids": "\n".join(self._feishu_allowed_user_ids), + "feishu_command_whitelist": ",".join(self._feishu_command_whitelist), + "feishu_command_aliases": self._feishu_command_aliases, + "feishu_command_mode": self._feishu_command_mode, + } + if overrides: + config.update(overrides) + return config + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + async def _request_payload(self, request: Request) -> Dict[str, Any]: + if str(getattr(request, "method", "") or "").upper() == "GET": + return {} + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _ensure_quark_service(self) -> QuarkTransferService: + if self._quark_service is None: + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + else: + self._quark_service.set_cookie(self._quark_cookie) + self._quark_service.timeout = max(10, self._safe_int(self._quark_timeout, 30)) + self._quark_service.default_target_path = self._quark_default_path + self._quark_service.auto_import_cookiecloud = self._quark_auto_import_cookiecloud + self._quark_service.cookie_refresh_callback = self._refresh_quark_cookie_from_cookiecloud + return self._quark_service + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _refresh_quark_cookie_from_cookiecloud(self) -> str: + cookie, _message = self._load_cookiecloud_quark_cookie() + if cookie: + self._quark_cookie = cookie + return cookie + + def _ensure_hdhive_service(self) -> HDHiveOpenApiService: + if self._hdhive_service is None: + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + else: + self._hdhive_service.api_key = self._hdhive_api_key + self._hdhive_service.base_url = self._hdhive_base_url + self._hdhive_service.timeout = self._hdhive_timeout + return self._hdhive_service + + def _ensure_p115_service(self) -> P115TransferService: + if self._p115_service is None: + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + else: + self._p115_service.default_target_path = self._p115_default_path + self._p115_service.set_cookie(self._p115_cookie) + self._p115_service.prefer_direct = self._p115_prefer_direct + return self._p115_service + + def _apply_runtime_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = self._build_config(overrides) + self.update_config(config) + self.init_plugin(config) + return config + + @staticmethod + def _p115_client_type_items() -> List[Dict[str, str]]: + return [ + {"title": "支付宝小程序(推荐)", "value": "alipaymini"}, + {"title": "115 Android", "value": "115android"}, + {"title": "115 iOS", "value": "115ios"}, + {"title": "115 iPad", "value": "115ipad"}, + {"title": "115 TV", "value": "tv"}, + {"title": "微信小程序", "value": "wechatmini"}, + {"title": "Web", "value": "web"}, + ] + + @classmethod + def _p115_client_type_title(cls, value: str) -> str: + final_value = P115TransferService.normalize_qrcode_client_type(value) + for item in cls._p115_client_type_items(): + if item.get("value") == final_value: + return str(item.get("title") or final_value) + return final_value + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + if self._feishu_channel is not None: + self._feishu_channel.stop() + return + + def _ensure_feishu_channel(self) -> FeishuChannel: + if self._feishu_channel is None: + self._feishu_channel = FeishuChannel(self) + return self._feishu_channel + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/quark/health", + "endpoint": self.api_quark_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的夸克配置", + }, + { + "path": "/quark/transfer", + "endpoint": self.api_quark_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行夸克分享转存", + }, + { + "path": "/hdhive/health", + "endpoint": self.api_hdhive_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的影巢配置", + }, + { + "path": "/hdhive/account", + "endpoint": self.api_hdhive_account, + "methods": ["GET"], + "summary": "获取影巢当前账号信息", + }, + { + "path": "/hdhive/checkin", + "endpoint": self.api_hdhive_checkin, + "methods": ["POST"], + "summary": "执行影巢普通签到或赌狗签到", + }, + { + "path": "/hdhive/checkin/history", + "endpoint": self.api_hdhive_checkin_history, + "methods": ["GET"], + "summary": "查看插件保存的影巢签到日志", + }, + { + "path": "/hdhive/quota", + "endpoint": self.api_hdhive_quota, + "methods": ["GET"], + "summary": "获取影巢当前配额信息", + }, + { + "path": "/hdhive/usage_today", + "endpoint": self.api_hdhive_usage_today, + "methods": ["GET"], + "summary": "获取影巢今日用量统计", + }, + { + "path": "/hdhive/weekly_free_quota", + "endpoint": self.api_hdhive_weekly_free_quota, + "methods": ["GET"], + "summary": "获取影巢每周免费解锁额度", + }, + { + "path": "/hdhive/search", + "endpoint": self.api_hdhive_search, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源搜索", + }, + { + "path": "/hdhive/search_by_keyword", + "endpoint": self.api_hdhive_search_by_keyword, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢关键词候选搜索", + }, + { + "path": "/hdhive/unlock", + "endpoint": self.api_hdhive_unlock, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源解锁", + }, + { + "path": "/hdhive/unlock_and_route", + "endpoint": self.api_hdhive_unlock_and_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 解锁影巢资源并尝试自动路由到对应网盘执行层", + }, + { + "path": "/p115/health", + "endpoint": self.api_p115_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 转存依赖状态", + }, + { + "path": "/p115/qrcode", + "endpoint": self.api_p115_qrcode, + "methods": ["GET"], + "summary": "获取 Agent影视助手 的 115 扫码登录二维码", + }, + { + "path": "/p115/qrcode/check", + "endpoint": self.api_p115_qrcode_check, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 扫码登录状态", + }, + { + "path": "/p115/transfer", + "endpoint": self.api_p115_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行 115 分享转存", + }, + { + "path": "/p115/pending", + "endpoint": self.api_p115_pending, + "methods": ["GET", "POST"], + "summary": "查看指定会话中待继续的 115 任务", + }, + { + "path": "/p115/pending/resume", + "endpoint": self.api_p115_pending_resume, + "methods": ["POST"], + "summary": "继续执行指定会话中待处理的 115 任务", + }, + { + "path": "/p115/pending/cancel", + "endpoint": self.api_p115_pending_cancel, + "methods": ["POST"], + "summary": "取消指定会话中待处理的 115 任务", + }, + { + "path": "/share/route", + "endpoint": self.api_share_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 自动识别 115 / 夸克分享链接并执行对应转存", + }, + { + "path": "/feishu/health", + "endpoint": self.api_feishu_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 内置飞书入口状态", + }, + { + "path": "/assistant/route", + "endpoint": self.api_assistant_route, + "methods": ["POST"], + "summary": "统一智能入口:盘搜 / 影巢 / 直链分享", + }, + { + "path": "/assistant/pick", + "endpoint": self.api_assistant_pick, + "methods": ["POST"], + "summary": "统一智能入口的按编号继续执行", + }, + { + "path": "/assistant/capabilities", + "endpoint": self.api_assistant_capabilities, + "methods": ["GET"], + "summary": "查看统一智能入口支持的结构化参数、默认值与推荐调用方式", + }, + { + "path": "/assistant/readiness", + "endpoint": self.api_assistant_readiness, + "methods": ["GET"], + "summary": "检查 Agent影视助手 是否已准备好给外部智能体调用", + }, + { + "path": "/assistant/pulse", + "endpoint": self.api_assistant_pulse, + "methods": ["GET"], + "summary": "轻量启动探针:返回版本、关键服务状态、警告和最佳恢复建议", + }, + { + "path": "/assistant/startup", + "endpoint": self.api_assistant_startup, + "methods": ["GET"], + "summary": "启动聚合包:一次返回 pulse、自检、核心工具、端点、默认目录和恢复建议", + }, + { + "path": "/assistant/maintain", + "endpoint": self.api_assistant_maintain, + "methods": ["GET", "POST"], + "summary": "低风险维护:查看或执行过期会话、已执行计划清理", + }, + { + "path": "/assistant/toolbox", + "endpoint": self.api_assistant_toolbox, + "methods": ["GET"], + "summary": "轻量工具清单:返回推荐工具、端点、工作流、动作名、默认目录和命令示例", + }, + { + "path": "/assistant/request_templates", + "endpoint": self.api_assistant_request_templates, + "methods": ["GET", "POST"], + "summary": "轻量请求模板:返回外部智能体常用 assistant 请求模板", + }, + { + "path": "/assistant/selfcheck", + "endpoint": self.api_assistant_selfcheck, + "methods": ["GET"], + "summary": "轻量自检:确认 compact 协议、模板默认 compact 和布尔字符串解析是否正常", + }, + { + "path": "/assistant/history", + "endpoint": self.api_assistant_history, + "methods": ["GET"], + "summary": "查看最近执行历史,便于外部智能体判断上一步是否完成", + }, + { + "path": "/assistant/action", + "endpoint": self.api_assistant_action, + "methods": ["POST"], + "summary": "直接执行统一智能入口返回的动作模板名,适合外部智能体无映射继续执行", + }, + { + "path": "/assistant/actions", + "endpoint": self.api_assistant_actions, + "methods": ["POST"], + "summary": "批量执行多个动作模板,适合外部智能体一次请求串起多步工作流,减少往返", + }, + { + "path": "/assistant/workflow", + "endpoint": self.api_assistant_workflow, + "methods": ["GET", "POST"], + "summary": "运行预设工作流,适合外部智能体用更短参数完成常见资源任务", + }, + { + "path": "/assistant/preferences", + "endpoint": self.api_assistant_preferences, + "methods": ["GET", "POST", "DELETE"], + "summary": "读取、保存或重置智能体片源偏好画像,用于云盘和 PT 分源评分", + }, + { + "path": "/assistant/plan/execute", + "endpoint": self.api_assistant_plan_execute, + "methods": ["POST"], + "summary": "执行 dry_run 保存的工作流计划,避免外部智能体重复携带大 JSON", + }, + { + "path": "/assistant/plans", + "endpoint": self.api_assistant_plans, + "methods": ["GET"], + "summary": "查看 dry_run 保存的工作流计划,便于断线恢复和选择 plan_id", + }, + { + "path": "/assistant/plans/clear", + "endpoint": self.api_assistant_plans_clear, + "methods": ["POST"], + "summary": "清理 dry_run 保存的工作流计划", + }, + { + "path": "/assistant/recover", + "endpoint": self.api_assistant_recover, + "methods": ["GET", "POST"], + "summary": "查看或直接执行当前最推荐的恢复动作,给外部智能体提供单入口续跑能力", + }, + { + "path": "/assistant/session", + "endpoint": self.api_assistant_session_state, + "methods": ["GET", "POST"], + "summary": "查看统一智能入口当前会话状态与建议动作", + }, + { + "path": "/assistant/session/clear", + "endpoint": self.api_assistant_session_clear, + "methods": ["POST"], + "summary": "清理统一智能入口当前会话缓存", + }, + { + "path": "/assistant/sessions", + "endpoint": self.api_assistant_sessions, + "methods": ["GET"], + "summary": "列出当前活跃的统一智能入口会话,便于外部智能体恢复和接续", + }, + { + "path": "/assistant/sessions/clear", + "endpoint": self.api_assistant_sessions_clear, + "methods": ["POST"], + "summary": "按 session_id、类型或过滤条件批量清理统一智能入口会话", + }, + { + "path": "/session/hdhive/search", + "endpoint": self.api_session_hdhive_search, + "methods": ["POST"], + "summary": "创建影巢搜索会话并返回候选影片列表", + }, + { + "path": "/session/hdhive/pick", + "endpoint": self.api_session_hdhive_pick, + "methods": ["POST"], + "summary": "按编号继续影巢会话:候选选片或资源解锁落盘", + }, + ] + + def _build_hdhive_page_summary(self) -> str: + if not self._enabled: + return "插件未启用" + if not self._hdhive_api_key: + return "影巢 API Key 未配置" + service = self._ensure_hdhive_service() + account_ok, account_result, account_message = service.fetch_me() + quota_ok, quota_result, _quota_message = service.fetch_quota() + usage_ok, usage_result, _usage_message = service.fetch_usage_today() + + account = account_result.get("data") or {} + account_source = "hdhive_openapi" + if not account_ok and self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + account = fallback_account + account_ok = True + account_source = "hdhivedailysign_snapshot" + account_fields = self._extract_hdhive_account_fields(account) + quota = quota_result.get("data") or {} + usage = usage_result.get("data") or {} + + return ( + f"影巢账号:{'可用' if account_ok else '异常'}" + f"\n资源入口:{'开启' if self._hdhive_resource_enabled else '关闭'}" + f"\n单资源积分上限:{self._hdhive_max_unlock_points if self._hdhive_max_unlock_points > 0 else '不限制'}" + f"\n签到入口:{'开启' if self._hdhive_checkin_enabled else '关闭'}" + f"\n昵称:{account_fields.get('nickname', '—')}" + f"\n积分:{account_fields.get('points', '—')}" + f"\nVIP:{'是' if account_fields.get('is_vip') else '否'}" + f"\n累计签到:{account_fields.get('signin_days_total', '—')}" + f"\n今日剩余配额:{quota.get('endpoint_remaining', '—')}" + f"\n今日总调用:{usage.get('total_calls', '—')}" + f"\n账号来源:{'网页快照' if account_source == 'hdhivedailysign_snapshot' else 'OpenAPI'}" + ) + + def get_page(self) -> List[dict]: + quark_ready = "已配置" if self._quark_cookie else "未配置" + hdhive_ready = "已配置" if self._hdhive_api_key else "未配置" + p115_health_ok, p115_health, _p115_health_message = self._ensure_p115_service().health() + cookie_state = p115_health.get("cookie_state") or {} + if cookie_state.get("valid"): + p115_ready = "已配置扫码会话" + elif cookie_state.get("configured"): + p115_ready = "已配置但不是扫码会话" + else: + p115_ready = "复用 115 助手客户端" + hdhive_summary = self._build_hdhive_page_summary() + feishu_health = self._ensure_feishu_channel().health() + feishu_state = "已启用" if feishu_health.get("enabled") else "未启用" + feishu_running = "运行中" if feishu_health.get("running") else "未运行" + hdhive_lines = [line.strip() for line in str(hdhive_summary or "").splitlines() if line.strip()] + hdhive_compact_lines = hdhive_lines[:4] + if len(hdhive_lines) >= 6: + hdhive_compact_lines.append(f"{hdhive_lines[4]} / {hdhive_lines[5]}") + p115_cookie_message = cookie_state.get("message") or "当前会话可直接用于 115 直转" + + def text_line(text: str, css_class: str = "text-body-2 py-1") -> Dict[str, Any]: + return { + "component": "div", + "props": {"class": css_class}, + "text": text, + } + + def status_card(title: str, subtitle: str, lines: List[str], color: str = "primary") -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"variant": "tonal", "color": color, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1"}, + "text": title, + }, + { + "component": "VCardSubtitle", + "props": {"class": "text-body-2"}, + "text": subtitle, + }, + { + "component": "VCardText", + "props": {"class": "py-2"}, + "content": [text_line(line, "text-body-2 py-0") for line in lines], + }, + ], + } + + def section_card(title: str, lines: List[str], compact: bool = False) -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"flat": True, "border": True, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1" if compact else "text-subtitle-1 font-weight-bold"}, + "text": title, + }, + { + "component": "VCardText", + "props": {"class": "py-2"} if compact else {}, + "content": [text_line(line, "text-body-2 py-0") for line in lines] if compact else [text_line(line) for line in lines], + }, + ], + } + + return [ + { + "component": "VContainer", + "props": {"fluid": True, "class": "pa-0"}, + "content": [ + { + "component": "VRow", + "props": {"dense": True}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "影巢", + hdhive_ready, + [ + f"默认目录:{self._hdhive_default_path}", + "能力:搜索 / 解锁 / 签到", + "API:/hdhive/account /checkin /quota", + ], + "success" if self._hdhive_api_key else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "115", + "可用" if p115_health_ok else "待修复", + [ + f"默认目录:{self._p115_default_path}", + f"登录方式:{p115_ready}", + f"扫码客户端:{self._p115_client_type_title(self._p115_client_type)}", + ], + "success" if p115_health_ok else "error", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "夸克", + quark_ready, + [ + f"默认目录:{self._quark_default_path}", + "能力:分享链接转存", + "入口:通用分享路由", + ], + "success" if self._quark_cookie else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "飞书", + f"{feishu_state},长连接:{feishu_running}", + [ + "模式:内置 Channel", + "健康检查:/feishu/health", + "建议:只保留一个飞书入口监听", + ], + "success" if feishu_health.get("running") else "secondary", + ) + ], + }, + ], + }, + { + "component": "VRow", + "props": {"dense": True, "class": "mt-3"}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "智能体入口", + [ + "统一路由:/assistant/route", + "继续选择:/assistant/pick", + "工作流:/assistant/workflow", + "计划执行:/assistant/plan/execute", + "Agent Tool:搜索/选择、115 扫码、待任务查看/继续/取消、通用分享路由", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "账号与签到", + hdhive_compact_lines + + [ + f"115 Cookie:{p115_cookie_message}", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "盘搜服务", + [ + f"API 地址:{self._pansou_base_url}", + f"请求超时:{self._pansou_timeout} 秒", + "用法:发送“盘搜搜索 片名”“ps片名”或“1片名”。", + "说明:插件只负责调用 PanSou API,本机需要先运行 PanSou 服务。", + ], + compact=True, + ) + ], + } + ], + }, + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "class": "mt-4 mb-1", + "title": "统一资源入口", + }, + "content": [ + text_line( + "Agent影视助手支持三种接入模式:飞书直接发命令、外部智能体调用 skill/helper、MP 内置智能体调用 Agent Tool。", + "text-body-2 mb-3", + ), + text_line( + "不接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2 mb-3", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "如果你只想直接用插件或飞书入口,不需要额外安装 skill。\n" + "直接使用这些命令即可:搜索 片名 / 云盘搜索 片名 / 转存 片名 / 下载 片名 / 更新检查 片名。" + ), + }, + text_line( + "接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "插件页不再直接放大段接入提示词,避免复制到旧配置。\n" + "请按快速开始主页和外部智能体接入文档配置:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins\n\n" + "外部智能体接入文档:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md\n\n" + "如果客户端支持 MoviePilot 官方 MCP,也请按文档里的分工接入;资源流仍优先使用 agent-resource-officer skill/helper。\n" + "长会话跑偏时,可以直接对智能体说:校准影视技能。" + ), + }, + ], + }, + ], + } + ] + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + return "vuetify", None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + form = [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "插件把资源搜索、链接转存、扫码登录、飞书消息和智能体调用集中到一个入口。首次使用先配置默认目录、影巢 OpenAPI、夸克会话,以及需要的飞书机器人信息。调试模式仅排查问题时打开。", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "debug", + "label": "调试模式", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下面这组是智能体默认评分策略,只影响还没有保存个人偏好的新会话。高分不代表一定执行;遇到影巢高积分、PT 低做种这类硬风险时,插件仍会拦截。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "mp_download_save_path", + "label": "PT 下载保存路径(可选)", + "placeholder": "MP 和 qB 在同一台机器可留空;不在同一台机器时填 qB 默认下载路径,如 /media/downloads/qb", + "hint": "只影响“下载 / MP搜索 / PT搜索”。MP 与 qB 分离时,填 qB WebUI 里的默认保存路径;同机一般不用填。", + "persistentHint": True, + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_pt_min_seeders", + "label": "PT 最低做种数", + "type": "number", + "placeholder": "3", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_confirm_score_threshold", + "label": "建议确认分数线", + "type": "number", + "placeholder": "70", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_auto_ingest_score_threshold", + "label": "自动入库分数线", + "type": "number", + "placeholder": "90", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "assistant_default_auto_ingest_enabled", + "label": "默认允许高分自动入库", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢用于资源搜索、解锁、配额查询和签到。资源入口关闭后,智能体和飞书都不会执行影巢搜索、解锁或转存;单资源积分上限默认 20 分,超过就拦截提醒,填 0 表示不限制。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_api_key", + "label": "影巢 API Key", + "rows": 2, + "placeholder": "填写影巢 OpenAPI 的 API Key", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_resource_enabled", + "label": "启用影巢资源搜索/解锁", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_max_unlock_points", + "label": "单资源积分上限", + "type": "number", + "placeholder": "20;填 0 不限制", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "text": "建议保留积分上限,避免智能体一步到位时误选高积分资源。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_base_url", + "label": "影巢 Base URL", + "placeholder": "https://hdhive.com", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_timeout", + "label": "影巢超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_default_path", + "label": "影巢默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_candidate_page_size", + "label": "候选页大小", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢签到支持 OpenAPI 与网页兜底两种方式。OpenAPI 签到需要 Premium;普通用户建议优先使用本机“影巢Cookie导出.command”自动写回完整网页登录 Cookie。手工复制 Cookie 容易漏字段,导致看起来已填写但签到仍失败。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_enabled", + "label": "启用影巢签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_gambler_mode", + "label": "默认赌狗签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_once", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_cron", + "label": "影巢签到 Cron", + "placeholder": "0 8 * * *", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_checkin_cookie", + "label": "影巢网页 Cookie(非 Premium 兜底)", + "rows": 3, + "placeholder": "不建议手工填写;优先在 Edge 登录 hdhive.com 后运行“影巢Cookie导出.command”自动写回", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_auto_login", + "label": "自动刷新 Cookie", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_username", + "label": "影巢用户名/邮箱", + "placeholder": "用于 Cookie 失效时自动登录", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_password", + "label": "影巢密码", + "type": "password", + "placeholder": "仅保存在 MoviePilot 本机配置中", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "盘搜用于聚合公开网盘分享结果。请填写 MoviePilot 容器视角下可访问的 API 地址,不要按外部智能体机器的视角填写。普通“搜索/找片”默认先盘搜;“云盘搜索”固定比较盘搜 + 影巢。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_base_url", + "label": "盘搜 API 地址", + "placeholder": "http://host.docker.internal:805", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_timeout", + "label": "盘搜超时(秒)", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "夸克用于转存 pan.quark.cn 分享链接。优先使用 CookieCloud 或有效网页登录 Cookie;只有明确出现“require login [guest]”“夸克登录态已过期”“当前夸克登录态不足”时,才建议走 Cookie 修复。分享受限、41031、分享者封禁不属于 Cookie 失效。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "quark_cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "可手工填写,但更推荐 CookieCloud 或本机夸克Cookie导出工具自动写回", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_default_path", + "label": "夸克默认目录", + "placeholder": "/飞书", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_timeout", + "label": "夸克超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "quark_auto_import_cookiecloud", + "label": "允许自动刷新 Cookie", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "115 建议走扫码会话,不建议填网页版 Cookie。插件支持 /p115/qrcode 和 /p115/qrcode/check 两步扫码登录;手填 Cookie 仅作为高级兜底。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "p115_default_path", + "label": "115 默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "p115_client_type", + "label": "115 扫码客户端类型", + "items": self._p115_client_type_items(), + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "p115_prefer_direct", + "label": "115 优先直转", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "p115_cookie", + "label": "115 扫码会话 Cookie(高级,可选)", + "rows": 3, + "placeholder": "仅支持 UID/CID/SEID/KID 这类扫码客户端 Cookie;普通网页版 Cookie 不建议粘贴到这里", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "飞书入口默认关闭。开启后可以在飞书里发送搜索、云盘搜索、转存、夸克转存、下载、更新检查、115 登录和影巢签到等命令;同一个飞书机器人建议只配置一个接收入口。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_enabled", + "label": "启用内置飞书入口", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_allow_all", + "label": "允许所有飞书会话", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_reply_enabled", + "label": "发送飞书回复", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_id", + "label": "飞书 App ID", + "placeholder": "cli_xxxxxxxxx", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_secret", + "label": "飞书 App Secret", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_verification_token", + "label": "Verification Token", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "feishu_reply_receive_id_type", + "label": "回复 ID 类型", + "items": [ + {"title": "群聊 chat_id", "value": "chat_id"}, + {"title": "用户 open_id", "value": "open_id"}, + {"title": "用户 union_id", "value": "union_id"}, + {"title": "用户 user_id", "value": "user_id"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_chat_ids", + "label": "允许的群聊 Chat ID", + "rows": 3, + "placeholder": "一个一行;allow_all 关闭时生效", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_user_ids", + "label": "允许的用户 Open ID", + "rows": 3, + "placeholder": "一个一行", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_whitelist", + "label": "飞书命令白名单", + "rows": 3, + "placeholder": "逗号或换行分隔;留空时会自动合并当前主线命令。旧 STRM/刮削命令不再默认暴露,如需兼容旧环境可手动加入。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_aliases", + "label": "飞书命令别名", + "rows": 5, + "placeholder": FeishuChannel.default_command_aliases(), + "hint": "默认别名已统一走 Agent影视助手 route/pick:转存默认 115,夸克转存需显式发送;旧 STRM/刮削别名如需保留请手动添加。", + }, + } + ], + }, + ], + }, + ], + } + ] + return form, self._build_config() + + async def api_feishu_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + channel = self._ensure_feishu_channel() + return { + "success": True, + "message": "Agent影视助手 内置飞书入口状态", + "data": { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + }, + } + + async def api_quark_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_quark_service() + cookie_ok, cookie_message = service.check_cookie() + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "quark_cookie_configured": bool(self._quark_cookie), + "quark_cookie_valid": cookie_ok, + "default_target_path": self._quark_default_path, + "message": "" if cookie_ok else cookie_message, + }, + } + + async def api_quark_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_text = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_quark_service() + transfer_ok, result, transfer_message = service.transfer_share( + share_text, + access_code=access_code, + target_path=target_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_hdhive_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_hdhive_service() + ping_ok, result, ping_message, _status_code = service.request("GET", "/api/open/ping") + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "hdhive_api_key_configured": bool(self._hdhive_api_key), + "hdhive_ping_ok": ping_ok, + "base_url": self._hdhive_base_url, + "default_target_path": self._hdhive_default_path, + "resource_enabled": self._hdhive_resource_enabled, + "max_unlock_points": self._hdhive_max_unlock_points, + "checkin_enabled": self._hdhive_checkin_enabled, + "checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "checkin_cron": self._hdhive_checkin_cron, + "checkin_web_cookie_configured": bool(self._hdhive_checkin_cookie), + "checkin_auto_login_enabled": self._hdhive_checkin_auto_login, + "checkin_username_configured": bool(self._hdhive_checkin_username), + "message": "" if ping_ok else ping_message, + "raw": result, + }, + } + + async def api_hdhive_account(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + account_ok, result, account_message = service.fetch_me() + if not account_ok: + if self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + return { + "success": True, + "message": "当前返回的是网页用户快照", + "data": fallback_account, + } + return {"success": False, "message": self._friendly_hdhive_error(account_message, "账号"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_checkin(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + is_gambler = self._parse_bool_value(body.get("is_gambler"), self._hdhive_checkin_gambler_mode) + return self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 API") + + async def api_hdhive_checkin_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._hdhive_checkin_history_public_data(limit=limit) + return { + "success": True, + "message": self._format_hdhive_checkin_history_text(limit=limit), + "data": data, + } + + async def api_hdhive_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + quota_ok, result, quota_message = service.fetch_quota() + if not quota_ok: + return {"success": False, "message": self._friendly_hdhive_error(quota_message, "配额"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_usage_today(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + usage_ok, result, usage_message = service.fetch_usage_today() + if not usage_ok: + return {"success": False, "message": self._friendly_hdhive_error(usage_message, "今日用量"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_weekly_free_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + weekly_ok, result, weekly_message = service.fetch_weekly_free_quota() + if not weekly_ok: + return {"success": False, "message": self._friendly_hdhive_error(weekly_message, "每周免费额度"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + media_type = self._clean_text(body.get("media_type") or body.get("type") or "movie").lower() + tmdb_id = self._clean_text(body.get("tmdb_id")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = service.search_resources(media_type=media_type, tmdb_id=tmdb_id) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_search_by_keyword(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + candidate_limit = self._safe_int(body.get("candidate_limit"), self._hdhive_candidate_page_size) + result_limit = self._safe_int(body.get("limit"), 12) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + result_limit=result_limit, + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_unlock(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(body) + if not points_ok: + return {"success": False, "message": points_message, "data": {"resource_guard": points_data}} + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return {"success": False, "message": unlock_message, "data": result} + return {"success": True, "message": "success", "data": result} + + @staticmethod + def _new_session_id(prefix: str = "aro") -> str: + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + def _save_session(self, session_id: str, payload: Dict[str, Any]) -> None: + payload = dict(payload) + payload["updated_at"] = int(time.time()) + with self._session_lock: + self._session_cache[session_id] = payload + self._persist_relevant_sessions() + + def _load_session(self, session_id: str) -> Optional[Dict[str, Any]]: + with self._session_lock: + session = self._session_cache.get(session_id) + if not session: + return None + return dict(session) + + def _persist_relevant_sessions(self) -> None: + try: + data: Dict[str, Dict[str, Any]] = {} + with self._session_lock: + for session_id, payload in (self._session_cache or {}).items(): + session = dict(payload or {}) + if self._is_session_expired(session): + continue + if str(session_id).startswith("assistant::") or session.get("pending_p115") or str(session.get("kind") or "").strip() == "assistant_p115_login": + data[session_id] = session + self.save_data(key=self._session_store_key, value=data) + except Exception: + pass + + def _restore_persisted_sessions(self) -> None: + try: + restored = self.get_data(self._session_store_key) or {} + if isinstance(restored, dict): + with self._session_lock: + for session_id, payload in restored.items(): + if isinstance(payload, dict) and not self._is_session_expired(payload): + self._session_cache[str(session_id)] = dict(payload) + except Exception: + pass + + def _persist_execution_history(self) -> None: + try: + history = list(self._execution_history or [])[-self._execution_history_limit:] + self._execution_history = history + self.save_data(key=self._execution_history_store_key, value=history) + except Exception: + pass + + def _restore_execution_history(self) -> None: + try: + restored = self.get_data(self._execution_history_store_key) or [] + if isinstance(restored, list): + self._execution_history = [ + dict(item) + for item in restored[-self._execution_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._execution_history = [] + + def _persist_hdhive_checkin_history(self) -> None: + try: + history = list(self._hdhive_checkin_history or [])[-self._hdhive_checkin_history_limit:] + self._hdhive_checkin_history = history + self.save_data(key=self._hdhive_checkin_history_store_key, value=history) + except Exception: + pass + + def _restore_hdhive_checkin_history(self) -> None: + try: + restored = self.get_data(self._hdhive_checkin_history_store_key) or [] + if isinstance(restored, list): + self._hdhive_checkin_history = [ + dict(item) + for item in restored[-self._hdhive_checkin_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._hdhive_checkin_history = [] + + def _record_hdhive_checkin_history( + self, + *, + trigger: str, + is_gambler: bool, + result: Dict[str, Any], + ) -> None: + timestamp = int(time.time()) + payload = dict(result or {}) + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + entry = { + "id": self._new_session_id("hdhive-sign"), + "time": timestamp, + "time_text": self._format_unix_time(timestamp), + "trigger": self._clean_text(trigger), + "mode": "赌狗签到" if is_gambler else "普通签到", + "is_gambler": bool(is_gambler), + "success": bool(payload.get("success")), + "message_head": self._assistant_result_message_head(payload.get("message")), + "status": self._clean_text(data.get("status")) or ("成功" if payload.get("success") else "失败"), + "source": self._clean_text(data.get("source")), + "status_code": data.get("status_code"), + "login_retry": bool(((data.get("login") or {}) if isinstance(data.get("login"), dict) else {}).get("ok")), + "web_fallback": bool(data.get("web_fallback")) if isinstance(data, dict) else False, + } + self._hdhive_checkin_history.append(entry) + self._hdhive_checkin_history = self._hdhive_checkin_history[-self._hdhive_checkin_history_limit:] + self._persist_hdhive_checkin_history() + + def _hdhive_checkin_history_public_data(self, *, limit: int = 20) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), self._hdhive_checkin_history_limit) + items = list(reversed(self._hdhive_checkin_history or []))[:max_limit] + return { + "total": len(self._hdhive_checkin_history or []), + "limit": max_limit, + "items": items, + } + + def _format_hdhive_checkin_history_text(self, *, limit: int = 10) -> str: + data = self._hdhive_checkin_history_public_data(limit=limit) + items = data.get("items") or [] + if not items: + return "暂无影巢签到日志。" + lines = [f"影巢签到日志:最近 {len(items)} 条"] + for idx, item in enumerate(items, start=1): + ok_text = "成功" if item.get("success") else "失败" + parts = [ + f"{idx}. {item.get('time_text') or ''}", + f"{item.get('mode') or ''}", + ok_text, + ] + if item.get("trigger"): + parts.append(f"来源:{item.get('trigger')}") + if item.get("login_retry"): + parts.append("已自动刷新Cookie") + message = self._clean_text(item.get("message_head")) + if message: + parts.append(message) + lines.append(" | ".join(part for part in parts if part)) + return "\n".join(lines) + + def _hdhive_resource_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢资源入口已关闭:当前不会执行影巢搜索、解锁或转存。可在插件设置中开启“影巢资源搜索/解锁”。", + "data": { + "provider": "hdhive", + "resource_enabled": False, + "error_code": "hdhive_resource_disabled", + }, + } + + def _ensure_hdhive_resource_enabled(self) -> Tuple[bool, Dict[str, Any]]: + if self._hdhive_resource_enabled: + return True, {} + return False, self._hdhive_resource_disabled_response() + + def _hdhive_checkin_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢签到入口已关闭:如需执行签到,请先在插件设置中开启“影巢签到”。", + "data": { + "provider": "hdhive", + "checkin_enabled": False, + "error_code": "hdhive_checkin_disabled", + }, + } + + @staticmethod + def _resource_has_free_marker(item: Optional[Dict[str, Any]]) -> bool: + if not isinstance(item, dict) or not item: + return False + for key in ["unlock_points", "cost", "points", "price", "point_text"]: + text = str(item.get(key) or "").strip().lower() + if text in {"0", "free", "免费", "0分"}: + return True + blob = " ".join( + str(item.get(key) or "") + for key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "note", + "source", + "source_name", + ] + ).lower() + return "免费" in blob or " free " in f" {blob} " + + @staticmethod + def _resource_points_value(item: Optional[Dict[str, Any]]) -> Optional[int]: + if not item: + return None + raw = item.get("unlock_points") + if raw is None: + raw = item.get("cost") + if raw is None: + raw = item.get("points") + text = str(raw or "").strip() + if not text: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + if text.lower() == "free" or text == "免费": + return 0 + match = re.search(r"-?\d+", text) + if not match: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + try: + return int(match.group(0)) + except Exception: + return None + + def _check_hdhive_unlock_points_limit(self, resource: Optional[Dict[str, Any]]) -> Tuple[bool, str, Dict[str, Any]]: + limit = max(0, self._safe_int(self._hdhive_max_unlock_points, 20)) + if limit <= 0: + return True, "", {"limit": limit, "points": None, "limited": False} + points = self._resource_points_value(resource) + title = self._clean_text((resource or {}).get("title") or (resource or {}).get("matched_title") or "该资源") + if points is None: + return True, "", {"limit": limit, "points": None, "limited": False, "reason": "unknown_points", "title": title} + if points > limit: + return False, ( + f"已阻止影巢解锁:{title} 需要 {points} 分,超过当前单资源积分上限 {limit} 分。" + "如确认要解锁,请提高上限或临时设为 0。" + ), {"limit": limit, "points": points, "limited": True, "reason": "points_over_limit"} + return True, "", {"limit": limit, "points": points, "limited": False} + + def _default_assistant_preferences(self) -> Dict[str, Any]: + return { + "schema_version": "preferences.v1", + "initialized": False, + "prefer_resolution": "4K", + "prefer_dolby_vision": True, + "prefer_hdr": True, + "prefer_chinese_subtitle": True, + "prefer_complete_series": True, + "prefer_cloud_provider": "", + "enable_pansou": True, + "enable_hdhive": True, + "enable_mp_pt": True, + "has_quark": True, + "has_115": True, + "cloud_default_path": self._hdhive_default_path, + "quark_default_path": self._quark_default_path, + "p115_default_path": self._p115_default_path, + "pt_require_free": False, + "pt_min_seeders": self._assistant_default_pt_min_seeders, + "pt_prefer_free": True, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "updated_at": 0, + } + + def _normalize_preference_key(self, session: Any = None, user_key: Any = None) -> str: + key = self._clean_text(user_key) + if key: + return key + session_name = self._clean_text(session) or "default" + return f"session:{session_name}" + + def _normalize_assistant_preferences(self, value: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + defaults = self._default_assistant_preferences() + payload = dict(value or {}) + if "resolution_priority" in payload and "prefer_resolution" not in payload: + choices = payload.get("resolution_priority") + if isinstance(choices, (list, tuple)) and choices: + payload["prefer_resolution"] = choices[0] + else: + payload["prefer_resolution"] = choices + if "subtitle_priority" in payload and "prefer_chinese_subtitle" not in payload: + subtitle_text = " ".join(str(item) for item in payload.get("subtitle_priority") or []) if isinstance(payload.get("subtitle_priority"), (list, tuple)) else str(payload.get("subtitle_priority") or "") + payload["prefer_chinese_subtitle"] = bool(re.search(r"中文|中字|简中|繁中|双语|chinese", subtitle_text, flags=re.IGNORECASE)) + if "pt_free_only" in payload and "pt_require_free" not in payload: + payload["pt_require_free"] = payload.get("pt_free_only") + if "preferred_cloud_drive" in payload and "prefer_cloud_provider" not in payload: + providers = payload.get("preferred_cloud_drive") + payload["prefer_cloud_provider"] = providers[0] if isinstance(providers, (list, tuple)) and providers else providers + if "available_sources" in payload: + sources = payload.get("available_sources") + if isinstance(sources, (list, tuple, set)): + source_list = [self._clean_text(item).lower() for item in sources] + if "enable_pansou" not in payload: + payload["enable_pansou"] = "pansou" in source_list or "盘搜" in source_list + if "enable_hdhive" not in payload: + payload["enable_hdhive"] = any(item in source_list for item in ["hdhive", "影巢", "影潮"]) + if "enable_mp_pt" not in payload: + payload["enable_mp_pt"] = any(item in source_list for item in ["mp_pt", "mp", "pt", "原生", "moviepilot"]) + if "available_providers" in payload: + providers = payload.get("available_providers") + if isinstance(providers, (list, tuple, set)): + provider_list = [self._clean_text(item).lower() for item in providers] + if "has_115" not in payload: + payload["has_115"] = "115" in provider_list + if "has_quark" not in payload: + payload["has_quark"] = "quark" in provider_list or "夸克" in provider_list + if "auto_ingest" in payload and "auto_ingest_enabled" not in payload: + payload["auto_ingest_enabled"] = payload.get("auto_ingest") + if "auto_execute_score_threshold" in payload and "auto_ingest_score_threshold" not in payload: + payload["auto_ingest_score_threshold"] = payload.get("auto_execute_score_threshold") + normalized = {**defaults, **payload} + normalized["schema_version"] = "preferences.v1" + normalized["initialized"] = bool(normalized.get("initialized")) + normalized["prefer_resolution"] = self._clean_text(normalized.get("prefer_resolution") or defaults["prefer_resolution"]).upper() + normalized["prefer_dolby_vision"] = self._parse_bool_value(normalized.get("prefer_dolby_vision"), True) + normalized["prefer_hdr"] = self._parse_bool_value(normalized.get("prefer_hdr"), True) + normalized["prefer_chinese_subtitle"] = self._parse_bool_value(normalized.get("prefer_chinese_subtitle"), True) + normalized["prefer_complete_series"] = self._parse_bool_value(normalized.get("prefer_complete_series"), True) + normalized["prefer_cloud_provider"] = self._clean_text(normalized.get("prefer_cloud_provider")).lower() + normalized["enable_pansou"] = self._parse_bool_value(normalized.get("enable_pansou"), True) + normalized["enable_hdhive"] = self._parse_bool_value(normalized.get("enable_hdhive"), True) + normalized["enable_mp_pt"] = self._parse_bool_value(normalized.get("enable_mp_pt"), True) + normalized["has_quark"] = self._parse_bool_value(normalized.get("has_quark"), True) + normalized["has_115"] = self._parse_bool_value(normalized.get("has_115"), True) + normalized["cloud_default_path"] = self._normalize_path(normalized.get("cloud_default_path") or self._hdhive_default_path) + normalized["quark_default_path"] = self._normalize_path(normalized.get("quark_default_path") or self._quark_default_path) + normalized["p115_default_path"] = self._normalize_path(normalized.get("p115_default_path") or self._p115_default_path) + normalized["pt_require_free"] = self._parse_bool_value(normalized.get("pt_require_free"), False) + normalized["pt_min_seeders"] = max(0, self._safe_int(normalized.get("pt_min_seeders"), 3)) + normalized["pt_prefer_free"] = self._parse_bool_value(normalized.get("pt_prefer_free"), True) + normalized["hdhive_max_unlock_points"] = max(0, self._safe_int(normalized.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + normalized["auto_ingest_enabled"] = self._parse_bool_value(normalized.get("auto_ingest_enabled"), False) + normalized["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("auto_ingest_score_threshold"), 90))) + normalized["confirm_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("confirm_score_threshold"), 70))) + normalized["updated_at"] = self._safe_int(normalized.get("updated_at"), 0) + return normalized + + def _restore_assistant_preferences(self) -> None: + try: + restored = self.get_data(self._assistant_preferences_store_key) or {} + if isinstance(restored, dict): + self._assistant_preferences = { + self._clean_text(key): self._normalize_assistant_preferences(value) + for key, value in restored.items() + if self._clean_text(key) and isinstance(value, dict) + } + except Exception: + self._assistant_preferences = {} + + def _persist_assistant_preferences(self) -> None: + try: + items = list((self._assistant_preferences or {}).items())[-self._assistant_preferences_limit:] + self._assistant_preferences = {key: value for key, value in items if key} + self.save_data(key=self._assistant_preferences_store_key, value=self._assistant_preferences) + except Exception: + pass + + def _assistant_preferences_public_data(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + questions = [ + "你更偏好 4K 还是 1080P?", + "是否优先杜比视界 / HDR?", + "是否必须中文字幕?", + "电视剧是否优先全集或完整季?", + "你可用哪些源?例如:只用盘搜、只用影巢、只用 MP/PT,或者关闭其中某些源。", + "你可用哪些云盘?例如:只有夸克、只有 115、两者都可。", + "PT 资源最低做种数是多少?默认 3。", + "影巢单资源最多愿意消耗多少积分?默认 20。", + "是否允许 90 分以上资源自动入库?默认关闭。", + ] + return { + "schema_version": "preferences.v1", + "key": key, + "initialized": bool(preferences.get("initialized")), + "preferences": preferences, + "needs_onboarding": not bool(preferences.get("initialized")), + "onboarding_questions": questions if not bool(preferences.get("initialized")) else [], + "defaults": self._default_assistant_preferences(), + } + + def _save_assistant_preferences( + self, + *, + session: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + current = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + incoming = dict(preferences or {}) + merged = {**current, **incoming} + merged["initialized"] = self._parse_bool_value(incoming.get("initialized"), True) if "initialized" in incoming else True + merged["updated_at"] = int(time.time()) + normalized = self._normalize_assistant_preferences(merged) + self._assistant_preferences[key] = normalized + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _reset_assistant_preferences(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + self._assistant_preferences.pop(key, None) + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _assistant_preferences_status_brief(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + data = self._assistant_preferences_public_data(session=session, user_key=user_key) + prefs = dict(data.get("preferences") or {}) + brief = { + "key": data.get("key"), + "initialized": bool(data.get("initialized")), + "needs_onboarding": bool(data.get("needs_onboarding")), + "summary": { + "prefer_resolution": self._clean_text(prefs.get("prefer_resolution")), + "prefer_dolby_vision": bool(prefs.get("prefer_dolby_vision")), + "prefer_hdr": bool(prefs.get("prefer_hdr")), + "prefer_chinese_subtitle": bool(prefs.get("prefer_chinese_subtitle")), + "prefer_complete_series": bool(prefs.get("prefer_complete_series")), + "prefer_cloud_provider": self._clean_text(prefs.get("prefer_cloud_provider")), + "enable_pansou": bool(prefs.get("enable_pansou")), + "enable_hdhive": bool(prefs.get("enable_hdhive")), + "enable_mp_pt": bool(prefs.get("enable_mp_pt")), + "has_quark": bool(prefs.get("has_quark")), + "has_115": bool(prefs.get("has_115")), + "pt_min_seeders": self._safe_int(prefs.get("pt_min_seeders"), 3), + "pt_require_free": bool(prefs.get("pt_require_free")), + "hdhive_max_unlock_points": self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points), + "auto_ingest_enabled": bool(prefs.get("auto_ingest_enabled")), + "auto_ingest_score_threshold": self._safe_int(prefs.get("auto_ingest_score_threshold"), 90), + }, + } + if brief["needs_onboarding"]: + brief["onboarding_questions"] = data.get("onboarding_questions") or [] + brief["recommended_action"] = "ask_user_preferences_then_save" + return brief + + def _assistant_default_preferences_template(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "prefer_resolution": prefs.get("prefer_resolution"), + "prefer_dolby_vision": prefs.get("prefer_dolby_vision"), + "prefer_hdr": prefs.get("prefer_hdr"), + "prefer_chinese_subtitle": prefs.get("prefer_chinese_subtitle"), + "prefer_complete_series": prefs.get("prefer_complete_series"), + "prefer_cloud_provider": prefs.get("prefer_cloud_provider"), + "enable_pansou": prefs.get("enable_pansou"), + "enable_hdhive": prefs.get("enable_hdhive"), + "enable_mp_pt": prefs.get("enable_mp_pt"), + "has_quark": prefs.get("has_quark"), + "has_115": prefs.get("has_115"), + "pt_require_free": prefs.get("pt_require_free"), + "pt_min_seeders": prefs.get("pt_min_seeders"), + "hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "p115_default_path": prefs.get("p115_default_path"), + "quark_default_path": prefs.get("quark_default_path"), + "auto_ingest_enabled": prefs.get("auto_ingest_enabled"), + "auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + } + + def _assistant_preferences_for_session(self, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + return self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + + def _assistant_source_enabled(self, preferences: Optional[Dict[str, Any]], source_type: str) -> bool: + prefs = self._normalize_assistant_preferences(preferences) + source = self._clean_text(source_type).lower() + if source == "pansou": + return bool(prefs.get("enable_pansou")) + if source == "hdhive": + return bool(prefs.get("enable_hdhive")) + if source in {"mp", "mp_pt", "pt"}: + return bool(prefs.get("enable_mp_pt")) + return True + + def _assistant_available_cloud_providers(self, preferences: Optional[Dict[str, Any]]) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + providers: List[str] = [] + if self._parse_bool_value(prefs.get("has_115"), True): + providers.append("115") + if self._parse_bool_value(prefs.get("has_quark"), True): + providers.append("quark") + return providers + + def _assistant_filter_cloud_items_by_preferences( + self, + items: Optional[List[Dict[str, Any]]], + preferences: Optional[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + available = set(self._assistant_available_cloud_providers(preferences)) + source_items = [dict(item or {}) for item in (items or []) if isinstance(item, dict)] + if not available: + return [] + if available == {"115", "quark"}: + return source_items + + filtered: List[Dict[str, Any]] = [] + for item in source_items: + provider = self._clean_text( + item.get("pan_type") + or item.get("provider") + or item.get("channel") + or item.get("network") + or item.get("netdisk") + or item.get("cloud") + ).lower() + if provider in {"quark", "夸克", "quarkcloud"}: + provider = "quark" + elif provider in {"115", "115cloud", "115网盘"}: + provider = "115" + if provider in available: + filtered.append(item) + return filtered + + def _assistant_smart_search_source_order( + self, + preferences: Optional[Dict[str, Any]], + source_order: Optional[List[str]] = None, + ) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + requested = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + order = requested or ["pansou", "hdhive", "mp_pt"] + normalized: List[str] = [] + for item in order: + if item in {"mp", "pt"}: + item = "mp_pt" + if item not in {"pansou", "hdhive", "mp_pt"} or item in normalized: + continue + if self._assistant_source_enabled(prefs, item): + normalized.append(item) + return normalized + + def _assistant_smart_source_availability( + self, + preferences: Optional[Dict[str, Any]], + *, + source_order: Optional[List[str]] = None, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + prefs = self._normalize_assistant_preferences(preferences) + available_cloud = self._assistant_available_cloud_providers(prefs) + order = self._assistant_smart_search_source_order(prefs, source_order=source_order) + source_labels = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + available: List[Dict[str, Any]] = [] + blocked: List[Dict[str, Any]] = [] + for source in ["pansou", "hdhive", "mp_pt"]: + entry = { + "source_type": source, + "label": source_labels[source], + } + if source not in order: + if not self._assistant_source_enabled(prefs, source): + blocked.append({**entry, "reason": f"当前偏好已关闭{source_labels[source]}"}) + else: + blocked.append({**entry, "reason": "当前决策顺序未包含该源"}) + continue + if source in {"pansou", "hdhive"} and not available_cloud: + blocked.append({**entry, "reason": "当前偏好未启用任何云盘"}) + continue + available.append({ + **entry, + "provider_scope": list(available_cloud) if source in {"pansou", "hdhive"} else ["pt"], + }) + if "115" not in available_cloud: + blocked.append({"source_type": "115", "label": "115", "reason": "当前偏好未启用 115"}) + if "quark" not in available_cloud: + blocked.append({"source_type": "quark", "label": "夸克", "reason": "当前偏好未启用夸克"}) + return available, blocked + + def _assistant_smart_decision_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + profile = self._clean_text(decision_profile).lower() + if not profile: + return prefs + updated = dict(prefs) + confirm_threshold = self._safe_int( + updated.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + if profile == "conservative": + updated["confirm_score_threshold"] = max(confirm_threshold, min(95, confirm_threshold + 10)) + elif profile == "aggressive": + updated["confirm_score_threshold"] = max(40, min(100, confirm_threshold - 10)) + return self._normalize_assistant_preferences(updated) + + def _assistant_smart_session_overrides_summary(self, overrides: Optional[Dict[str, Any]]) -> str: + raw = dict(overrides or {}) + if not raw: + return "" + payload = self._normalize_assistant_preferences(raw) + parts: List[str] = [] + if "has_quark" in raw or "has_115" in raw: + if payload.get("has_quark") and not payload.get("has_115"): + parts.append("只用夸克") + elif payload.get("has_115") and not payload.get("has_quark"): + parts.append("只用115") + elif payload.get("has_115") and payload.get("has_quark"): + parts.append("115 / 夸克都可") + if "enable_pansou" in raw or "enable_hdhive" in raw or "enable_mp_pt" in raw: + enabled_sources: List[str] = [] + if payload.get("enable_pansou"): + enabled_sources.append("盘搜") + if payload.get("enable_hdhive"): + enabled_sources.append("影巢") + if payload.get("enable_mp_pt"): + enabled_sources.append("MP/PT") + if enabled_sources: + parts.append("仅会话源:" + " / ".join(enabled_sources)) + return ";".join(parts[:2]) + + def _assistant_smart_merge_session_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + session_overrides: Optional[Dict[str, Any]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + merged = { + **self._normalize_assistant_preferences(preferences), + **dict(session_overrides or {}), + } + return self._assistant_smart_decision_preferences( + merged, + decision_profile=decision_profile, + ) + + def _assistant_smart_apply_session_override( + self, + session_overrides: Optional[Dict[str, Any]], + *, + adjust_action: str, + source_order: Optional[List[str]] = None, + ) -> Tuple[Dict[str, Any], List[str]]: + overrides = dict(session_overrides or {}) + order = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + normalized_action = self._clean_text(adjust_action).lower() + if normalized_action == "decision_only_quark": + overrides["has_quark"] = True + overrides["has_115"] = False + overrides["prefer_cloud_provider"] = "quark" + elif normalized_action == "decision_only_115": + overrides["has_quark"] = False + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "115" + elif normalized_action == "decision_cloud_both": + overrides["has_quark"] = True + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "" + elif normalized_action == "decision_only_mp_pt": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = True + order = ["mp_pt"] + elif normalized_action == "decision_only_pansou": + overrides["enable_pansou"] = True + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = False + order = ["pansou"] + elif normalized_action == "decision_only_hdhive": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = True + overrides["enable_mp_pt"] = False + order = ["hdhive"] + elif normalized_action == "decision_disable_pansou": + overrides["enable_pansou"] = False + elif normalized_action == "decision_disable_hdhive": + overrides["enable_hdhive"] = False + elif normalized_action == "decision_disable_mp_pt": + overrides["enable_mp_pt"] = False + elif normalized_action == "decision_reset_preferences": + overrides = {} + order = [] + return overrides, order + + def _parse_assistant_preferences_text(self, text: str) -> Dict[str, Any]: + raw = self._clean_text(text) + compact = re.sub(r"\s+", "", raw).lower() + payload: Dict[str, Any] = {} + if re.search(r"1080|fhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "1080P" + elif re.search(r"4k|2160|uhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "4K" + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}(杜比|dv)", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = False + elif re.search(r"杜比|dv|dolby", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = False + elif re.search(r"hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,6}(中字|中文|字幕)", raw, flags=re.IGNORECASE) or re.search(r"无字幕也可|字幕无所谓", raw): + payload["prefer_chinese_subtitle"] = False + elif re.search(r"中字|中文|简中|繁中|双语字幕|字幕", raw, flags=re.IGNORECASE): + payload["prefer_chinese_subtitle"] = True + + if re.search(r"不强求全集|不要全集|单集也可|更新也可", raw): + payload["prefer_complete_series"] = False + elif re.search(r"全集|完整季|整季|完结", raw): + payload["prefer_complete_series"] = True + + if re.search(r"夸克优先|优先夸克|quark", raw, flags=re.IGNORECASE): + payload["prefer_cloud_provider"] = "quark" + elif re.search(r"115优先|优先115", raw): + payload["prefer_cloud_provider"] = "115" + + if re.search(r"只用盘搜|仅用盘搜|只搜盘搜", raw): + payload["enable_pansou"] = True + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = False + elif re.search(r"只用影巢|仅用影巢|只搜影巢|只用影潮", raw): + payload["enable_pansou"] = False + payload["enable_hdhive"] = True + payload["enable_mp_pt"] = False + elif re.search(r"只用原生|只用pt|仅用pt|只搜pt|只搜原生|只要原生", raw, flags=re.IGNORECASE): + payload["enable_pansou"] = False + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}盘搜", raw): + payload["enable_pansou"] = False + elif re.search(r"(启用|使用|打开).{0,4}盘搜", raw): + payload["enable_pansou"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = False + elif re.search(r"(启用|使用|打开).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = False + elif re.search(r"(启用|使用|打开).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = True + + if re.search(r"只有夸克|仅有夸克|只用夸克|没有115|不用115", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + payload["has_115"] = False + elif re.search(r"只有115|仅有115|只用115|没有夸克|不用夸克", raw, flags=re.IGNORECASE): + payload["has_115"] = True + payload["has_quark"] = False + if re.search(r"(不要|不用|关闭|禁用).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = False + elif re.search(r"(启用|使用|打开).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}115", raw): + payload["has_115"] = False + elif re.search(r"(启用|使用|打开).{0,4}115", raw): + payload["has_115"] = True + + if re.search(r"pt.{0,4}(只要|必须).{0,4}免费|只下免费|只要免费", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = True + elif re.search(r"pt.{0,4}(不限|不强求).{0,4}免费|不只要免费|免费不强求", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = False + + seed_match = re.search(r"(?:做种|种子|seeders?|seeder)[^\d]{0,8}(\d+)", raw, flags=re.IGNORECASE) + if seed_match: + payload["pt_min_seeders"] = self._safe_int(seed_match.group(1), 3) + + points_match = re.search(r"(?:影巢|积分|解锁)[^\d]{0,10}(\d+)", raw) + if points_match: + payload["hdhive_max_unlock_points"] = self._safe_int(points_match.group(1), self._hdhive_max_unlock_points) + + if re.search(r"(不|不要|关闭|禁用).{0,4}自动入库", raw): + payload["auto_ingest_enabled"] = False + elif re.search(r"自动入库|自动下载|自动转存", raw): + payload["auto_ingest_enabled"] = True + + threshold_match = re.search(r"(?:自动入库(?:评分|分数|阈值)|评分阈值|分数阈值|阈值)[^\d]{0,10}(\d{2,3})", raw) + if threshold_match: + payload["auto_ingest_score_threshold"] = self._safe_int(threshold_match.group(1), 90) + + for key, target in [ + ("115目录", "p115_default_path"), + ("p115目录", "p115_default_path"), + ("夸克目录", "quark_default_path"), + ("quark目录", "quark_default_path"), + ("云盘目录", "cloud_default_path"), + ]: + match = re.search(rf"{re.escape(key)}\s*=\s*([^\s,,]+)", raw, flags=re.IGNORECASE) + if match: + payload[target] = self._normalize_path(match.group(1)) + + if "initialized" not in payload and payload: + payload["initialized"] = True + if "auto_ingest_score_threshold" in payload: + payload["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(payload["auto_ingest_score_threshold"], 90))) + if "pt_min_seeders" in payload: + payload["pt_min_seeders"] = max(0, self._safe_int(payload["pt_min_seeders"], 3)) + if "hdhive_max_unlock_points" in payload: + payload["hdhive_max_unlock_points"] = max(0, self._safe_int(payload["hdhive_max_unlock_points"], self._hdhive_max_unlock_points)) + return payload + + @staticmethod + def _score_text_blob(item: Any) -> str: + if isinstance(item, dict): + parts: List[str] = [] + for value in item.values(): + if isinstance(value, (dict, list, tuple)): + parts.append(AgentResourceOfficer._score_text_blob(value)) + elif value is not None: + parts.append(str(value)) + return " ".join(parts).lower() + if isinstance(item, (list, tuple)): + return " ".join(AgentResourceOfficer._score_text_blob(value) for value in item).lower() + return str(item or "").lower() + + @staticmethod + def _score_has_any(text: str, keywords: List[str]) -> bool: + return any(keyword.lower() in text for keyword in keywords) + + @classmethod + def _score_quality_rank(cls, item: Any) -> int: + text = cls._score_text_blob(item) + rank = 0 + if cls._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + rank += 60 + if cls._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比", "臻彩"]): + rank += 45 + if cls._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + rank += 35 + if cls._score_has_any(text, ["4k", "2160", "uhd"]): + rank += 25 + if cls._score_has_any(text, ["高码率", "remux", "原盘", "web-dl", "webrip"]): + rank += 15 + if cls._score_has_any(text, ["e01", "s01", "全集", "全季", "更新至", "更至"]): + rank += 10 + if cls._score_has_any(text, ["中字", "简中", "繁中", "内封", "内嵌", "字幕"]): + rank += 8 + return rank + + @staticmethod + def _extract_series_progress(text: str) -> Dict[str, int]: + blob = str(text or "").lower() + result = {"max_episode": 0, "episode_count": 0} + episodes: List[int] = [] + for pattern in ( + r"\be(\d{1,3})\b", + r"第\s*(\d{1,3})\s*集", + r"更新(?:至)?\s*(?:ep|e)?\s*0*(\d{1,3})", + r"更(?:至)?\s*0*(\d{1,3})\s*集", + ): + for match in re.finditer(pattern, blob, flags=re.IGNORECASE): + try: + episodes.append(int(match.group(1))) + except Exception: + continue + for pattern in ( + r"e0*(\d{1,3})\s*[-~—]\s*e?0*(\d{1,3})", + r"第?\s*0*(\d{1,3})\s*[-~—]\s*0*(\d{1,3})\s*集", + ): + match = re.search(pattern, blob, flags=re.IGNORECASE) + if not match: + continue + try: + start_ep = int(match.group(1)) + end_ep = int(match.group(2)) + except Exception: + continue + if end_ep >= start_ep > 0: + result["episode_count"] = max(result["episode_count"], end_ep - start_ep + 1) + episodes.extend([start_ep, end_ep]) + if episodes: + result["max_episode"] = max(episodes) + if result["episode_count"] <= 0 and len(set(episodes)) >= 2: + sorted_eps = sorted(set(episodes)) + if sorted_eps == list(range(sorted_eps[0], sorted_eps[-1] + 1)): + result["episode_count"] = len(sorted_eps) + return result + + @staticmethod + def _score_level(score: int) -> str: + if score >= 90: + return "excellent" + if score >= 70: + return "confirm" + return "low" + + def _score_decision( + self, + *, + score: int, + risk_reasons: List[str], + hard_risk_reasons: Optional[List[str]] = None, + preferences: Dict[str, Any], + default_action: str, + ) -> Dict[str, Any]: + threshold = max(1, min(100, self._safe_int(preferences.get("auto_ingest_score_threshold"), 90))) + confirm_threshold = max(1, min(100, self._safe_int(preferences.get("confirm_score_threshold"), 70))) + auto_enabled = self._parse_bool_value(preferences.get("auto_ingest_enabled"), False) + hard_risks = [self._clean_text(item) for item in (hard_risk_reasons or []) if self._clean_text(item)] + hard_risk = bool(hard_risks) + can_auto = bool(auto_enabled and not hard_risk and score >= threshold) + if can_auto: + recommended = default_action + elif score >= confirm_threshold and not hard_risk: + recommended = "ask_confirm" + else: + recommended = "do_not_auto" + return { + "score": score, + "score_level": self._score_level(score), + "risk_reasons": risk_reasons, + "hard_risk_reasons": hard_risks, + "can_auto_execute": can_auto, + "recommended_action": recommended, + "auto_ingest_enabled": auto_enabled, + "auto_ingest_score_threshold": threshold, + "confirm_score_threshold": confirm_threshold, + } + + def _score_cloud_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + provider = self._clean_text(item.get("pan_type") or item.get("channel")).lower() + media_type = self._clean_text(item.get("media_type") or item.get("type")).lower() + series_like = ( + media_type in {"tv", "series", "电视剧", "剧集", "番剧"} + or bool(re.search(r"\bs\d{1,2}\b|\be\d{1,3}\b|season|第\s*\d+\s*集|[全整]季|全集|完结|更至|更新至|短剧|剧集", text, flags=re.IGNORECASE)) + ) + series_progress = self._extract_series_progress(text) + + if "2160" in text or "4k" in text or "uhd" in text: + score += 25 + reasons.append("4K/UHD +25") + elif "1080" in text: + score += 16 + reasons.append("1080P +16") + elif "720" in text: + score += 6 + reasons.append("720P +6") + if resolution_pref and resolution_pref in text: + score += 5 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +5") + + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + score += 18 + reasons.append("杜比视界 +18") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + score += 12 + reasons.append("HDR +12") + if self._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + score += 4 + reasons.append("高帧率 +4") + + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "内封简繁", "双语", "官中"]): + score += 14 + reasons.append("中文字幕 +14") + elif self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True): + score -= 5 + risks.append("未识别到中文字幕") + + completeness_detected = self._score_has_any(text, ["全集", "全季", "完结", "complete", "全 ", "更至", "更0", "更新至"]) + if not completeness_detected and series_like and series_progress.get("episode_count", 0) >= 3: + completeness_detected = True + + if completeness_detected: + score += 12 + reasons.append("完整度信息 +12") + elif series_like and self._parse_bool_value(prefs.get("prefer_complete_series"), True): + score -= 6 + risks.append("未识别到全集/更新完整度") + + if series_like and series_progress.get("max_episode", 0) > 0: + progress_bonus = min(10, series_progress["max_episode"]) + if progress_bonus > 0: + score += progress_bonus + reasons.append(f"更新到 E{series_progress['max_episode']:02d} +{progress_bonus}") + if series_progress["max_episode"] >= 7: + latest_bonus = min(4, series_progress["max_episode"] - 6) + if latest_bonus > 0: + score += latest_bonus + reasons.append(f"更新进度领先 +{latest_bonus}") + if series_like and series_progress.get("episode_count", 0) >= 3: + pack_bonus = min(12, series_progress["episode_count"] + 2) + score += pack_bonus + reasons.append(f"连续剧集包 +{pack_bonus}") + if series_progress["episode_count"] >= series_progress.get("max_episode", 0) >= 3: + score += 4 + reasons.append("从 E01 连续覆盖到当前 +4") + + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + + prefer_provider = self._clean_text(prefs.get("prefer_cloud_provider")).lower() + if prefer_provider and provider and prefer_provider == provider: + score += 5 + reasons.append(f"匹配网盘偏好 {provider} +5") + if provider == "115" and not self._parse_bool_value(prefs.get("has_115"), True): + message = "当前偏好未启用 115" + risks.append(message) + hard_risks.append(message) + score -= 30 + if provider == "quark" and not self._parse_bool_value(prefs.get("has_quark"), True): + message = "当前偏好未启用夸克" + risks.append(message) + hard_risks.append(message) + score -= 30 + if target_path and target_path in { + self._clean_text(prefs.get("cloud_default_path")), + self._clean_text(prefs.get("p115_default_path")), + self._clean_text(prefs.get("quark_default_path")), + }: + score += 3 + reasons.append("匹配默认目录 +3") + + if source_type == "hdhive": + limit = max(0, self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + points = self._resource_points_value(item) + if points == 0: + score += 10 + reasons.append("影巢免费 +10") + elif points is None and limit > 0: + reasons.append("影巢未标积分") + elif limit > 0 and points is not None and points > limit: + score -= 30 + message = f"影巢积分 {points} 超过上限 {limit},禁止自动解锁" + risks.append(message) + hard_risks.append(message) + elif points is not None: + score += max(0, 10 - points) + reasons.append(f"影巢积分 {points}") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_ingest_cloud", + ) + return { + **decision, + "source_type": source_type, + "score_reasons": reasons, + } + + def _score_pt_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else item + meta = item.get("meta_info") if isinstance(item.get("meta_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + min_seeders = max(0, self._safe_int(prefs.get("pt_min_seeders"), 3)) + seeders = self._safe_int(torrent.get("seeders"), 0) + peers = self._safe_int(torrent.get("peers"), 0) + volume = self._clean_text(torrent.get("volume_factor") or item.get("volume_factor")).lower() + title = self._clean_text(torrent.get("title")) + site_name = self._clean_text(torrent.get("site_name")) + group_name = self._clean_text(meta.get("resource_team")) + media_title = self._clean_text(media.get("title")).lower() + media_year = self._clean_text(media.get("year")) + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + prefer_dovi = self._parse_bool_value(prefs.get("prefer_dolby_vision"), True) + prefer_hdr = self._parse_bool_value(prefs.get("prefer_hdr"), True) + prefer_subtitle = self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True) + prefer_complete = self._parse_bool_value(prefs.get("prefer_complete_series"), True) + + if seeders <= 0: + message = "做种数 0,禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 35 + elif seeders < min_seeders: + message = f"做种数 {seeders},低于阈值 {min_seeders},禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 25 + elif seeders >= 20: + score += 22 + reasons.append(f"做种数 {seeders} +22") + elif seeders >= 10: + score += 16 + reasons.append(f"做种数 {seeders} +16") + else: + score += 10 + reasons.append(f"做种数 {seeders} +10") + + if peers >= seeders * 5 and seeders < 5: + score -= 8 + risks.append("下载需求高但做种偏低") + elif peers >= max(8, seeders): + score += 6 + reasons.append(f"下载热度 {peers} +6") + elif peers >= 3: + score += 3 + reasons.append(f"下载热度 {peers} +3") + + if self._score_has_any(volume, ["free", "免费", "2xfree", "2x free", "freeleech"]): + score += 18 + reasons.append("免费/促销 +18") + elif self._parse_bool_value(prefs.get("pt_require_free"), False): + score -= 20 + message = "用户要求 PT 免费资源" + risks.append(message) + hard_risks.append(message) + elif self._parse_bool_value(prefs.get("pt_prefer_free"), True): + reasons.append("普通 PT 资源,未额外扣分") + + if "2160" in text or "4k" in text or "uhd" in text: + score += 20 + reasons.append("4K/UHD +20") + elif "1080" in text: + score += 12 + reasons.append("1080P +12") + elif "720" in text: + score += 4 + reasons.append("720P +4") + if resolution_pref and resolution_pref in text: + score += 6 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +6") + has_dovi = self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]) + has_hdr = self._score_has_any(text, ["hdr10", "hdr", "hlg"]) + if has_dovi: + score += 14 + reasons.append("杜比视界 +14") + elif prefer_dovi: + score -= 4 + risks.append("未识别到杜比视界") + if has_hdr: + score += 9 + reasons.append("HDR +9") + elif prefer_hdr: + score -= 2 + risks.append("未识别到 HDR") + if self._score_has_any(text, ["中字", "简中", "繁中", "双语", "内封", "字幕"]): + score += 10 + reasons.append("字幕信息 +10") + elif prefer_subtitle: + score -= 5 + risks.append("未识别到中文字幕") + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + if self._score_has_any(text, ["s01", "season", "全集", "全季", "complete", "完结", "更新至", "更至"]): + score += 6 + reasons.append("季/全集标识 +6") + elif prefer_complete and self._score_has_any(text, ["s01", "s02", "e01", "第1集", "season"]): + score -= 4 + risks.append("未识别到全集/完整季") + + if media_title: + if media_title in title.lower(): + score += 8 + reasons.append("标题匹配媒体名 +8") + else: + score -= 4 + risks.append("标题与识别媒体名匹配一般") + if media_year and media_year in title: + score += 4 + reasons.append(f"年份匹配 {media_year} +4") + if site_name: + score += 2 + reasons.append(f"站点标识 {site_name} +2") + if group_name: + score += 2 + reasons.append(f"发布组 {group_name} +2") + + size_text = self._clean_text(torrent.get("size") or item.get("size")) + size_value = 0.0 + size_match = re.search(r"(\d+(?:\.\d+)?)\s*(tb|gb|mb)", size_text, flags=re.IGNORECASE) + if size_match: + size_value = float(size_match.group(1)) + unit = size_match.group(2).lower() + if unit == "tb": + size_value *= 1024 + elif unit == "mb": + size_value /= 1024 + if size_value > 0: + if self._score_has_any(text, ["2160", "4k", "uhd", "remux", "原盘"]) and size_value < 8: + score -= 6 + risks.append(f"体积 {size_text} 偏小,需留意是否压制过度") + elif self._score_has_any(text, ["1080", "web-dl", "bluray"]) and size_value >= 4: + score += 3 + reasons.append(f"体积 {size_text} 较充足 +3") + if peers >= 30 and seeders >= min_seeders: + score += 4 + reasons.append("热度稳定 +4") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_download_pt", + ) + return { + **decision, + "source_type": "pt", + "score_reasons": reasons, + "seeders": seeders, + "peers": peers, + "min_seeders": min_seeders, + "volume_factor": volume, + "site_name": site_name, + "resource_team": group_name, + } + + def _attach_cloud_scores( + self, + items: List[Dict[str, Any]], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> List[Dict[str, Any]]: + return [ + { + **dict(item or {}), + "score": self._score_cloud_resource( + dict(item or {}), + preferences=preferences, + source_type=source_type, + target_path=target_path, + ), + } + for item in items + if isinstance(item, dict) + ] + + def _rank_pansou_items( + self, + items: List[Dict[str, Any]], + *, + channel_order: Optional[List[str]] = None, + limit_per_channel: int = 20, + ) -> List[Dict[str, Any]]: + grouped: Dict[str, List[Dict[str, Any]]] = {} + for item in items: + if not isinstance(item, dict): + continue + channel = self._normalize_pansou_channel_name(item.get("channel")) + grouped.setdefault(channel, []).append(dict(item)) + + def sort_key(entry: Dict[str, Any]) -> Tuple[Any, ...]: + score = entry.get("score") if isinstance(entry.get("score"), dict) else {} + score_value = self._safe_int(score.get("score"), 0) + hard_risk_count = len(score.get("hard_risk_reasons") or []) + risk_count = len(score.get("risk_reasons") or []) + dt = self._clean_text(entry.get("datetime")) + note = self._clean_text(entry.get("note")) + return ( + score_value, + self._score_quality_rank(entry), + -hard_risk_count, + -risk_count, + dt, + len(note), + ) + + ordered_channels = channel_order or ["115", "quark"] + ranked: List[Dict[str, Any]] = [] + for channel in ordered_channels: + channel_items = sorted(grouped.get(channel) or [], key=sort_key, reverse=True) + ranked.extend(channel_items[:max(1, limit_per_channel)]) + for index, item in enumerate(ranked, start=1): + item["index"] = index + return ranked + + def _public_pansou_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_item = { + "index": self._safe_int(item.get("index"), 0), + "channel": self._normalize_pansou_channel_name(item.get("channel")), + "note": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + "datetime": self._clean_text(item.get("datetime")), + "display_datetime": self._format_pansou_display_datetime(item.get("datetime")), + "password": self._clean_text(item.get("password")), + "url": self._clean_text(item.get("url")), + "brief_summary": self._pansou_item_brief_summary(item), + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + public_item[extra_key] = value + public_items.append(public_item) + return public_items + + @staticmethod + def _public_hdhive_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_items.append({ + "index": AgentResourceOfficer._safe_int(item.get("index"), 0), + "cloud_index": AgentResourceOfficer._safe_int(item.get("cloud_index"), 0), + "title": AgentResourceOfficer._clean_text(item.get("title")), + "pan_type": AgentResourceOfficer._clean_text(item.get("pan_type")), + "share_size": AgentResourceOfficer._list_text(item.get("share_size") or item.get("size")), + "source": AgentResourceOfficer._list_text(item.get("source")), + "subtitle_text": AgentResourceOfficer._resource_subtitle_text(item), + "episode_text": AgentResourceOfficer._resource_episode_text(item), + "remark": AgentResourceOfficer._resource_remark_text(item), + "points_text": AgentResourceOfficer._resource_points_text(item), + "video_resolution": item.get("video_resolution") or [], + "slug": AgentResourceOfficer._clean_text(item.get("slug")), + }) + return public_items + + @staticmethod + def _prepend_search_note(message: str, note: str) -> str: + clean_message = str(message or "").strip() + clean_note = str(note or "").strip() + if not clean_note: + return clean_message + if not clean_message: + return clean_note + return f"{clean_note}\n\n{clean_message}" + + def _assistant_finalize_pansou_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + items: List[Dict[str, Any]], + total: int, + target_path: str, + action_name: str, + search_scope: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_pansou_text(keyword, items, total, page=1, page_size=page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + public_items = self._public_pansou_items(items) + result_data = { + "action": action_name, + "ok": True, + "items": public_items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + "total_pages": max(1, (len(items) + page_size - 1) // page_size) if items else 1, + "decision_summary": self._assistant_pansou_entry_summary(items), + "search_scope": search_scope, + } + if recommend_handoff: + saved_state = self._load_session(cache_key) or {} + result_data.update(self._assistant_recommend_handoff_short_metadata(saved_state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(saved_state) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data=result_data), + } + + @staticmethod + def _pick_cloud_hdhive_candidate( + keyword: str, + candidates: List[Dict[str, Any]], + year: str = "", + ) -> Dict[str, Any]: + clean_year = AgentResourceOfficer._clean_text(year)[:4] + if not candidates: + return {} + if len(candidates) == 1: + return dict(candidates[0] or {}) + if not clean_year: + return {} + matched = [ + dict(item or {}) + for item in candidates + if AgentResourceOfficer._clean_text((item or {}).get("year"))[:4] == clean_year + ] + if len(matched) == 1: + return matched[0] + return {} + + def _format_pansou_body_lines(self, items: List[Dict[str, Any]], total: int) -> List[str]: + count_115 = len([x for x in items if x.get("channel") == "115"]) + count_quark = len([x for x in items if x.get("channel") == "quark"]) + lines = [f"共找到 {total} 条结果,当前展示 115 {count_115} 条、夸克 {count_quark} 条:"] + seen_115 = False + seen_quark = False + for cached in items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + return lines + + @classmethod + def _format_pansou_list_title(cls, item: Dict[str, Any]) -> str: + channel = cls._normalize_pansou_channel_name(item.get("channel")) + emoji = "📺" if channel == "115" else "🗄" if channel == "quark" else "🔗" + title = cls._clean_text(item.get("note") or item.get("title") or "未命名资源") + title = re.sub(r"^\s*\[(?:115|quark|QUARK)\]\s*", "", title).strip() + title = re.sub(r"^\s*#(?:剧集|电影|动漫|资源)\s*", "", title).strip() + title = cls._truncate_text(title, 110) + if title.startswith(("📺", "🗄", "🗃", "📁", "🔗")): + return title + return f"{emoji} {title}".strip() + + def _format_pansou_text( + self, + keyword: str, + items: List[Dict[str, Any]], + total: int, + *, + page: int = 1, + page_size: int = 10, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(items), page=page, page_size=page_size) + page_items = items[start:end] + count_115 = len([x for x in page_items if x.get("channel") == "115"]) + count_quark = len([x for x in page_items if x.get("channel") == "quark"]) + lines = [ + f"盘搜搜索:{keyword}", + f"共找到 {total} 条结果,当前第 {safe_page}/{total_pages} 页(本次缓存 {len(items)} 条候选),本页 115 {count_115} 条、夸克 {count_quark} 条:", + ] + seen_115 = False + seen_quark = False + for cached in page_items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + first_visible_index = self._safe_int((page_items[0] or {}).get("index"), 1) if page_items else 1 + next_quark_hint = next((self._safe_int(item.get("index"), 0) for item in page_items if item.get("channel") == "quark"), 0) + suggestion_lines = self._format_pansou_selection_suggestion(page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + if next_quark_hint > 0 and next_quark_hint != first_visible_index: + lines.append(f"本页夸克结果从 {next_quark_hint} 开始编号;例如直接回复“{next_quark_hint}”即可处理本页第 1 条夸克结果。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _human_resource_signal_text(cls, title: str, reasons: List[str]) -> str: + blob = f"{title} {' '.join(reasons or [])}".lower() + signals: List[str] = [] + if any(token in blob for token in ["4k", "2160", "uhd", "臻彩"]): + signals.append("画质规格更高") + if any(token in blob for token in ["hdr", "dv", "杜比", "dolby", "高码率", "60fps", "60帧"]): + signals.append("视频规格更完整") + if any(token in blob for token in ["e01", "s01", "全集", "全季", "连续", "更新至", "更至", "覆盖到当前"]): + signals.append("集数覆盖更明确") + if any(token in blob for token in ["中字", "简中", "繁中", "字幕", "内封", "内嵌"]): + signals.append("字幕信息更清楚") + if any(token in blob for token in ["免费", "积分 0", "影巢免费"]): + signals.append("成本更低") + if not signals: + return "综合画质、完整度和可转存性更均衡" + return "、".join(signals[:3]) + + @classmethod + def _human_resource_provider_text(cls, provider: str) -> str: + normalized = cls._clean_text(provider).lower() + if normalized == "115" or "115" in normalized: + return "115 资源更适合做主收藏和后续入库" + if normalized == "quark" or "夸克" in normalized: + return "夸克资源更适合走夸克转存链路" + return "这个资源整体更适合优先处理" + + @classmethod + def _human_resource_suggestion_line(cls, best_index: int, best_title: str, provider: str, reasons: List[str]) -> str: + title_part = f":{best_title}" if best_title else "" + provider_text = cls._human_resource_provider_text(provider) + signal_text = cls._human_resource_signal_text(best_title, reasons) + return f"优先选 {best_index}{title_part}。{provider_text},主要优势是{signal_text}。" + + @classmethod + def _human_quark_fallback_line(cls, quark_index: int, quark_title: str, reasons: List[str]) -> str: + title_part = f":{quark_title}" if quark_title else "" + signal_text = cls._human_resource_signal_text(quark_title, reasons) + return f"如果这次明确只走夸克,备选 {quark_index}{title_part}。它在夸克结果里相对更稳,优势是{signal_text}。" + + def _format_pansou_selection_suggestion(self, items: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(items, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:4] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_items = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and self._normalize_pansou_channel_name(item.get("channel")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_hdhive_resource_body_lines( + self, + resources: List[Dict[str, Any]], + *, + candidate: Optional[Dict[str, Any]] = None, + start_index: int = 1, + ) -> List[str]: + lines: List[str] = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {len(resources)} 条") + current_provider = "" + for offset, item in enumerate(resources, start=start_index): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + lines.append(self._format_hdhive_resource_summary_line(item, offset)) + lines.append("") + return [line for line in lines if line is not None] + + @classmethod + def _format_hdhive_resource_summary_line(cls, item: Dict[str, Any], index: int) -> str: + provider = str(item.get("pan_type") or "?").lower() + emoji = "📺" if provider == "115" else "🗄" if provider == "quark" else "🔗" + title = cls._clean_text(item.get("title") or "未命名资源") + points_text = cls._resource_points_text(item) + share_size = cls._list_text(item.get("share_size") or item.get("size")) + episode = cls._resource_episode_text(item) + resolution = "/".join(item.get("video_resolution") or []) or "" + source = cls._list_text(item.get("source")) + subtitle = cls._resource_subtitle_text(item) + remark = cls._resource_remark_text(item) + detail_parts = [ + points_text, + share_size, + episode, + resolution, + source, + subtitle, + remark, + ] + details = " · ".join(part for part in detail_parts if part) + return f"{index}. {emoji} {title}" + (f" · {details}" if details else "") + + def _format_hdhive_selection_suggestion(self, resources: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(resources, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + quark_items = [ + dict(item or {}) + for item in (resources or []) + if isinstance(item, dict) and str(item.get("pan_type") or "").lower() == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("pick_index") or best_quark.get("index"), 0) + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_cloud_selection_suggestion( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> List[str]: + combined: List[Dict[str, Any]] = [] + for item in pansou_items or []: + if isinstance(item, dict): + combined.append(dict(item)) + for item in hdhive_resources or []: + if not isinstance(item, dict): + continue + current = dict(item) + cloud_index = self._safe_int( + current.get("cloud_index") or current.get("pick_index") or current.get("index"), + 0, + ) + current["index"] = cloud_index + current["pick_index"] = cloud_index + combined.append(current) + summary = self._score_summary(combined, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_candidates = [ + dict(item or {}) + for item in combined + if isinstance(item, dict) + and self._normalize_pansou_channel_name(item.get("channel") or item.get("pan_type")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_candidates) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index") or best_quark.get("cloud_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _assistant_cloud_entry_summary( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> Dict[str, Any]: + preferred_index = 0 + if pansou_items: + preferred_index = self._safe_int((pansou_items[0] or {}).get("index"), 0) + elif hdhive_resources: + preferred_index = self._safe_int((hdhive_resources[0] or {}).get("cloud_index") or (hdhive_resources[0] or {}).get("index"), 0) + if preferred_index <= 0: + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(preferred_index), + "fallback_command": f"选择 {preferred_index} 详情", + "compact_commands": [str(preferred_index), f"选择 {preferred_index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _assistant_finalize_cloud_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + target_path: str, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_cloud_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_cloud", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "pansou_items": pansou_items, + "pansou_total": pansou_total, + "hdhive_resources": hdhive_resources, + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidates": hdhive_candidates, + "page": 1, + "page_size": page_size, + }, + ) + text_message = self._format_cloud_text( + keyword=keyword, + pansou_items=pansou_items, + pansou_total=pansou_total, + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=1, + page_size=page_size, + ) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search", + "ok": True, + "items": self._public_pansou_items(pansou_items), + "hdhive_resources": self._public_hdhive_items(hdhive_resources), + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidate_count": len(hdhive_candidates or []), + "page": 1, + "page_size": page_size, + "total_pages": max(1, ((len(pansou_items) + len(hdhive_resources)) + page_size - 1) // page_size) if (pansou_items or hdhive_resources) else 1, + "decision_summary": self._assistant_cloud_entry_summary(pansou_items, hdhive_resources), + "search_scope": "pansou_and_hdhive", + }), + } + + def _format_cloud_text( + self, + *, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + page: int = 1, + page_size: int = 20, + ) -> str: + total_items = len(pansou_items) + len(hdhive_resources) + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(total_items, page=page, page_size=page_size) + start_index = start + 1 + end_index = min(total_items, end) + lines = [f"云盘搜索:{keyword}"] + if total_items > 0: + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {start_index}-{end_index} / 共 {total_items} 条已展开结果:") + pansou_page_items = [item for item in pansou_items if start < self._safe_int(item.get('index'), 0) <= end] + hdhive_page_items = [item for item in hdhive_resources if start < self._safe_int(item.get('cloud_index') or item.get('index'), 0) <= end] + lines.extend(["", "盘搜结果"]) + if pansou_page_items: + lines.extend(self._format_pansou_body_lines(pansou_page_items, pansou_total)) + elif pansou_items: + lines.append("本页无盘搜结果") + else: + lines.append("暂无结果") + lines.extend(["", "影巢结果"]) + if hdhive_page_items: + lines.extend(self._format_hdhive_resource_body_lines( + hdhive_page_items, + candidate=hdhive_candidate, + start_index=self._safe_int((hdhive_page_items[0] or {}).get("cloud_index") or (hdhive_page_items[0] or {}).get("index"), 1), + )) + elif hdhive_resources: + lines.append("本页无影巢结果") + elif hdhive_candidates: + lines.append(f"候选影片 {len(hdhive_candidates)} 个,当前未自动展开。") + lines.append(f"如需细看影巢,请发送:影巢搜索 {keyword}") + else: + lines.append("暂无结果") + suggestion_lines = self._format_cloud_selection_suggestion(pansou_page_items, hdhive_page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + lines.append("如需只看单一来源,可继续发:盘搜搜索 片名 / 影巢搜索 片名。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + def _assistant_finalize_hdhive_candidates( + self, + *, + session: str, + cache_key: str, + keyword: str, + candidates: List[Dict[str, Any]], + media_type: str, + year: str, + target_path: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + self._save_session( + cache_key, + { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or self._hdhive_default_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": True, + "candidates": candidates, + "search_scope": "hdhive", + }), + } + + @staticmethod + def _format_score_label(item: Dict[str, Any]) -> str: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + if not score: + return "" + try: + value = int(float(score.get("score") or 0)) + except Exception: + value = 0 + action = AgentResourceOfficer._clean_text(score.get("recommended_action")) + if score.get("can_auto_execute"): + suffix = "可自动入库" + elif action == "ask_confirm": + suffix = "建议确认" + else: + suffix = "不建议自动" + return f"{value}分 {suffix}" + + @staticmethod + def _score_decision_label(value: Any) -> str: + action = AgentResourceOfficer._clean_text(value) + if action == "ask_confirm": + return "建议确认" + if action == "do_not_auto": + return "不建议自动" + if action == "auto_ingest_cloud": + return "可自动入库" + if action == "auto_download_pt": + return "可自动下载" + return action or "待判断" + + def _score_brief_item(self, item: Dict[str, Any], fallback_index: int = 0) -> Dict[str, Any]: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + if not score: + return {} + torrent = current.get("torrent_info") if isinstance(current.get("torrent_info"), dict) else {} + index = self._safe_int( + current.get("pick_index") or current.get("index") or fallback_index, + fallback_index, + ) + title = ( + self._clean_text(torrent.get("title")) + or self._clean_text(current.get("note")) + or self._clean_text(current.get("title") or current.get("matched_title")) + or "未命名资源" + ) + provider = ( + self._clean_text(current.get("pan_type") or current.get("channel")) + or self._clean_text(torrent.get("site_name")) + or self._clean_text(current.get("site")) + ) + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + brief = { + "index": index, + "title": title[:160], + "provider": provider, + "source_type": self._clean_text(score.get("source_type")), + "score": self._safe_int(score.get("score"), 0), + "quality_rank": self._score_quality_rank(current), + "score_level": self._clean_text(score.get("score_level")), + "recommended_action": self._clean_text(score.get("recommended_action")), + "can_auto_execute": bool(score.get("can_auto_execute")), + "score_reasons": reasons[:3], + "risk_reasons": risks[:2], + "hard_risk_reasons": hard_risks[:2], + } + points_text = self._resource_points_text(current) if current and brief.get("source_type") != "pt" else "" + if points_text and points_text != "积分未知": + brief["points_text"] = points_text + seeders = torrent.get("seeders") if torrent else score.get("seeders") + if seeders is not None: + brief["seeders"] = seeders + volume = self._clean_text(torrent.get("volume_factor") if torrent else score.get("volume_factor")) + if volume: + brief["volume_factor"] = volume + size = self._clean_text(current.get("share_size") or current.get("size") or torrent.get("size")) + if size: + brief["size"] = size + return brief + + def _score_summary(self, items: List[Dict[str, Any]], *, limit: int = 5) -> Dict[str, Any]: + scored: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + brief = self._score_brief_item(item, fallback_index=index) + if brief: + scored.append(brief) + scored.sort(key=lambda value: ( + 1 if value.get("can_auto_execute") else 0, + self._safe_int(value.get("score"), 0), + self._safe_int(value.get("quality_rank"), 0), + ), reverse=True) + auto_count = len([item for item in scored if item.get("can_auto_execute")]) + confirm_count = len([item for item in scored if item.get("recommended_action") == "ask_confirm"]) + blocked_count = len([item for item in scored if item.get("hard_risk_reasons")]) + warning_count = len([item for item in scored if item.get("risk_reasons")]) + summary = { + "total_scored": len(scored), + "auto_count": auto_count, + "confirm_count": confirm_count, + "blocked_count": blocked_count, + "warning_count": warning_count, + "best": scored[0] if scored else None, + "top_recommendations": scored[:max(1, min(10, self._safe_int(limit, 5)))], + } + summary["decision"] = self._score_summary_decision(summary) + return summary + + def _score_summary_decision(self, summary: Optional[Dict[str, Any]]) -> Dict[str, Any]: + data = dict(summary or {}) + best = data.get("best") if isinstance(data.get("best"), dict) else {} + if not best: + return { + "stage": "no_scored_items", + "label": "暂无评分结果", + "requires_confirmation": False, + "prefer_plan_first": True, + "decision_hint": "当前没有可评分条目,先完成搜索或选择后再判断。", + "command_policy": "none", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "", + "fallback_command": "", + "compact_commands": [], + "recommended_commands": [], + } + choice = self._safe_int(best.get("index"), 0) + source_type = self._clean_text(best.get("source_type")).lower() + recommended_action = self._clean_text(best.get("recommended_action")) + title = self._clean_text(best.get("title")) + hard_risks = [self._clean_text(value) for value in (best.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + is_pt = source_type == "pt" + detail_command = f"选择 {choice}" if is_pt else f"选择 {choice} 详情" + plan_command = f"下载{choice}" if is_pt else f"计划选择 {choice}" + commands = [command for command in [detail_command if choice else "", plan_command if choice else "", "执行计划"] if command] + if best.get("can_auto_execute"): + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},已达到自动化阈值,但默认仍建议先生成计划再执行。" + stage = "auto_candidate" + elif hard_risks: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但存在硬风险,不能自动执行。" + stage = "blocked" + elif recommended_action == "ask_confirm": + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},风险可控,建议先看详情再决定。" + stage = "confirm" + else: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但综合评分偏低,不建议直接处理。" + stage = "low_score" + return { + "stage": stage, + "label": self._score_decision_label("auto_download_pt" if best.get("can_auto_execute") and is_pt else "auto_ingest_cloud" if best.get("can_auto_execute") else recommended_action), + "source_type": source_type, + "choice": choice, + "title": title[:160], + "score": self._safe_int(best.get("score"), 0), + "requires_confirmation": not bool(best.get("can_auto_execute")), + "prefer_plan_first": True, + "command_policy": "read_then_confirm_write" if len(commands) > 1 else "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": bool(len(commands) > 1), + "can_auto_run_preferred": bool(commands), + "preferred_command": commands[0] if commands else "", + "fallback_command": commands[1] if len(commands) > 1 else "", + "compact_commands": commands[:2], + "recommended_commands": commands, + "decision_hint": hint, + "top_hard_risk": hard_risks[0] if hard_risks else "", + "top_warning": risks[0] if risks else "", + } + + @staticmethod + def _format_score_summary_decision_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + decision = data.get("decision") if isinstance(data.get("decision"), dict) else {} + if not decision: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(decision.get("label")) + hint = AgentResourceOfficer._clean_text(decision.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (decision.get("compact_commands") or decision.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"决策建议:{label}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("下一步:" + " / ".join(commands)) + return lines + + def _best_scored_source_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not candidates: + return {} + return max( + candidates, + key=lambda item: ( + 1 if (item.get("score") or {}).get("can_auto_execute") else 0, + self._safe_int((item.get("score") or {}).get("score"), 0), + self._score_quality_rank(item), + -self._safe_int(item.get("index") or item.get("pick_index"), 0), + ), + ) + + def _format_cloud_item_detail_text(self, item: Dict[str, Any], *, title: str = "云盘资源详情") -> str: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + score_summary = self._score_summary([current], limit=1) if score else {} + index = self._safe_int(current.get("index") or current.get("pick_index"), 0) + name = ( + self._clean_text(current.get("note")) + or self._clean_text(current.get("title")) + or self._clean_text(current.get("matched_title")) + or "未命名资源" + ) + provider = self._clean_text(current.get("channel") or current.get("pan_type") or current.get("provider")) + size = self._clean_text(current.get("share_size") or current.get("size")) + resolution = self._clean_text(current.get("resolution")) + source = self._clean_text(current.get("source") or current.get("source_name")) + datetime_text = self._format_pansou_display_datetime(current.get("datetime")) + password = self._clean_text(current.get("password")) + url = self._clean_text(current.get("url") or current.get("share_url") or current.get("link")) + brief = self._clean_text(current.get("brief_summary") or self._pansou_item_brief_summary(current)) + points = self._resource_points_text(current) + lines = [title] + if index: + lines.append(f"编号:{index}") + lines.append(f"资源:{name}") + if provider: + lines.append(f"网盘:{provider}") + if points: + lines.append(f"积分:{points}") + if resolution: + lines.append(f"分辨率:{resolution}") + if size: + lines.append(f"大小:{size}") + if source: + lines.append(f"来源:{source}") + if datetime_text: + lines.append(f"日期:{datetime_text}") + if brief: + lines.append(f"摘要:{brief}") + if password: + lines.append(f"提取码:{password}") + if url: + lines.append(f"链接:{url}") + if score: + lines.append("") + lines.append("智能建议") + lines.append(f"评分:{self._safe_int(score.get('score'), 0)} / {self._clean_text(score.get('score_level')) or '-'}") + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + lines.extend(self._format_score_summary_decision_lines(score_summary)) + return "\n".join(lines) + + def _assistant_scoring_policy_public_data(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "schema_version": "scoring_policy.v1", + "owner": "plugin_rules", + "agent_role": "explain_and_confirm_only", + "summary": "评分由插件内置规则执行;外部智能体只读取 score_summary、解释原因、请求确认,不能绕过硬风险。", + "global_decision": { + "auto_execute_requires": [ + "auto_ingest_enabled=true", + "score >= auto_ingest_score_threshold", + "hard_risk_reasons 为空", + ], + "confirm_range": "confirm_score_threshold <= score < auto_ingest_score_threshold 且无硬风险", + "default_source": "plugin_config_then_session_preferences", + "default_confirm_score_threshold": prefs.get("confirm_score_threshold"), + "default_auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + "auto_ingest_default": prefs.get("auto_ingest_enabled"), + }, + "cloud": { + "source_types": ["hdhive", "pansou", "115", "quark"], + "positive_signals": [ + "4K/UHD", + "杜比视界/HDR", + "中文字幕", + "全集/完整季/更新完整度", + "REMUX/原盘/Web-DL/高码率", + "匹配网盘偏好", + "匹配默认目录", + ], + "hard_gates": [ + "影巢积分超过 hdhive_max_unlock_points 时禁止自动解锁", + "影巢积分未知时禁止自动解锁", + ], + "default_hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "pansou_cost": "无积分成本,主要按质量、完整度、字幕和网盘类型评分", + }, + "pt": { + "source_types": ["moviepilot_native_search", "torrent_search", "subscribe_search"], + "positive_signals": [ + "做种数高", + "免费/促销/FreeLeech", + "4K/UHD", + "杜比视界/HDR", + "字幕信息", + "REMUX/原盘/Web-DL/高码率", + "季/全集标识", + ], + "hard_gates": [ + "做种数 0 禁止自动下载", + "做种数低于 pt_min_seeders 禁止自动下载", + "用户要求免费时,非免费资源禁止自动下载", + ], + "default_pt_min_seeders": prefs.get("pt_min_seeders"), + "volume_factor_note": "免费/促销明显加分;普通资源默认中性,不强行判废", + }, + "score_summary_contract": { + "read_fields": [ + "best", + "top_recommendations", + "decision", + "score", + "score_level", + "recommended_action", + "can_auto_execute", + "score_reasons", + "risk_reasons", + "hard_risk_reasons", + ], + "decision_fields": [ + "stage", + "label", + "choice", + "decision_hint", + "preferred_command", + "fallback_command", + "compact_commands", + "recommended_commands", + ], + "blocked_count": "只统计 hard_risk_reasons,不统计缺字幕等软提醒", + "warning_count": "统计 risk_reasons,用于解释需要用户确认的原因", + "do_not_parse": "不要解析自然语言 message 来判断自动化,优先读取 score_summary", + }, + } + + @staticmethod + def _format_bytes_size(value: Any) -> str: + try: + size = float(value or 0) + except Exception: + return "" + if size <= 0: + return "" + units = ["B", "KB", "MB", "GB", "TB"] + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f}{units[index]}" if index else f"{int(size)}B" + + def _mp_context_preview_item(self, context: Any, index: int, preferences: Dict[str, Any]) -> Dict[str, Any]: + torrent = getattr(context, "torrent_info", None) + meta = getattr(context, "meta_info", None) + media = getattr(context, "media_info", None) + item = { + "index": index, + "cache_index": index, + "torrent_info": { + "title": self._clean_text(getattr(torrent, "title", "")), + "size": self._format_bytes_size(getattr(torrent, "size", None)), + "seeders": getattr(torrent, "seeders", None), + "peers": getattr(torrent, "peers", None), + "site_name": self._clean_text(getattr(torrent, "site_name", "")), + "volume_factor": self._clean_text(getattr(torrent, "volume_factor", "")), + "page_url": self._clean_text(getattr(torrent, "page_url", "")), + }, + "meta_info": { + "season_episode": self._clean_text(getattr(meta, "season_episode", "")), + "resource_team": self._clean_text(getattr(meta, "resource_team", "")), + "video_encode": self._clean_text(getattr(meta, "video_encode", "")), + "edition": self._clean_text(getattr(meta, "edition", "")), + "resource_pix": self._clean_text(getattr(meta, "resource_pix", "")), + }, + "media_info": { + "title": self._clean_text(getattr(media, "title", "")), + "year": self._clean_text(getattr(media, "year", "")), + "tmdb_id": getattr(media, "tmdb_id", None), + "douban_id": getattr(media, "douban_id", None), + }, + } + item["score"] = self._score_pt_resource(item, preferences=preferences) + return item + + @classmethod + def _display_pt_title(cls, title: Any) -> str: + text = cls._clean_text(title or "未命名资源") + # Some chat frontends auto-link dotted release names such as + # Spider-Man.2002.1080p. Insert zero-width breaks for display only. + return re.sub(r"(?<=[A-Za-z0-9])\.(?=[A-Za-z0-9])", ".\u200b", text) + + @classmethod + def _pt_title_badges(cls, title: Any) -> str: + text = cls._clean_text(title).lower() + badges: List[str] = [] + if cls._score_has_any(text, ["2160", "4k", "uhd"]): + badges.append("✨4K") + elif cls._score_has_any(text, ["1080"]): + badges.append("🎞️1080p") + if cls._score_has_any(text, ["dolby vision", "dovi", " dv ", ".dv.", "杜比视界"]): + badges.append("⚡DV") + if cls._score_has_any(text, ["hdr10", "hdr", "hlg"]): + badges.append("🔶HDR") + if cls._score_has_any(text, ["remux", "原盘"]): + badges.append("💿REMUX") + return " ".join(badges) + + @staticmethod + def _pt_promotion_text(value: Any) -> str: + text = str(value or "").strip() or "普通" + lowered = text.lower() + if "免费" in text or "free" in lowered: + return f"🆓 {text}" + if "%" in text or "x" in lowered: + return f"🎁 {text}" + return f"🎫 {text}" + + def _sort_mp_preview_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + ranked = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) + ] + ranked.sort( + key=lambda item: ( + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + self._score_quality_rank(item), + self._safe_int((item.get("score") or {}).get("score"), 0), + -self._safe_int(item.get("index"), 0), + ), + reverse=True, + ) + for index, item in enumerate(ranked, start=1): + item["index"] = index + item["display_index"] = index + return ranked + + def _renumber_mp_display_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + renumbered: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], start=1): + if not isinstance(item, dict): + continue + current = dict(item) + current["source_index"] = self._safe_int( + current.get("source_index") or current.get("index") or current.get("display_index"), + index, + ) + current["index"] = index + current["display_index"] = index + renumbered.append(current) + return renumbered + + def _assistant_mp_selection_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + state = self._load_session(cache_key) or {} + state_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if state_items: + return [ + dict(item or {}) + for item in state_items + if isinstance(item, dict) + ] + return self._mp_search_all_preview_items(cache_key, preferences=preferences) + + def _assistant_mp_item_by_display_index( + self, + *, + choice: int, + cache_key: str, + preferences: Dict[str, Any], + ) -> Tuple[Dict[str, Any], List[int]]: + items = self._assistant_mp_selection_items(cache_key, preferences) + selected = next( + ( + dict(item or {}) + for item in items + if self._safe_int((item or {}).get("index"), 0) == choice + ), + {}, + ) + available = [ + self._safe_int((item or {}).get("index"), 0) + for item in items + if isinstance(item, dict) and self._safe_int(item.get("index"), 0) > 0 + ] + return selected, available + + @staticmethod + def _page_bounds(total_items: int, page: int = 1, page_size: int = 20) -> Tuple[int, int, int, int]: + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (max(0, total_items) + safe_page_size - 1) // safe_page_size) if total_items else 1 + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + end = start + safe_page_size + return safe_page, safe_page_size, total_pages, start, end + + def _mp_search_cache_preview( + self, + cache_key: str, + preferences: Dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + all_items: List[Dict[str, Any]] = [] + for index, context in enumerate(results, 1): + all_items.append(self._mp_context_preview_item(context, index, preferences)) + all_items = self._sort_mp_preview_items(all_items) + effective_page_size = max(1, self._safe_int(limit, page_size)) + return self._slice_mp_preview_items(all_items, page=page, page_size=effective_page_size) + + def _mp_search_all_preview_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + return self._sort_mp_preview_items([ + self._mp_context_preview_item(context, index, preferences) + for index, context in enumerate(results, 1) + ]) + + def _slice_mp_preview_items( + self, + items: List[Dict[str, Any]], + *, + page: int = 1, + page_size: int = 20, + ) -> List[Dict[str, Any]]: + _safe_page, _safe_page_size, _total_pages, start, end = self._page_bounds( + len(items), + page=page, + page_size=page_size, + ) + return [ + dict(item or {}) + for item in items[start:end] + if isinstance(item, dict) + ] + + def _format_mp_search_text( + self, + keyword: str, + message_text: str, + preview: List[Dict[str, Any]], + *, + total: int = 0, + page: int = 1, + page_size: int = 20, + result_filter: str = "", + latest_episode: int = 0, + episode_filter: int = 0, + ) -> str: + header = message_text.strip().splitlines()[0] if message_text else f"MP 原生搜索:{keyword}" + lines = [header] + if preview: + total_results = max(self._safe_int(total, 0), len(preview)) + safe_page_size = max(1, self._safe_int(page_size, self._assistant_result_page_size)) + total_pages = max(1, (total_results + safe_page_size - 1) // safe_page_size) + score_summary = self._score_summary(preview, limit=5) + lines.append("") + if result_filter == "latest_episode" and latest_episode > 0: + lines.append(f"最新集筛选:当前最高 E{latest_episode:02d},仅展示包含该集数的候选。") + elif result_filter.startswith("episode:") and episode_filter > 0: + lines.append(f"集数筛选:仅展示包含 E{episode_filter:02d} 的候选。") + lines.append(f"当前第 {max(1, page)}/{total_pages} 页,共 {total_results} 条结果(按做种数优先排序):") + lines.append("PT 资源:") + for item in preview: + torrent = item.get("torrent_info") or {} + score = item.get("score") or {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + badge_suffix = f" {badges}" if badges else "" + lines.append(f"{item.get('index')}. 🧲【{torrent.get('site_name') or '未知站点'}】 {title}{badge_suffix}") + details = [ + f"🌱 做种:{torrent.get('seeders') if torrent.get('seeders') is not None else '?'}", + self._pt_promotion_text(torrent.get("volume_factor")), + f"💾 {torrent.get('size') or '未知'}", + f"⭐ {self._format_score_label(item)}", + ] + hard_risks = score.get("hard_risk_reasons") or [] + risks = score.get("risk_reasons") or [] + risks = [risk for risk in risks if risk not in hard_risks] + if hard_risks: + details.append("硬风险:" + ";".join(str(item) for item in hard_risks[:2])) + elif risks: + details.append("提醒:" + ";".join(str(item) for item in risks[:2])) + lines.append(" " + " | ".join(details)) + decision_lines = self._format_score_summary_decision_lines(score_summary) + if result_filter == "latest_episode" or result_filter.startswith("episode:"): + normalized_decision_lines: List[str] = [] + for line in decision_lines: + if line.startswith("下一步:"): + continue + if line.startswith("建议:"): + line = line.replace( + "建议先看详情再决定", + "可直接生成下载计划,计划不会立即执行", + ) + normalized_decision_lines.append(line) + decision_lines = normalized_decision_lines + lines.extend(decision_lines) + if page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + best_index = self._safe_int(((score_summary.get("best") or {}) if isinstance(score_summary, dict) else {}).get("index"), 0) + if (result_filter == "latest_episode" or result_filter.startswith("episode:")) and best_index > 0: + lines.append(f"操作提示:建议回复“{best_index}”或“下载{best_index}”生成下载计划,不会立即下载。") + lines.append(f"如需先核对站点详情,可回复“{best_index}详情”。") + else: + lines.append("操作提示:回复编号或“下载N”生成下载计划;回复“N详情”看详情。") + lines.append("计划生成后,再回复“执行计划”或同一个编号确认执行。") + return "\n".join(line for line in lines if line) + + async def _assistant_mp_media_detail( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "", + year: str = "", + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_media_detail( + keyword=keyword, + media_type=media_type, + year=year, + ) + item = result.get("item") if isinstance(result.get("item"), dict) else {} + self._save_session(cache_key, { + "kind": "assistant_mp_media_detail", + "stage": "media_detail", + "keyword": keyword, + "items": [item] if item else [], + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "媒体识别完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": bool(result.get("success")), + "keyword": keyword, + "media_type": media_type, + "year": year, + "item": item, + }), + } + + async def _assistant_mp_media_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + preferences: Dict[str, Any], + page: int = 1, + page_size: int = 10, + result_filter: str = "", + ) -> Dict[str, Any]: + message_text = self._ensure_feishu_channel()._execute_media_search(keyword, cache_key) + failed_prefixes = ("MP 原生搜索失败", "未识别到媒体信息", "搜索资源失败") + route_ok = not any(message_text.startswith(prefix) for prefix in failed_prefixes) + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + total = len((cache or {}).get("results") or []) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + filtered_items = all_items + latest_episode = 0 + episode_filter = 0 + effective_filter = self._clean_text(result_filter) + if effective_filter == "latest_episode": + latest_items, latest_episode = self._latest_episode_mp_items(all_items) + if latest_items: + filtered_items = self._renumber_mp_display_items(latest_items) + total = len(filtered_items) + elif effective_filter.startswith("episode:"): + episode_filter = self._safe_int(effective_filter.split(":", 1)[1], 0) + episode_items = self._episode_filter_mp_items(all_items, episode_filter) + if episode_items: + filtered_items = self._renumber_mp_display_items(episode_items) + total = len(filtered_items) + preview = self._slice_mp_preview_items(filtered_items, page=page, page_size=page_size) if filtered_items else self._mp_search_cache_preview(cache_key, preferences=preferences, page=page, page_size=page_size) + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": keyword, + "items": preview, + "all_items": filtered_items, + "raw_all_items": all_items, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "target_path": "", + }) + return { + "success": route_ok, + "message": self._format_mp_search_text( + keyword, + message_text, + preview, + total=total, + page=page, + page_size=page_size, + result_filter=effective_filter, + latest_episode=latest_episode, + episode_filter=episode_filter, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search", + "ok": route_ok, + "keyword": keyword, + "source_type": "pt", + "items": preview, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "total_pages": max(1, (max(0, total) + max(1, self._safe_int(page_size, self._assistant_result_page_size)) - 1) // max(1, self._safe_int(page_size, self._assistant_result_page_size))) if total else 1, + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "score_summary": self._score_summary(preview, limit=5), + "preferences": preferences, + }), + } + + async def _assistant_mp_candidate_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "auto", + year: str = "", + page: int = 1, + pending_action: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type=self._clean_text(media_type or "auto").lower() or "auto", + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "success": False, + "message": f"MP 候选解析失败:{search_message}", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": False, + "keyword": clean_keyword, + "error_code": "candidate_search_failed", + "result": result, + }), + } + candidates = result.get("candidates") or [] + page_size = self._hdhive_candidate_page_size + self._save_session(cache_key, { + "kind": "assistant_mp_candidate", + "stage": "candidate", + "keyword": clean_keyword, + "media_type": self._clean_text(media_type or "auto").lower() or "auto", + "year": self._clean_text(year), + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + "target_path": target_path or "", + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }) + message = self._format_mp_candidate_lines(candidates, page=page, page_size=page_size) + if isinstance(pending_action, dict) and pending_action: + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名生成待确认下载计划,不会直接下载。", + ) + elif pending_mode == "cloud_transfer_execute": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名继续搜索云盘资源并转存。", + ) + pending_label = self._clean_text(pending_action.get("label")) + if pending_label: + message = f"{message}\n选定影片后将继续:{pending_label}" + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": True, + "keyword": clean_keyword, + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }), + } + + async def _assistant_mp_result_detail( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + item, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not item: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice}。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + torrent = item.get("torrent_info") or {} + meta = item.get("meta_info") or {} + media = item.get("media_info") or {} + score = item.get("score") or {} + lines = [ + f"MP 搜索结果详情 #{choice}", + f"标题:🧲 {self._display_pt_title(torrent.get('title'))} {self._pt_title_badges(torrent.get('title'))}".strip(), + f"站点:【{torrent.get('site_name') or '未知站点'}】", + f"大小:💾 {torrent.get('size') or '未知'}", + f"热度:🌱 做种 {torrent.get('seeders') if torrent.get('seeders') is not None else '?'} | 下载 {torrent.get('peers') if torrent.get('peers') is not None else '?'} | {self._pt_promotion_text(torrent.get('volume_factor'))}", + ] + media_text = " / ".join(str(part) for part in [ + media.get("title"), + media.get("year"), + f"TMDB:{media.get('tmdb_id')}" if media.get("tmdb_id") else "", + f"豆瓣:{media.get('douban_id')}" if media.get("douban_id") else "", + ] if part) + if media_text: + lines.append(f"媒体:{media_text}") + meta_text = " / ".join(str(part) for part in [ + meta.get("season_episode"), + meta.get("resource_pix"), + meta.get("video_encode"), + meta.get("edition"), + meta.get("resource_team"), + ] if part) + if meta_text: + lines.append(f"识别标签:{meta_text}") + score_label = self._format_score_label(item) + if score_label: + lines.append(f"评分:{score_label}") + reasons = [str(value) for value in (score.get("score_reasons") or []) if value] + hard_risks = [str(value) for value in (score.get("hard_risk_reasons") or []) if value] + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + risks = [risk for risk in risks if risk not in hard_risks] + score_summary = self._score_summary([item], limit=1) + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + if torrent.get("page_url"): + lines.append(f"详情页:{torrent.get('page_url')}") + lines.extend(self._format_score_summary_decision_lines(score_summary)) + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": (cache or {}).get("keyword") or current_state.get("keyword") or "", + "items": current_state.get("items") or self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=max(1, self._safe_int(current_state.get("page"), 1)), + page_size=max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + ), + "all_items": current_state.get("all_items") or [], + "raw_all_items": current_state.get("raw_all_items") or [], + "selected_index": choice, + "total": len(results), + "page": max(1, self._safe_int(current_state.get("page"), 1)), + "page_size": max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + "result_filter": current_state.get("result_filter") or "", + "latest_episode": self._safe_int(current_state.get("latest_episode"), 0), + "episode_filter": self._safe_int(current_state.get("episode_filter"), 0), + "target_path": current_state.get("target_path") or "", + **({"recommend_handoff": dict(current_state.get("recommend_handoff") or {})} if current_state.get("recommend_handoff") else {}), + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": True, + "choice": choice, + "item": item, + "score_summary": score_summary, + }), + } + + async def _assistant_mp_best_result_detail( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_best_detail", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = await self._assistant_mp_result_detail( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if result.get("success"): + result["message"] = f"当前评分最高的 PT 候选是 #{choice}\n{result.get('message') or ''}".strip() + data = dict(result.get("data") or {}) + data["action"] = "mp_search_best_detail" + data["best_choice"] = choice + result["data"] = data + return result + + async def _assistant_mp_best_download_plan( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_best_download_plan", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_best_download", + message="最佳片源下载计划已生成", + ) + if result.get("success"): + result["message"] = "\n".join(line for line in [ + f"已选择当前评分最高的 PT 候选:#{choice}", + result.get("message") or "", + ] if line) + data = dict(result.get("data") or {}) + data["choice"] = choice + data["item"] = best + result["data"] = data + return result + + def _assistant_format_download_plan_choice(self, *, rank: int, plan_id: str, item: Dict[str, Any]) -> str: + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + score = item.get("score") if isinstance(item.get("score"), dict) else {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + site = self._clean_text(torrent.get("site_name")) or "未知站点" + seeders = torrent.get("seeders") if torrent.get("seeders") is not None else "?" + size = self._clean_text(torrent.get("size")) or "未知" + score_label = self._format_score_label(item) + risk_text = self._assistant_score_warning_text(score, limit=1).replace("风险提示:", "") + suffix = f" | 提醒:{risk_text}" if risk_text else "" + badge_text = f" {badges}" if badges else "" + return ( + f"{rank}. {plan_id} | 资源 #{self._safe_int(item.get('index'), 0)} | " + f"🧲【{site}】 {title}{badge_text}\n" + f" 🌱 做种:{seeders} | {self._pt_promotion_text(torrent.get('volume_factor'))} | 💾 {size} | ⭐ {score_label}{suffix}" + ) + + async def _assistant_attach_download_plan_choices( + self, + result: Dict[str, Any], + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + limit: int = 3, + ) -> Dict[str, Any]: + if not isinstance(result, dict) or not result.get("success"): + return result + result_data = result.get("data") if isinstance(result.get("data"), dict) else {} + preview = [ + dict(item or {}) + for item in (result_data.get("items") or []) + if isinstance(item, dict) + ] + if not preview: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + scored.sort( + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + reverse=True, + ) + choices = scored[:max(1, min(3, self._safe_int(limit, 3)))] + if not choices: + return result + group_id = self._new_session_id("mpdl") + plan_rows: List[str] = [] + plan_items: List[Dict[str, Any]] = [] + for rank, item in enumerate(choices, start=1): + plan_result = self._assistant_mp_download_plan_response( + choice=self._safe_int(item.get("index"), 0), + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download_choice", + message=f"下载方案 {rank} 已生成", + ) + if not plan_result.get("success"): + continue + plan_data = plan_result.get("data") or {} + plan_id = self._clean_text(plan_data.get("plan_id")) + if plan_id and isinstance(self._workflow_plans.get(plan_id), dict): + execute_body = self._workflow_plans[plan_id].get("execute_body") + if not isinstance(execute_body, dict): + execute_body = {} + execute_body.update({ + "plan_rank": rank, + "multi_plan_group": group_id, + }) + self._workflow_plans[plan_id]["execute_body"] = execute_body + self._workflow_plans[plan_id]["plan_rank"] = rank + self._workflow_plans[plan_id]["plan_choice"] = self._safe_int(item.get("index"), 0) + self._workflow_plans[plan_id]["multi_plan_group"] = group_id + self._persist_workflow_plans() + plan_rows.append(self._assistant_format_download_plan_choice(rank=rank, plan_id=plan_id, item=item)) + plan_items.append({ + "rank": rank, + "plan_id": plan_id, + "choice": self._safe_int(item.get("index"), 0), + "item": item, + }) + if not plan_items: + return result + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + **current_state, + "kind": "assistant_mp_download_plans", + "stage": "download_plan_choices", + "pending_plan_group": group_id, + "plan_choices": plan_items, + "source_search": result.get("data") or {}, + }) + merged = { + "success": True, + "message": "\n".join([ + f"已按当前偏好生成 {len(plan_items)} 个待确认下载方案,均未实际下载:", + *plan_rows, + "回复方案编号 1/2/3 执行对应方案;回复“选择 资源编号”可先看详情。", + ]), + } + data = { + "action": "mp_download_plan_choices", + "ok": True, + "pending_plan_group": group_id, + "plan_choices": plan_items, + "ready_to_execute": True, + "write_effect": "state", + } + data["source_search"] = result.get("data") or {} + data["source_search_message"] = self._clean_text(result.get("message")) + merged["data"] = self._assistant_response_data(session=session, data=data) + return merged + + def _assistant_smart_search_stop_after_source( + self, + candidate: Optional[Dict[str, Any]], + preferences: Optional[Dict[str, Any]], + ) -> bool: + current = dict(candidate or {}) + if not current: + return False + score_value = self._safe_int(current.get("score"), 0) + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + hard_risks = current.get("hard_risk_reasons") or [] + recommended_action = self._clean_text(current.get("recommended_action")) + return score_value >= threshold and not hard_risks and recommended_action != "not_recommended" + + def _assistant_smart_search_candidate_from_cloud_item( + self, + item: Dict[str, Any], + *, + source_type: str, + ) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + provider = self._clean_text(item.get("pan_type") or item.get("channel") or item.get("provider")).lower() + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action == "ask_confirm": + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"计划选择 {index}" if index > 0 else "" + detail_command = f"选择 {index} 详情" if index > 0 else "" + return { + "source_type": source_type, + "title": self._clean_text(item.get("note") or item.get("title") or item.get("matched_title") or "未命名资源"), + "year": self._clean_text(item.get("year") or item.get("matched_year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "provider": provider or self._clean_text(item.get("source") or source_type), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "points_text": self._resource_points_text(item), + "provider": provider, + "size": self._clean_text(item.get("share_size") or item.get("size")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + def _assistant_smart_search_candidate_from_pt_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action in {"ask_confirm", "auto_download_pt"}: + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"下载{index}" if index > 0 else "" + detail_command = f"选择 {index}" if index > 0 else "" + return { + "source_type": "mp_pt", + "title": self._clean_text(torrent.get("title") or media.get("title") or "未命名资源"), + "year": self._clean_text(media.get("year")), + "media_type": self._clean_text(item.get("media_type") or media.get("type") or "tv"), + "provider": self._clean_text(torrent.get("site_name") or "pt"), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "seeders": self._safe_int(score.get("seeders"), self._safe_int(torrent.get("seeders"), 0)), + "volume_factor": self._clean_text(score.get("volume_factor") or torrent.get("volume_factor")), + "site_name": self._clean_text(score.get("site_name") or torrent.get("site_name")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + async def _assistant_smart_search_hdhive_resources( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + target_path: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return { + "source_type": "hdhive", + "ok": False, + "skipped": True, + "reason": disabled.get("message") or "影巢资源入口已关闭", + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "source_type": "hdhive", + "ok": False, + "reason": search_message, + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + candidates = result.get("candidates") or [] + if not candidates: + return { + "source_type": "hdhive", + "ok": True, + "reason": "未找到影巢候选", + "state": { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + "candidates": [], + "items": [], + "score_summary": {}, + } + + selected_candidate: Dict[str, Any] = {} + selected_preview: List[Dict[str, Any]] = [] + selected_summary: Dict[str, Any] = {} + for candidate in candidates[:3]: + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or media_type or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + continue + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path, + ) + if not preview: + continue + summary = self._score_summary(preview, limit=5) + best = summary.get("best") or {} + current_best = selected_summary.get("best") if isinstance(selected_summary.get("best"), dict) else {} + if not selected_preview or self._safe_int(best.get("score"), 0) > self._safe_int(current_best.get("score"), 0): + selected_candidate = dict(candidate or {}) + selected_preview = preview + selected_summary = summary + if self._assistant_smart_search_stop_after_source( + self._assistant_smart_search_candidate_from_cloud_item(self._best_scored_source_item(preview), source_type="hdhive"), + preferences, + ): + break + + state: Dict[str, Any] + if selected_preview: + state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "selected_candidate": selected_candidate, + "resources": selected_preview, + "page": 1, + "page_size": self._assistant_result_page_size, + } + else: + state = { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + } + return { + "source_type": "hdhive", + "ok": True, + "reason": search_message, + "state": state, + "candidates": candidates, + "items": selected_preview, + "score_summary": selected_summary, + } + + def _assistant_smart_search_decision_summary( + self, + *, + keyword: str, + preferences: Dict[str, Any], + checked: List[Dict[str, Any]], + best_candidate: Dict[str, Any], + available_sources: Optional[List[Dict[str, Any]]] = None, + blocked_sources: Optional[List[Dict[str, Any]]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + auto_threshold = self._safe_int( + prefs.get("auto_ingest_score_threshold"), + self._assistant_default_auto_ingest_score_threshold, + ) + source_names = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + preferred_command = self._clean_text(best_candidate.get("next_command")) + fallback_command = self._clean_text(best_candidate.get("detail_command")) + detail_command = "先看详情" if best_candidate.get("choice") else "" + detail_short_command = "详情" if best_candidate.get("choice") else "" + title = self._clean_text(best_candidate.get("title")) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + write_intent_commands = {"计划最佳", "执行最佳", "确认执行", "先计划"} + decision_mode = "show_detail" + confirm_required = False + decision_reason = "" + confirmation_prompt = "" + if not best_candidate: + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "decision_hint": "已按当前偏好尝试可用源,但没有找到符合条件的资源。", + "decision_mode": "not_recommended", + "decision_reason": "所有可用源都没有给出符合当前偏好的候选。", + "preferred_command": "", + "fallback_command": "", + "detail_command": "", + "detail_short_command": "", + "confirmation_prompt": "", + "plan_short_command": "", + "confirm_short_command": "", + "compact_commands": [], + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": False, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": "show_only", + } + if hard_risks: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最高分是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')},但存在硬风险。" + decision_mode = "not_recommended" + decision_reason = "当前最高分候选存在硬风险,不能作为直接推荐执行项。" + confirmation_prompt = "先看详情,或换源后再试。" + elif score_value >= threshold: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前首选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分)。" + if score_value >= auto_threshold: + decision_mode = "execute_now" + decision_reason = "当前首选分数已达到高可信区间,可以作为立即执行首选,但写入仍需明确意图。" + preferred_command = "执行最佳" + fallback_command = "计划最佳" + confirm_required = True + confirmation_prompt = "确认执行;如果想保守一点,回复:先计划 或 先看详情。" + else: + decision_mode = "make_plan" + decision_reason = "当前首选已达到建议确认阈值,优先生成待确认计划。" + preferred_command = "计划最佳" + fallback_command = "执行最佳" + confirm_required = True + confirmation_prompt = "先计划;如果想直接落地,回复:确认执行;如果想先检查资源,回复:先看详情。" + else: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最佳候选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分),但还没达到优先阈值。" + decision_mode = "show_detail" + decision_reason = "当前最佳候选分数偏低,优先查看详情或尝试切换搜索源。" + preferred_command = fallback_command or preferred_command + fallback_command = "计划最佳" if best_candidate.get("choice") and not hard_risks else "" + confirmation_prompt = "先看详情;如果仍要继续,回复:先计划 或 换影巢 / 换盘搜 / 换PT。" + if title: + hint = f"{hint} {title}" + auto_run_command = "" + confirm_command = "" + display_command = preferred_command or detail_command + preferred_requires_confirmation = preferred_command in write_intent_commands + fallback_requires_confirmation = fallback_command in write_intent_commands + if preferred_requires_confirmation: + confirm_command = preferred_command + if detail_command and detail_command != preferred_command: + command_policy = "read_then_confirm_write" + auto_run_command = detail_command + display_command = detail_command + recommended_agent_behavior = "auto_continue_then_wait_confirmation" + else: + command_policy = "wait_user_confirmation" + recommended_agent_behavior = "wait_user_confirmation" + can_auto_run_preferred = False + else: + command_policy = "safe_read_only" + can_auto_run_preferred = bool(preferred_command) + recommended_agent_behavior = "show_only" if decision_mode == "not_recommended" else "auto_continue" + display_command = preferred_command or detail_command + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "threshold": threshold, + "auto_ingest_score_threshold": auto_threshold, + "preferred_source": source_type, + "preferred_score": score_value, + "preferred_title": title[:160], + "decision_hint": hint.strip(), + "decision_mode": decision_mode, + "decision_reason": decision_reason or hint.strip(), + "preferred_command": preferred_command, + "fallback_command": fallback_command, + "detail_command": detail_command if best_candidate.get("choice") and not hard_risks else "", + "detail_short_command": detail_short_command if best_candidate.get("choice") and not hard_risks else "", + "confirmation_prompt": confirmation_prompt, + "plan_command": "计划最佳" if best_candidate.get("choice") and not hard_risks else "", + "plan_short_command": "计划" if best_candidate.get("choice") and not hard_risks else "", + "execute_command": "执行最佳" if best_candidate.get("choice") and not hard_risks else "", + "confirm_short_command": "确认" if best_candidate.get("choice") and not hard_risks else "", + "compact_commands": [ + command + for command in [preferred_command, fallback_command, detail_short_command or detail_command] + if command + ][:2], + "command_policy": command_policy, + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": fallback_requires_confirmation, + "can_auto_run_preferred": can_auto_run_preferred, + "auto_run_command": auto_run_command, + "confirm_command": confirm_command, + "display_command": display_command, + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": confirm_required, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": recommended_agent_behavior, + } + + def _assistant_smart_decision_entry_message( + self, + *, + keyword: str, + decision_summary: Dict[str, Any], + available_sources: List[Dict[str, Any]], + blocked_sources: List[Dict[str, Any]], + ) -> str: + checked = decision_summary.get("checked_sources") or [] + checked_text = " -> ".join( + { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + }.get(self._clean_text(item).lower(), self._clean_text(item)) + for item in checked + if self._clean_text(item) + ) + lines = [f"资源决策:{keyword}"] + if checked_text: + lines.append(f"已检查:{checked_text}") + if available_sources: + lines.append("可用源:" + " / ".join(self._clean_text(item.get("label")) for item in available_sources if self._clean_text(item.get("label")))) + if blocked_sources: + blocked_text = ";".join( + f"{self._clean_text(item.get('label'))}:{self._clean_text(item.get('reason'))}" + for item in blocked_sources[:3] + if self._clean_text(item.get("label")) and self._clean_text(item.get("reason")) + ) + if blocked_text: + lines.append("已跳过:" + blocked_text) + if self._clean_text(decision_summary.get("decision_hint")): + lines.append(self._clean_text(decision_summary.get("decision_hint"))) + if self._clean_text(decision_summary.get("decision_reason")): + lines.append("结论:" + self._clean_text(decision_summary.get("decision_reason"))) + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + confirmation_prompt = self._clean_text(decision_summary.get("confirmation_prompt")) + if preferred_command: + lines.append(f"首选:{preferred_command}") + if fallback_command and fallback_command != preferred_command: + lines.append(f"备选:{fallback_command}") + if confirmation_prompt: + lines.append("确认链:" + confirmation_prompt) + return "\n".join(line for line in lines if line).strip() + + def _assistant_smart_source_item_by_choice( + self, + source_states: Dict[str, Any], + *, + source_type: str, + choice: int, + ) -> Dict[str, Any]: + current_state = source_states.get(source_type) if isinstance(source_states, dict) else {} + current_state = current_state if isinstance(current_state, dict) else {} + if source_type == "pansou": + items = current_state.get("items") or [] + elif source_type == "hdhive": + items = current_state.get("resources") or current_state.get("items") or [] + else: + items = current_state.get("items") or [] + for item in items: + if self._safe_int((item or {}).get("index") or (item or {}).get("pick_index"), 0) == choice: + return dict(item or {}) + return {} + + def _assistant_smart_best_plan_response( + self, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的智能搜索结果,请先发送:智能搜索 片名 或 智能计划 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_search_session_not_found", + }), + } + best_candidate = current_state.get("best_candidate") if isinstance(current_state.get("best_candidate"), dict) else {} + decision_summary = current_state.get("decision_summary") if isinstance(current_state.get("decision_summary"), dict) else {} + checked = current_state.get("sources_checked") if isinstance(current_state.get("sources_checked"), list) else [] + source_states = current_state.get("source_states") if isinstance(current_state.get("source_states"), dict) else {} + if not best_candidate: + return { + "success": False, + "message": "智能搜索当前没有可用于生成计划的候选,请先换片名,或调整偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_not_found", + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best_candidate.get("risk_reasons") or []) if self._clean_text(value)] + origin = self._clean_text(current_state.get("origin")).lower() + final_path = ( + target_path + or self._clean_text(current_state.get("target_path")) + or self._clean_text(((source_states.get(source_type) or {}) if isinstance(source_states, dict) else {}).get("target_path")) + ) + + if hard_risks: + head = self._clean_text(decision_summary.get("decision_hint")) or "当前首选存在硬风险。" + tail = ";".join(hard_risks[:2]) + return { + "success": False, + "message": f"{head}\n未生成计划:{tail}", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_blocked", + "best_candidate": best_candidate, + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + result: Dict[str, Any] + if source_type == "pansou": + selected = self._assistant_smart_source_item_by_choice(source_states, source_type="pansou", choice=choice) or dict(best_candidate.get("raw_item") or {}) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return { + "success": False, + "message": "当前盘搜首选缺少可转存链接,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_share_url", + "best_candidate": best_candidate, + }), + } + provider_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": self._clean_text(selected.get("password")), + "path": final_path or provider_path, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "pansou", + "path": final_path or provider_path, + }, + message="智能搜索待确认计划已生成", + score_items=[selected], + extra_data={ + "choice": choice, + "source_type": "pansou", + "target_path": final_path or provider_path, + "selected_item": selected, + }, + ) + elif source_type == "hdhive": + resource = self._assistant_smart_source_item_by_choice(source_states, source_type="hdhive", choice=choice) or dict(best_candidate.get("raw_item") or {}) + slug = self._clean_text(resource.get("slug")) + if not slug: + return { + "success": False, + "message": "当前影巢首选缺少 slug,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_hdhive_slug", + "best_candidate": best_candidate, + }), + } + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path or self._hdhive_default_path, + "resource": resource, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "hdhive", + "path": final_path or self._hdhive_default_path, + }, + message="智能搜索待确认计划已生成", + score_items=[resource], + extra_data={ + "choice": choice, + "source_type": "hdhive", + "target_path": final_path or self._hdhive_default_path, + "selected_resource": resource, + }, + ) + elif source_type == "mp_pt": + preferences = self._assistant_preferences_for_session(session=session) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="smart_resource_plan", + message="智能搜索待确认计划已生成", + ) + else: + return { + "success": False, + "message": "当前首选来源暂不支持统一计划生成,请先查看详情后手动选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_unsupported_source", + "best_candidate": best_candidate, + }), + } + + data = dict(result.get("data") or {}) + adjusted_decision_summary = dict(decision_summary) + if origin == "mp_recommend" and choice and not hard_risks: + adjusted_decision_summary.update({ + "decision_mode": "make_plan", + "decision_reason": "推荐会话已为当前首项生成待确认计划;建议先看详情,再决定是否确认执行。", + "preferred_command": "确认", + "fallback_command": "详情", + "detail_command": "详情", + "detail_short_command": "详情", + "confirmation_prompt": "先看详情;如果确认无误,回复:确认。", + "plan_command": "计划", + "plan_short_command": "计划", + "execute_command": "确认", + "confirm_short_command": "确认", + "compact_commands": ["确认", "详情"], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + }) + data.update({ + "source_type": source_type, + "best_candidate": best_candidate, + "decision_summary": adjusted_decision_summary, + "sources_checked": checked, + "smart_plan_auto_selected": True, + }) + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in adjusted_decision_summary: + data[key] = adjusted_decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + if choice and not data.get("choice"): + data["choice"] = choice + result["data"] = data + message_lines = [ + "已根据智能搜索结果自动选择当前首选并生成待确认计划", + f"首选:{source_type} #{choice} | {score_value}分" + (f" | {title}" if title else ""), + result.get("message") or "", + ] + if risks: + message_lines.append("风险提示:" + ";".join(risks[:2])) + result["message"] = "\n".join(line for line in message_lines if line) + return result + + async def _assistant_execute_prepared_plan_result( + self, + request: Request, + *, + session: str, + cache_key: str, + plan_result: Dict[str, Any], + message_prefix: str = "", + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if not bool(plan_result.get("success")): + return plan_result + plan_data = dict(plan_result.get("data") or {}) + plan_id = self._clean_text(plan_data.get("plan_id")) + if not plan_id: + return { + "success": False, + "message": "当前没有可执行的待确认计划,请先生成计划后再执行。", + "data": self._assistant_response_data(session=session, data={ + "action": "execute_plan", + "ok": False, + "error_code": "plan_not_found", + }), + } + execute_result = await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": plan_id, + "prefer_unexecuted": True, + "stop_on_error": True, + "include_raw_results": False, + "compact": False, + "apikey": self._extract_apikey(request, {}), + }) + ) + execute_data = dict(execute_result.get("data") or {}) + for key in [ + "source_type", + "best_candidate", + "decision_summary", + "sources_checked", + "score_summary", + "choice", + "smart_plan_auto_selected", + ]: + if key in plan_data: + execute_data[key] = plan_data.get(key) + if extra_data: + execute_data.update(dict(extra_data)) + execute_result["data"] = execute_data + if message_prefix: + execute_result["message"] = "\n".join( + line for line in [message_prefix, self._clean_text(execute_result.get("message"))] + if line + ).strip() + return execute_result + + async def _assistant_smart_best_execute_response( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + plan_result = self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + ) + plan_data = dict(plan_result.get("data") or {}) + best_candidate = plan_data.get("best_candidate") if isinstance(plan_data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + source_label = {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get(source_type, source_type or "当前源") + transfer_source_text = { + "pansou": "盘搜搜索转存", + "hdhive": "影巢搜索转存", + "mp_pt": "MP/PT 下载", + }.get(source_type, source_label) + prefix = "已根据智能搜索结果自动生成并执行当前首选计划" + prefix += f"\n执行方式:{transfer_source_text}" + if choice: + prefix += f"\n首选:{source_label} #{choice} | {score_value}分" + (f" | {title}" if title else "") + return await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=prefix, + extra_data={ + "smart_execute_auto_selected": True, + "source_type": source_type, + }, + ) + + async def _assistant_cloud_transfer_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + effective_target_path = self._clean_text(target_path) + prefer_provider = self._clean_text((session_preference_overrides or {}).get("prefer_cloud_provider")).lower() + if not effective_target_path: + if prefer_provider == "quark": + effective_target_path = self._quark_default_path + elif prefer_provider == "115": + effective_target_path = self._p115_default_path + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + search_result=search_result, + origin=origin, + ) + best_candidate = dict((smart_state.get("best_candidate") or {})) + if not search_result.get("success") and not best_candidate.get("source_type"): + return search_result + + preferences = self._assistant_smart_merge_session_preferences( + self._assistant_preferences_for_session(session=session), + session_overrides=session_preference_overrides, + ) + threshold = self._safe_int( + preferences.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + if best_candidate.get("source_type") and score_value >= threshold and not hard_risks: + execute_result = await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=effective_target_path, + ) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if source_type == "hdhive": + raw_item = dict(best_candidate.get("raw_item") or {}) + points_text = self._resource_points_text(raw_item) + if points_text and points_text not in {"免费", "未标积分"}: + execute_result["message"] = "\n".join( + line + for line in [ + f"影巢积分:{points_text},已直接解锁并转存。", + self._clean_text(execute_result.get("message")), + ] + if line + ).strip() + return execute_result + + source_states = smart_state.get("source_states") if isinstance(smart_state.get("source_states"), dict) else {} + pansou_state = dict(source_states.get("pansou") or {}) + hdhive_state = dict(source_states.get("hdhive") or {}) + cloud_result = self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=pansou_state.get("items") or [], + pansou_total=len(pansou_state.get("items") or []), + hdhive_resources=hdhive_state.get("resources") or [], + hdhive_candidate=hdhive_state.get("selected_candidate") or {}, + hdhive_candidates=hdhive_state.get("candidates") or [], + target_path=effective_target_path or self._hdhive_default_path, + lead_note="当前云盘资源质量或风险未达到直接转存阈值,已列出盘搜 + 影巢结果供你手动选择。", + ) + top_choices: List[str] = [] + if best_candidate.get("choice") and best_candidate.get("source_type"): + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get( + self._clean_text(best_candidate.get("source_type")).lower(), + self._clean_text(best_candidate.get("source_type")), + ) + top_choices.append(f"{source_label} #{self._safe_int(best_candidate.get('choice'), 0)}") + for item in (smart_state.get("alternatives") or []): + if not isinstance(item, dict): + continue + source_type = self._clean_text(item.get("source_type")).lower() + if source_type not in {"pansou", "hdhive"}: + continue + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get(source_type, source_type) + label = f"{source_label} #{self._safe_int(item.get('choice'), 0)}" + if label not in top_choices and self._safe_int(item.get("choice"), 0) > 0: + top_choices.append(label) + if len(top_choices) >= 3: + break + if top_choices: + cloud_result["message"] = "\n".join( + [ + self._clean_text(cloud_result.get("message")), + "推荐优先看:" + " / ".join(top_choices[:3]), + ] + ).strip() + if isinstance(cloud_result.get("data"), dict): + cloud_result["data"]["action"] = "cloud_transfer_execute" + cloud_result["data"]["best_candidate"] = best_candidate + return cloud_result + + async def _assistant_smart_resource_search( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + action_name: str = "smart_resource_search", + origin: str = "", + ) -> Dict[str, Any]: + base_preferences = self._assistant_preferences_for_session(session=session) + preferences = self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + decision_profile=decision_profile, + ) + search_order = self._assistant_smart_search_source_order(preferences, source_order=source_order) + available_sources, blocked_sources = self._assistant_smart_source_availability( + preferences, + source_order=source_order, + ) + if not search_order: + return { + "success": False, + "message": "当前偏好把盘搜、影巢、MP/PT 都关闭了,请先保存偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": False, + "error_code": "no_enabled_sources", + "preference_status": self._assistant_preferences_status_brief(session=session), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + }), + } + + checked: List[Dict[str, Any]] = [] + alternatives: List[Dict[str, Any]] = [] + active_state: Dict[str, Any] = {} + best_candidate: Dict[str, Any] = {} + best_source_score = -1 + source_states: Dict[str, Any] = {} + threshold = self._safe_int(preferences.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold) + + for current_source in search_order: + if current_source == "pansou": + search_ok, payload, search_message = self._call_pansou_search(keyword) + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + for idx, item in enumerate(items, start=1): + item["index"] = idx + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=6) + source_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + } + source_states[current_source] = copy.deepcopy(source_state) + summary = self._score_summary(items, limit=5) + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="pansou") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(search_ok), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": search_message or ("盘搜无结果" if not items else ""), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="pansou") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + if current_source == "hdhive": + hdhive_result = await self._assistant_smart_search_hdhive_resources( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + preferences=preferences, + ) + source_state = dict(hdhive_result.get("state") or {}) + items = hdhive_result.get("items") or [] + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + source_state["items"] = items + source_state["resources"] = items + source_states[current_source] = copy.deepcopy(source_state) + summary = hdhive_result.get("score_summary") if isinstance(hdhive_result.get("score_summary"), dict) else {} + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="hdhive") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(hdhive_result.get("ok")), + "result_count": len(items), + "candidate_count": len(hdhive_result.get("candidates") or []), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._clean_text(hdhive_result.get("reason")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="hdhive") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + items = ((mp_result.get("data") or {}).get("items") or []) if isinstance(mp_result.get("data"), dict) else [] + summary = ((mp_result.get("data") or {}).get("score_summary") or {}) if isinstance(mp_result.get("data"), dict) else {} + source_state = copy.deepcopy(self._load_session(cache_key) or {}) + source_states[current_source] = source_state + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_pt_item(best_item) if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(mp_result.get("success")), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._assistant_result_message_head(mp_result.get("message")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_pt_item(item) + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + + alternatives = [item for item in alternatives if isinstance(item, dict) and item.get("choice")] + alternatives.sort( + key=lambda item: ( + self._safe_int(item.get("score"), 0), + 0 if item.get("hard_risk_reasons") else 1, + ), + reverse=True, + ) + unique_alternatives: List[Dict[str, Any]] = [] + seen_keys: set = set() + for item in alternatives: + dedupe_key = ( + self._clean_text(item.get("source_type")).lower(), + self._safe_int(item.get("choice"), 0), + ) + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + unique_alternatives.append(item) + + decision_summary = self._assistant_smart_search_decision_summary( + keyword=keyword, + preferences=preferences, + checked=checked, + best_candidate=best_candidate, + available_sources=available_sources, + blocked_sources=blocked_sources, + decision_profile=decision_profile, + ) + if active_state: + smart_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or active_state.get("target_path") or "", + "active_source": self._clean_text(best_candidate.get("source_type")), + "active_state": copy.deepcopy(active_state), + "source_states": source_states, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "source_order": search_order, + "decision_profile": self._clean_text(decision_profile), + "decision_entry": action_name, + "origin": self._clean_text(origin), + "session_preference_overrides": dict(session_preference_overrides or {}), + } + self._save_session(cache_key, smart_state) + + if action_name == "smart_resource_decision": + message_lines = [ + self._assistant_smart_decision_entry_message( + keyword=keyword, + decision_summary=decision_summary, + available_sources=available_sources, + blocked_sources=blocked_sources, + ) + ] + else: + message_lines = [ + f"智能搜索:{keyword}", + "已检查:" + " -> ".join( + {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get( + self._clean_text(item.get("source_type")).lower(), + self._clean_text(item.get("source_type")), + ) + for item in checked + if self._clean_text(item.get("source_type")) + ), + ] + if best_candidate: + message_lines.append(decision_summary.get("decision_hint") or "") + if best_candidate.get("hard_risk_reasons"): + message_lines.append("降级原因:" + ";".join(best_candidate.get("hard_risk_reasons")[:2])) + elif best_candidate.get("risk_reasons") and self._safe_int(best_candidate.get("score"), 0) < threshold: + message_lines.append("继续原因:" + ";".join(best_candidate.get("risk_reasons")[:2])) + else: + message_lines.append("当前按偏好筛过可用源后,没有找到可推荐结果。") + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + plan_command = self._clean_text(decision_summary.get("plan_command")) + execute_command = self._clean_text(decision_summary.get("execute_command")) + detail_short_command = self._clean_text(decision_summary.get("detail_short_command")) + plan_short_command = self._clean_text(decision_summary.get("plan_short_command")) + confirm_short_command = self._clean_text(decision_summary.get("confirm_short_command")) + if action_name != "smart_resource_decision" and preferred_command: + message_lines.append(f"建议先发:{preferred_command}") + if action_name != "smart_resource_decision" and fallback_command and fallback_command != preferred_command: + message_lines.append(f"备选:{fallback_command}") + if action_name != "smart_resource_decision" and plan_command and plan_command not in {preferred_command, fallback_command}: + message_lines.append(f"如需直接生成待确认计划:{plan_command}") + if action_name != "smart_resource_decision" and execute_command and execute_command not in {preferred_command, fallback_command, plan_command}: + message_lines.append(f"如需直接执行:{execute_command}") + override_summary = self._assistant_smart_session_overrides_summary(session_preference_overrides) + if override_summary: + message_lines.append("会话偏好:" + override_summary) + + score_summary = {} + active_source_type = self._clean_text(best_candidate.get("source_type")).lower() + if active_source_type == "pansou": + active_items = (source_states.get("pansou") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "hdhive": + active_items = (source_states.get("hdhive") or {}).get("resources") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "mp_pt": + active_items = (source_states.get("mp_pt") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + + return { + "success": True, + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": True, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "score_summary": score_summary, + "preference_status": self._assistant_preferences_status_brief(session=session), + "effective_preferences": preferences, + "session_preference_overrides": dict(session_preference_overrides or {}), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "decision_mode": self._clean_text(decision_summary.get("decision_mode")), + "decision_reason": self._clean_text(decision_summary.get("decision_reason")), + "next_actions": [ + command + for command in [ + preferred_command, + fallback_command, + detail_short_command, + plan_command, + plan_short_command, + execute_command, + confirm_short_command, + "继续决策", + "换影巢", + "换盘搜", + "换PT", + "保守一点", + "激进一点", + "只用夸克", + "只用115", + "只走PT", + "不用影巢", + "按保存偏好", + "跟进", + ] + if self._clean_text(command) + ], + }), + } + + async def _assistant_smart_resource_decision( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + return await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + action_name="smart_resource_decision", + origin=origin, + ) + + async def _assistant_smart_resource_decision_adjust( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]], + adjust_action: str, + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的资源决策会话,请先发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_session_not_found", + }), + } + keyword = self._clean_text(current_state.get("keyword")) + if not keyword: + return { + "success": False, + "message": "当前资源决策会话缺少片名,请重新发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_missing_keyword", + }), + } + media_type = self._clean_text(current_state.get("media_type") or "auto") or "auto" + year = self._clean_text(current_state.get("year")) + target_path = self._clean_text(current_state.get("target_path")) + source_order = list(current_state.get("source_order") or []) + session_preference_overrides = dict(current_state.get("session_preference_overrides") or {}) + if not source_order: + base_preferences = self._assistant_preferences_for_session(session=session) + source_order = self._assistant_smart_search_source_order( + self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + ) + ) + decision_profile = self._clean_text(current_state.get("decision_profile")) + if adjust_action == "decision_hdhive": + source_order = ["hdhive", "pansou", "mp_pt"] + elif adjust_action == "decision_pansou": + source_order = ["pansou", "hdhive", "mp_pt"] + elif adjust_action == "decision_mp_pt": + source_order = ["mp_pt", "pansou", "hdhive"] + elif adjust_action in { + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + session_preference_overrides, source_order = self._assistant_smart_apply_session_override( + session_preference_overrides, + adjust_action=adjust_action, + source_order=source_order, + ) + elif adjust_action == "decision_conservative": + decision_profile = "conservative" + elif adjust_action == "decision_aggressive": + decision_profile = "aggressive" + elif adjust_action == "decision_continue": + pass + return await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + ) + + async def _assistant_smart_resource_plan( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + async def _assistant_smart_resource_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + def _assistant_smart_state_after_search( + self, + *, + cache_key: str, + keyword: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + search_result: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + current_state = dict(self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) == "assistant_smart_search": + return current_state + data = (search_result or {}).get("data") if isinstance((search_result or {}).get("data"), dict) else {} + best_candidate = data.get("best_candidate") if isinstance(data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if not source_type: + return current_state + source_states = copy.deepcopy(current_state.get("source_states") or {}) if isinstance(current_state.get("source_states"), dict) else {} + raw_item = dict(best_candidate.get("raw_item") or {}) if isinstance(best_candidate.get("raw_item"), dict) else {} + choice = self._safe_int(best_candidate.get("choice") or raw_item.get("index"), 0) + final_path = self._clean_text(target_path or current_state.get("target_path") or data.get("target_path")) + + if source_type == "pansou" and raw_item: + item = dict(raw_item) + if choice and not item.get("index"): + item["index"] = choice + if not final_path: + share_url = self._clean_text(item.get("url")) + final_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + source_states[source_type] = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": final_path, + "items": [item], + } + elif source_type == "hdhive" and raw_item: + resource = dict(raw_item) + source_states[source_type] = { + "kind": "assistant_hdhive", + "stage": "resource_list", + "keyword": keyword, + "target_path": final_path or self._hdhive_default_path, + "resources": [resource], + "items": [resource], + } + + active_state = copy.deepcopy(source_states.get(source_type) or current_state.get("active_state") or {}) + fallback_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": final_path, + "active_source": source_type, + "active_state": active_state, + "source_states": source_states, + "sources_checked": data.get("sources_checked") if isinstance(data.get("sources_checked"), list) else [], + "best_candidate": best_candidate, + "alternatives": data.get("alternatives") if isinstance(data.get("alternatives"), list) else [], + "decision_summary": data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}, + "available_sources": data.get("available_sources") if isinstance(data.get("available_sources"), list) else [], + "blocked_sources": data.get("blocked_sources") if isinstance(data.get("blocked_sources"), list) else [], + "source_order": source_order or current_state.get("source_order") or [], + "decision_profile": self._clean_text(current_state.get("decision_profile") or ((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_profile")), + "decision_entry": self._clean_text(data.get("action") or current_state.get("decision_entry") or "smart_resource_search"), + "origin": self._clean_text(origin or current_state.get("origin")), + "session_preference_overrides": data.get("session_preference_overrides") if isinstance(data.get("session_preference_overrides"), dict) else dict(current_state.get("session_preference_overrides") or {}), + } + self._save_session(cache_key, fallback_state) + return fallback_state + + @staticmethod + def _assistant_score_warning_text(score: Dict[str, Any], *, limit: int = 3) -> str: + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + if not risks: + return "" + return "风险提示:" + ";".join(risks[:max(1, limit)]) + + def _assistant_mp_download_plan_response( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + workflow: str = "mp_download", + message: str = "PT 下载计划已生成", + ) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会生成下载计划。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”后再选择编号。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + result = self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": "pick_mp_download", + "session": session, + "session_id": cache_key, + "choice": choice, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "choice": choice, + "dry_run": False, + }, + message=message, + score_items=[selected], + extra_data={ + "choice": choice, + "selected": selected, + }, + ) + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + warning = self._assistant_score_warning_text(score) + if warning: + result["message"] = f"{result.get('message')}\n{warning}" + return result + + def _assistant_mp_subscribe_plan_response( + self, + *, + keyword: str, + session: str, + cache_key: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + action_name = "start_mp_subscribe_search" if immediate_search else "start_mp_subscribe" + workflow = "mp_subscribe_and_search" if immediate_search else "mp_subscribe" + label = "订阅并搜索计划已生成" if immediate_search else "订阅计划已生成" + return self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": action_name, + "session": session, + "session_id": cache_key, + "keyword": keyword, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "keyword": keyword, + "dry_run": False, + }, + message=label, + extra_data={ + "keyword": keyword, + "immediate_search": bool(immediate_search), + }, + ) + + def _assistant_mp_download_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "下载任务操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + } + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return { + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_target_not_found", + "target": target, + }), + } + downloader = self._clean_text(downloader) + return self._save_assistant_pick_plan_response( + workflow="mp_download_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + }], + execute_body={ + "workflow": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + "dry_run": False, + }, + message="下载任务操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + def _assistant_mp_subscribe_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "订阅操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + } + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id): + return { + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + } + return self._save_assistant_pick_plan_response( + workflow="mp_subscribe_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + }], + execute_body={ + "workflow": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "dry_run": False, + }, + message="订阅操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + async def _assistant_mp_download(self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any]) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会执行下载。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + "write_effect": "state", + }), + } + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + cache_choice = self._safe_int(selected.get("cache_index"), choice) + message_text = self._ensure_feishu_channel()._execute_media_download(cache_choice, cache_key) + ok = not message_text.startswith(( + "下载资源失败", + "下载提交失败", + "没有可用", + "序号超出范围", + )) and "无法连接" not in message_text and "添加下载任务失败" not in message_text + warning = self._assistant_score_warning_text(score) + if warning: + message_text = f"{message_text}\n{warning}".strip() + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": ok, + "choice": choice, + "cache_choice": cache_choice, + "selected": selected, + "score": score, + "write_effect": "write", + }), + } + + async def _assistant_mp_subscribe( + self, + *, + keyword: str, + session: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + message_text = self._ensure_feishu_channel()._execute_media_subscribe(keyword, immediate_search) + ok = not message_text.startswith("订阅失败") + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": ok, + "keyword": keyword, + "immediate_search": bool(immediate_search), + "write_effect": "write", + }), + } + + def _resolve_mp_download_task_target(self, *, target: str, cache_key: str) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index: + return dict(item) + hash_match = re.search(r"\b[0-9a-fA-F]{40}\b", target_text) + if hash_match: + task_hash = hash_match.group(0) + return {"hash": task_hash, "hash_short": task_hash[:8]} + short_hash_match = re.search(r"\b[0-9a-fA-F]{6,12}\b", target_text) + if short_hash_match: + short_hash = short_hash_match.group(0).lower() + for item in items: + task_hash = self._clean_text(item.get("hash")).lower() + if task_hash.startswith(short_hash): + return dict(item) + return {} + + async def _assistant_mp_download_tasks( + self, + *, + session: str, + cache_key: str, + status: str = "downloading", + title: str = "", + hash_value: str = "", + downloader: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_tasks( + downloader=downloader, + status=status or "downloading", + title=title, + hash_value=hash_value, + limit=max(1, min(30, self._safe_int(limit, 10))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_tasks", + "stage": "download_tasks", + "keyword": title or hash_value or status or "downloading", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_tasks", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": result.get("total", len(items)), + }), + } + + async def _assistant_mp_download_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_download_task_target(target=target, cache_key=cache_key) + task_hash = self._clean_text(selected.get("hash")) + if not task_hash: + return { + "success": False, + "message": "操作下载任务失败:请先发送“下载任务”获取列表,再按编号操作,例如“暂停下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "missing_download_task_hash", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_download_task( + action=control, + hash_value=task_hash, + downloader=downloader or self._clean_text(selected.get("downloader")), + delete_files=delete_files, + ) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_download_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_history( + title=title, + hash_value=hash_value, + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_history", + "stage": "download_history", + "keyword": title or hash_value or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_download_history", + "title": title, + "hash": hash_value, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + async def _assistant_mp_downloaders(self, *, session: str, cache_key: str) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_downloaders() + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_downloaders", + "stage": "downloaders", + "keyword": "downloaders", + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载器查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_downloaders", + "ok": bool(result.get("success")), + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + }), + } + + async def _assistant_mp_sites( + self, + *, + session: str, + cache_key: str, + status: str = "active", + name: str = "", + limit: int = 30, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_sites( + status=status or "active", + name=name, + limit=max(1, min(100, self._safe_int(limit, 30))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_sites", + "stage": "sites", + "keyword": name or status or "active", + "items": items, + "status": result.get("status") or status, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "站点查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_sites", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), 0), + }), + } + + def _resolve_mp_subscribe_target(self, *, target: str, cache_key: str, allow_raw_id: bool = False) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index or self._safe_int(item.get("id"), 0) == index: + return dict(item) + if allow_raw_id: + return {"id": index} + return {} + + async def _assistant_mp_subscribes( + self, + *, + session: str, + cache_key: str, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_subscribes( + status=status or "all", + media_type=media_type or "all", + name=name, + limit=max(1, min(100, self._safe_int(limit, 20))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_subscribes", + "stage": "subscribe_list", + "keyword": name or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribes", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + }), + } + + async def _assistant_mp_subscribe_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id) + subscribe_id = self._safe_int(selected.get("id") or target, 0) + if subscribe_id <= 0: + return { + "success": False, + "message": "操作订阅失败:请先发送“订阅列表”获取列表,再按编号操作,例如“搜索订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "missing_subscribe_id", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_subscribe(action=control, subscribe_id=subscribe_id) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_transfer_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status=status or "all", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_transfer_history", + "stage": "transfer_history", + "keyword": title or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "整理历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_transfer_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_history", + "status": result.get("status") or status, + "title": title, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + def _assistant_mp_detect_stage( + self, + *, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> str: + def has_term(value: Any, terms: List[str]) -> bool: + text = self._clean_text(value).lower() + return bool(text) and any(term in text for term in terms) + + failed_terms = ["失败", "错误", "error", "fail", "未识别", "识别失败", "入库失败", "整理失败"] + success_terms = ["成功", "完成", "已入库", "已整理", "已转移", "imported", "success", "completed"] + running_terms = ["处理中", "进行中", "转移中", "整理中", "入库中", "等待", "队列", "pending", "running"] + + for item in transfer_items: + if has_term(item.get("status_text") or item.get("status"), failed_terms): + return "failed" + for item in download_items: + if has_term(item.get("transfer_status_text") or item.get("transfer_status"), failed_terms): + return "failed" + + if task_items: + return "downloading" + + for item in transfer_items: + status_value = item.get("status_text") or item.get("status") + if has_term(status_value, success_terms): + return "imported" + if has_term(status_value, running_terms): + return "transferring" + + for item in download_items: + transfer_value = item.get("transfer_status_text") or item.get("transfer_status") + if has_term(transfer_value, success_terms): + return "imported" + if has_term(transfer_value, running_terms): + return "transferring" + + if transfer_items: + return "transferring" + if download_items: + return "downloaded" + return "not_found" + + def _assistant_mp_diagnosis_summary( + self, + *, + keyword: str, + hash_value: str, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + force_failed: bool = False, + ) -> Dict[str, Any]: + stage = "failed" if force_failed else self._assistant_mp_detect_stage( + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + matched_count = len(task_items) + len(download_items) + len(transfer_items) + matched = matched_count > 0 + evidence: List[str] = [] + risk_reasons: List[str] = [] + recommended_action = "" + follow_up_hint = "" + confidence = 0.0 + + if task_items: + first_task = task_items[0] + evidence.append( + f"下载任务 {self._clean_text(first_task.get('title')) or '-'} | " + f"{self._clean_text(first_task.get('progress')) or '-'} | " + f"{self._clean_text(first_task.get('state')) or '-'}" + ) + if download_items: + first_download = download_items[0] + evidence.append( + f"下载历史 {self._clean_text(first_download.get('title')) or '-'} | " + f"{self._clean_text(first_download.get('date')) or '-'} | " + f"{self._clean_text(first_download.get('transfer_status_text')) or '-'}" + ) + transfer_status_text = self._clean_text(first_download.get("transfer_status_text")) + if transfer_status_text and any(word in transfer_status_text for word in ["失败", "错误"]): + risk_reasons.append(f"下载历史显示整理状态异常:{transfer_status_text}") + if transfer_items: + first_transfer = transfer_items[0] + evidence.append( + f"整理历史 {self._clean_text(first_transfer.get('title')) or '-'} | " + f"{self._clean_text(first_transfer.get('status_text')) or '-'} | " + f"{self._clean_text(first_transfer.get('date')) or '-'}" + ) + status_text = self._clean_text(first_transfer.get("status_text")) + if status_text and any(word in status_text for word in ["失败", "错误"]): + risk_reasons.append(f"整理历史存在失败记录:{status_text}") + + if stage == "downloading": + confidence = 0.95 + recommended_action = "query_mp_download_tasks" + follow_up_hint = "资源仍在下载阶段,优先查看下载任务进度。" + elif stage == "downloaded": + confidence = 0.85 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已找到下载历史,但还没有明确的入库记录,下一步查看整理/入库历史。" + elif stage == "transferring": + confidence = 0.9 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "资源已经进入整理/入库链路,优先查看最近整理历史。" + elif stage == "imported": + confidence = 0.98 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已经找到成功入库线索,如需确认最终落地可查看最近整理历史。" + elif stage == "failed": + confidence = 0.92 if matched else 0.65 + recommended_action = "query_mp_local_diagnose" + follow_up_hint = "已发现失败线索,建议进入本地诊断汇总失败原因和下一步处理建议。" + elif stage == "not_found": + confidence = 0.95 + recommended_action = "query_mp_download_history" + follow_up_hint = "当前没有找到相关下载任务、下载历史或整理记录。先检查是否真的提交过下载/订阅。" + else: + confidence = 0.5 + recommended_action = "query_mp_lifecycle_status" + follow_up_hint = "状态不足以判断,建议继续查看生命周期聚合结果。" + + if hash_value: + evidence.append(f"Hash 检索:{hash_value}") + elif keyword: + evidence.append(f"关键词检索:{keyword}") + + return { + "status": "ok" if matched else "not_found", + "matched": matched, + "stage": stage, + "confidence": confidence, + "evidence": evidence[:6], + "risk_reasons": risk_reasons[:6], + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + } + + def _assistant_mp_diagnosis_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + hash_value: str, + preferred: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + } + keyword_body = dict(base_body) + if keyword: + keyword_body["keyword"] = keyword + if hash_value: + keyword_body["hash"] = hash_value + action_order = [preferred] + for name in ["query_mp_lifecycle_status", "query_mp_download_history", "query_mp_transfer_history", "query_mp_local_diagnose"]: + if name not in action_order: + action_order.append(name) + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_mp_lifecycle_status": "聚合查看下载任务、下载历史和整理/入库历史", + "query_mp_download_history": "查看下载历史,并确认是否已经提交过下载", + "query_mp_transfer_history": "查看整理/入库历史,确认是否已经落库或失败", + "query_mp_local_diagnose": "汇总本地/PT 链路的失败线索与处理建议", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**keyword_body, "name": name, "compact": True}, + ) + for name in action_order[:4] + ] + return action_order[:4], templates + + def _assistant_followup_command(self, action_name: str, *, keyword: str = "", hash_value: str = "") -> str: + target = self._clean_text(keyword or hash_value) + mapping = { + "query_execution_followup": "后续", + "query_mp_download_tasks": "下载任务", + "query_mp_subscribes": "订阅列表", + } + if action_name in mapping: + return mapping[action_name] + if action_name == "query_mp_ingest_status": + return f"入库 {target}".strip() + if action_name == "query_mp_lifecycle_status": + return f"状态 {target}".strip() + if action_name == "query_mp_download_history": + return f"记录 {target}".strip() + if action_name == "query_mp_transfer_history": + return f"入库记录 {target}".strip() + if action_name == "query_mp_local_diagnose": + return f"诊断 {target}".strip() + if action_name == "query_ai_failed_samples": + return f"失败样本 {target}".strip() if target else "失败样本" + if action_name == "query_ai_sample_worklist": + return f"工作清单 {target}".strip() if target else "工作清单" + if action_name == "query_ai_sample_insights": + return f"样本洞察 {target}".strip() if target else "样本洞察" + if action_name == "start_mp_media_search": + return f"MP搜索 {target}".strip() + return "" + + def _assistant_template_command( + self, + template: Dict[str, Any], + *, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> str: + if not isinstance(template, dict): + return "" + body = dict(template.get("action_body") or template.get("body") or {}) + action_name = self._clean_text(body.get("name")) or self._clean_text(template.get("name")) + target_text = self._clean_text(target or body.get("keyword") or body.get("hash") or body.get("target")) + command = self._assistant_followup_command( + action_name, + keyword=self._clean_text(keyword or body.get("keyword") or target_text), + hash_value=self._clean_text(hash_value or body.get("hash")), + ) + if command: + return command + if action_name == "query_mp_recent_activity": + return "最近" + if action_name == "execute_session_latest_plan": + return "执行计划" + if action_name == "mp_download_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停下载", + "resume": "恢复下载", + "delete": "删除下载", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "下载任务" + if action_name == "mp_subscribe_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停订阅", + "resume": "恢复订阅", + "delete": "删除订阅", + "search": "搜索订阅", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "订阅列表" + return "" + + def _assistant_error_summary( + self, + *, + error_code: str, + recommended_action: str = "", + message_head: str = "", + next_actions: Optional[List[Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> Dict[str, Any]: + code = self._clean_text(error_code) + if not code: + return {} + label_map = { + "latest_plan_not_executed": "当前会话还有待执行计划", + "plan_not_executed": "指定计划还没有执行", + "executed_plan_not_found": "当前没有可追踪的已执行计划", + "followup_not_available": "当前计划没有自动追踪动作", + "invalid_download_control_args": "下载任务参数不完整", + "download_target_not_found": "没有匹配到可操作的下载任务", + "invalid_subscribe_control_args": "订阅操作参数不完整", + "subscribe_target_not_found": "没有匹配到可操作的订阅", + } + hint_map = { + "latest_plan_not_executed": "先执行计划,再继续查看后续状态。", + "plan_not_executed": "先执行这条计划,再继续查看执行后追踪。", + "executed_plan_not_found": "先执行下载、订阅或转存计划,再查看后续。", + "followup_not_available": "当前计划只能改查状态、记录或最近活动。", + "invalid_download_control_args": "先发“下载任务”获取列表,再按编号暂停、恢复或删除。", + "download_target_not_found": "先发“下载任务”刷新列表,再按编号操作。", + "invalid_subscribe_control_args": "先发“订阅列表”获取列表,再按编号暂停、恢复或删除。", + "subscribe_target_not_found": "先发“订阅列表”刷新列表,再按编号操作。", + } + command_candidates: List[str] = [] + fallback_map = { + "latest_plan_not_executed": ["执行计划", "后续"], + "plan_not_executed": ["执行计划", "后续"], + "executed_plan_not_found": ["最近"], + "followup_not_available": [f"状态 {target}".strip(), f"记录 {target}".strip(), "最近"], + "invalid_download_control_args": ["下载任务"], + "download_target_not_found": ["下载任务"], + "invalid_subscribe_control_args": ["订阅列表"], + "subscribe_target_not_found": ["订阅列表"], + } + for command in fallback_map.get(code, []): + command_text = self._clean_text(command) + if command_text and command_text not in command_candidates: + command_candidates.append(command_text) + synthetic_templates: List[Dict[str, Any]] = [] + action_name = self._clean_text(recommended_action) + if action_name: + synthetic_templates.append({ + "name": action_name, + "action_body": { + "name": action_name, + "keyword": keyword, + "hash": hash_value, + "target": target, + }, + }) + synthetic_templates.extend( + item for item in (action_templates or []) if isinstance(item, dict) + ) + for item in synthetic_templates: + command = self._assistant_template_command( + item, + keyword=keyword, + hash_value=hash_value, + target=target, + ) + if command and command not in command_candidates: + command_candidates.append(command) + for name in [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)]: + command = self._assistant_followup_command(name, keyword=keyword or target, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + preferred_requires_confirmation = code in {"latest_plan_not_executed", "plan_not_executed"} + return { + "error_code": code, + "label": label_map.get(code, message_head or code), + "decision_hint": hint_map.get(code, message_head or "当前请求未完成,请先按建议补充上下文。"), + "command_policy": "confirm_then_resume" if preferred_requires_confirmation else "safe_read_recovery", + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": not preferred_requires_confirmation and bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + } + + def _format_quark_transfer_failure(self, detail: str = "", target_path: str = "") -> str: + text = self._clean_text(detail) + target = self._clean_text(target_path) or self._quark_default_path or "/飞书" + if "41031" in text or "分享者用户封禁链接查看受限" in text: + return ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + "建议改选同一列表里的其他夸克资源。" + ) + if "未获取到 stoken" in text: + try: + cookie_ok, _cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok = False + if cookie_ok: + return ( + "夸克转存失败:当前夸克登录态正常,但这条分享无法获取 stoken," + "更可能是分享链接失效、提取码错误或分享已受限。建议改选同一列表里的其他夸克资源。" + ) + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if "分享链接为空" in text or "无权查看内容" in text: + return ( + "夸克转存失败:当前分享内容为空或账号无权查看," + "更可能是分享链接失效、内容被取消分享或分享受限。建议改选其他资源。" + ) + if "require login [guest]" in text or "未配置夸克 Cookie" in text: + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text or text == f"无法转存到 {target}": + try: + cookie_ok, cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok, cookie_message = True, "" + cookie_text = self._clean_text(cookie_message) + if (not cookie_ok) and ( + "require login [guest]" in cookie_text + or "未配置夸克 Cookie" in cookie_text + or "cookie" in cookie_text.lower() + or "login" in cookie_text.lower() + ): + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text: + return ( + f"夸克转存失败:无法转存到 {target}。" + "当前原因未明,请先不要自行推断为路径问题,可稍后重试或先检查夸克登录态。" + ) + return f"夸克转存失败:{text}" + + @staticmethod + def _is_quark_share_restricted_message(detail: str) -> bool: + text = str(detail or "").strip() + if not text: + return False + return "41031" in text or "分享者用户封禁链接查看受限" in text + + async def _assistant_retry_pansou_quark_candidates( + self, + request: Request, + *, + items: List[Dict[str, Any]], + selected_index: int, + selected_url: str, + session: str, + target_path: str, + apikey: str, + ) -> Optional[Dict[str, Any]]: + candidates: List[Tuple[int, Dict[str, Any]]] = [] + for offset, raw_item in enumerate(items[selected_index:], start=selected_index + 1): + item = dict(raw_item or {}) + share_url = self._clean_text(item.get("url")) + if not share_url or not self._is_quark_url(share_url): + continue + if share_url == selected_url: + continue + candidates.append((offset, item)) + if len(candidates) >= 3: + break + + if not candidates: + return None + + for alt_index, alt_item in candidates: + alt_url = self._clean_text(alt_item.get("url")) + alt_code = self._clean_text(alt_item.get("password")) + alt_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": alt_url, + "access_code": alt_code, + "path": target_path, + "trigger": "Agent影视助手 盘搜夸克候选自动切换", + "apikey": apikey, + }) + ) + if alt_result.get("success"): + message = ( + f"原夸克链接已受限,已自动切换到同列表夸克候选 #{alt_index}。\n" + f"{str(alt_result.get('message') or '夸克转存成功')}" + ) + data = dict(alt_result.get("data") or {}) + data.update( + { + "provider": "quark", + "selected_choice": alt_index, + "auto_switched_from_choice": selected_index, + "auto_switched": True, + "selected_item": alt_item, + } + ) + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data=data), + } + + hint_choices = "、".join(f"#{idx}" for idx, _ in candidates) + return { + "success": False, + "message": ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + f"可改试同列表夸克候选 {hint_choices}。" + ), + "data": self._assistant_response_data( + session=session, + data={ + "provider": "quark", + "auto_switched": False, + "fallback_choices": [idx for idx, _ in candidates], + }, + ), + } + + def _assistant_followup_summary( + self, + *, + category: str, + stage: str, + recommended_action: str, + follow_up_hint: str, + next_actions: Optional[List[str]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + ) -> Dict[str, Any]: + action_names = [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)] + command_candidates: List[str] = [] + for name in action_names: + command = self._assistant_followup_command(name, keyword=keyword, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + label_map = { + "mp_download": "下载后追踪", + "mp_subscribe": "订阅后追踪", + "cloud_write": "云盘落库追踪", + "mp_diagnosis": "本地/PT 状态追踪", + "ai_reingest": "AI 二次识别重放追踪", + } + summary = { + "category": category, + "stage": self._clean_text(stage), + "label": label_map.get(category, category or "后续追踪"), + "preferred_action": self._clean_text(recommended_action), + "decision_hint": self._clean_text(follow_up_hint), + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + "next_actions": action_names[:4], + "action_templates_count": len([item for item in (action_templates or []) if isinstance(item, dict)]), + } + if category == "ai_reingest" and keyword: + summary.update({ + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "recommended_commands": ["诊断", "入库状态", "工作清单"], + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + }) + return summary + + @staticmethod + def _format_followup_summary_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + if not data: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(data.get("label")) + stage = AgentResourceOfficer._clean_text(data.get("stage")) + hint = AgentResourceOfficer._clean_text(data.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (data.get("compact_commands") or data.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"后续追踪:{label}{f' | {stage}' if stage else ''}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("可直接继续:" + " / ".join(commands)) + return lines + + @staticmethod + def _assistant_local_api_base_urls() -> List[str]: + return [ + "http://127.0.0.1:3001", + "http://127.0.0.1:3000", + "http://host.docker.internal:3000", + ] + + def _assistant_plugin_api_request( + self, + *, + plugin_name: str, + path: str, + query: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + method: str = "GET", + timeout: int = 15, + ) -> Dict[str, Any]: + token = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not token: + return {"success": False, "message": "服务端未配置 API Token"} + safe_path = "/" + self._clean_text(path).lstrip("/") + query_payload = dict(query or {}) + query_payload["apikey"] = token + last_error = "" + payload_bytes: Optional[bytes] = None + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + payload_bytes = json.dumps(dict(body or {}), ensure_ascii=False).encode("utf-8") + for base_url in self._assistant_local_api_base_urls(): + try: + url = f"{base_url}/api/v1/plugin/{plugin_name}{safe_path}?{urlencode(query_payload)}" + request = UrlRequest( + url=url, + data=payload_bytes, + headers=headers, + method=(method or "GET").upper(), + ) + with urlopen(request, timeout=max(5, self._safe_int(timeout, 15))) as response: + raw = response.read().decode("utf-8", errors="ignore") + payload = json.loads(raw or "{}") + if isinstance(payload, dict): + return payload + last_error = f"插件 {plugin_name} 返回了非字典 JSON" + except Exception as exc: + last_error = str(exc) + return { + "success": False, + "message": f"调用 {plugin_name}{safe_path} 失败:{last_error or '未知错误'}", + } + + @staticmethod + def _assistant_ai_recognizer_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + return running_plugins.get("AIRecognizerEnhancer") + + def _assistant_ai_failed_samples_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + return { + "success": True, + "data": { + "count": len(samples), + "samples": samples, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/failed_samples", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_worklist_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_summarize_sample"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + worklist = [plugin._summarize_sample(sample) for sample in samples] + return { + "success": True, + "data": { + "count": len(worklist), + "samples": worklist, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 工作清单失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_worklist", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_insights_payload(self, *, limit: int, top: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_build_sample_insights"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 200)))) + insights = plugin._build_sample_insights(samples, top=max(1, min(top, 20))) + return { + "success": True, + "data": insights, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 样本洞察失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_insights", + query={"limit": max(1, min(limit, 200)), "top": max(1, min(top, 20))}, + method="GET", + ) + + def _assistant_ai_replay_failed_sample_payload( + self, + *, + sample_index: int, + remove_if_resolved: bool, + ) -> Dict[str, Any]: + body = { + "sample_index": max(0, self._safe_int(sample_index, 0)), + "remove_if_resolved": bool(remove_if_resolved), + } + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_replay_failed_sample"): + try: + result = plugin._replay_failed_sample(body) + if isinstance(result, dict): + return result + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 重放 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/replay_failed_sample", + method="POST", + body=body, + ) + + def _assistant_ai_sample_by_index(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + for item in items: + current = item if isinstance(item, dict) else {} + if self._safe_int(current.get("sample_index"), 0) == desired: + return dict(current) + if self._safe_int(current.get("index"), 0) == desired: + return dict(current) + return {} + + @staticmethod + def _assistant_quarkdisk_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + plugin = running_plugins.get("QuarkDisk") or running_plugins.get("quarkdisk") + if plugin is not None: + return plugin + for candidate in running_plugins.values(): + if candidate is None: + continue + if candidate.__class__.__name__ == "QuarkDisk": + return candidate + if getattr(candidate, "plugin_name", "") == "夸克网盘存储": + return candidate + return None + + def _assistant_quarkdisk_clear_directory(self, path: str) -> Optional[Tuple[bool, Dict[str, Any], str]]: + plugin = self._assistant_quarkdisk_plugin() + if plugin is None or app_schemas is None: + return None + try: + storage_name = self._clean_text(getattr(plugin, "_disk_name", "")) or "夸克网盘" + clear_cache = getattr(plugin, "clear_cache", None) + + def _safe_clear_cache() -> None: + if callable(clear_cache): + try: + clear_cache() + except Exception: + pass + + def _resolve_folder() -> Any: + resolved = None + if hasattr(plugin, "get_folder"): + resolved = plugin.get_folder(storage=storage_name, path=Path(path)) + if resolved is None and hasattr(plugin, "get_file_item"): + resolved = plugin.get_file_item(storage=storage_name, path=Path(path)) + return resolved + + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 未定位到目录,改走直连夸克接口确认" + + items = [] + if hasattr(plugin, "list_files"): + items = plugin.list_files(folder, recursion=False) or [] + if not items: + _safe_clear_cache() + folder = _resolve_folder() or folder + items = plugin.list_files(folder, recursion=False) or [] + files = [item for item in items if getattr(item, "type", "") != "dir"] + folders = [item for item in items if getattr(item, "type", "") == "dir"] + if not items: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 返回空列表,改走直连夸克接口确认" + + removed_count = 0 + failed_items: List[str] = [] + for item in items: + ok = plugin.delete_file(item) if hasattr(plugin, "delete_file") else None + if ok: + removed_count += 1 + continue + item_path = self._clean_text(getattr(item, "path", "")) or self._clean_text(getattr(item, "name", "")) + if item_path: + failed_items.append(item_path) + result = { + "target_path": path, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self._clean_text(getattr(item, "name", "")) for item in items[:20] if self._clean_text(getattr(item, "name", ""))], + "failed_items": failed_items[:20], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + } + if failed_items: + return False, result, f"夸克网盘存储删除失败:{len(failed_items)} 项未删除" + return True, result, "success" + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 调用 quarkdisk 清空目录失败,改走直连夸克接口:{exc}") + return None + + def _assistant_ai_sample_reference(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + cached = self._assistant_ai_sample_by_index(cache_key=cache_key, sample_index=desired) + if cached: + return cached + for payload in ( + self._assistant_ai_failed_samples_payload(limit=1000), + self._assistant_ai_sample_worklist_payload(limit=1000), + ): + rows = [] + if isinstance((payload or {}).get("data"), dict): + rows = (payload.get("data") or {}).get("samples") or [] + if not isinstance(rows, list): + continue + for item in rows: + current = dict(item or {}) + if self._safe_int(current.get("sample_index") or current.get("index"), 0) == desired: + return current + return {} + + def _assistant_ai_replay_target_title(self, *, sample: Dict[str, Any], target: Dict[str, Any]) -> str: + current_sample = dict(sample or {}) + current_target = dict(target or {}) + return ( + self._clean_text(current_target.get("name")) + or self._clean_text(current_sample.get("verified_title")) + or self._clean_text(current_sample.get("guess_name")) + or self._clean_text(current_sample.get("title")) + or self._clean_text(current_sample.get("path")) + ) + + def _assistant_ai_sample_matches(self, sample: Dict[str, Any], keyword: str) -> bool: + needle = self._clean_text(keyword).lower() + if not needle: + return True + target = sample.get("inferred_target") if isinstance(sample.get("inferred_target"), dict) else {} + fields = [ + sample.get("title"), + sample.get("path"), + sample.get("reason"), + sample.get("guess_name"), + sample.get("verified_title"), + target.get("name"), + ] + for value in fields: + text = self._clean_text(value).lower() + if text and needle in text: + return True + return False + + def _assistant_ai_filtered_rows(self, rows: List[Dict[str, Any]], keyword: str) -> List[Dict[str, Any]]: + if not keyword: + return [dict(item or {}) for item in rows] + return [ + dict(item or {}) + for item in rows + if self._assistant_ai_sample_matches(dict(item or {}), keyword) + ] + + def _assistant_ai_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + "keyword": keyword, + "compact": True, + } + action_order = [ + "query_ai_sample_worklist", + "query_ai_sample_insights", + "query_ai_failed_samples", + "query_mp_local_diagnose", + ] + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_ai_sample_worklist": "查看 AI 识别失败样本工作清单,适合先挑目标", + "query_ai_sample_insights": "查看 AI 失败样本洞察,适合看重复样本和优先处理项", + "query_ai_failed_samples": "查看 AI 原始失败样本列表,适合核对标题、路径和失败原因", + "query_mp_local_diagnose": "回到本地/PT 诊断链,继续看入库失败阶段和证据", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": name}, + ) + for name in action_order + ] + return action_order, templates + + def _assistant_ai_sample_brief_lines( + self, + rows: List[Dict[str, Any]], + *, + limit: int = 5, + ) -> List[str]: + lines: List[str] = [] + for item in rows[: max(1, min(limit, 10))]: + label = ( + self._clean_text( + ((item.get("inferred_target") or {}).get("name")) + if isinstance(item.get("inferred_target"), dict) + else "" + ) + or self._clean_text(item.get("verified_title")) + or self._clean_text(item.get("guess_name")) + or self._clean_text(item.get("title")) + or "未命名样本" + ) + confidence = round(self._safe_float(item.get("guess_confidence"), 0.0), 2) + reason = self._clean_text(item.get("reason")) or "-" + lines.append( + f"{self._safe_int(item.get('sample_index'), 0)}. {label} | 置信度 {confidence} | {reason}" + ) + return lines + + def _assistant_mp_recent_activity_summary( + self, + *, + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> Dict[str, Any]: + evidence: List[str] = [] + for item in download_items[:3]: + evidence.append( + f"下载 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('transfer_status_text')) or '-'}" + ) + for item in transfer_items[:3]: + evidence.append( + f"入库 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('status_text')) or '-'}" + ) + return { + "status": "ok" if (download_items or transfer_items) else "not_found", + "matched": bool(download_items or transfer_items), + "stage": "unknown", + "confidence": 0.8 if (download_items or transfer_items) else 0.95, + "evidence": evidence[:6], + "risk_reasons": [], + "recommended_action": "query_mp_lifecycle_status", + "follow_up_hint": "先从最近活动里挑目标,再继续看单个资源的生命周期或本地诊断。", + } + + async def _assistant_mp_lifecycle_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(10, self._safe_int(limit, 5))) + channel = self._ensure_feishu_channel() + task_result = channel._query_download_tasks( + status="all", + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + download_result = channel._query_download_history( + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + transfer_result = channel._query_transfer_history( + title=title, + status="all", + limit=safe_limit, + ) + task_items = task_result.get("items") if isinstance(task_result.get("items"), list) else [] + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + keyword = title or hash_value or "全部" + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value=hash_value, + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value=hash_value, + preferred=self._clean_text(diagnosis_summary.get("recommended_action")) or "query_mp_download_history", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword=title, + hash_value=hash_value, + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = [f"MP 生命周期追踪:{keyword}"] + lines.append( + f"结论:{diagnosis_summary.get('stage') or 'unknown'} | " + f"置信度 {int(float(diagnosis_summary.get('confidence') or 0) * 100)}%" + ) + lines.append(f"活动下载任务:{len(task_items)} 条;下载历史:{download_result.get('total', len(download_items))} 条;整理历史:{transfer_result.get('total', len(transfer_items))} 条") + if task_items: + lines.append("下载任务:") + for item in task_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} | {item.get('progress') or '-'} | {item.get('state') or '-'} | Hash:{item.get('hash_short') or '-'}") + if download_items: + lines.append("下载历史:") + for item in download_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('date') or '-'} | {item.get('transfer_status_text') or '-'} | Hash:{item.get('download_hash_short') or '-'}") + if transfer_items: + lines.append("整理/入库历史:") + for item in transfer_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('status_text') or '-'} | {item.get('date') or '-'}") + if not task_items and not download_items and not transfer_items: + lines.append("未找到相关任务、下载历史或整理历史。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + lines.append("说明:这是只读聚合查询,用于判断资源处于搜索后、下载中、已下载还是已落库阶段。") + self._save_session(cache_key, { + "kind": "assistant_mp_lifecycle_status", + "stage": "lifecycle_status", + "keyword": keyword, + "items": { + "download_tasks": task_items, + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + ok = bool(task_result.get("success")) and bool(download_result.get("success")) and bool(transfer_result.get("success")) + return { + "success": ok, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_lifecycle_status", + "ok": ok, + "source_type": "moviepilot_lifecycle_status", + "title": title, + "hash": hash_value, + "download_tasks": { + "ok": bool(task_result.get("success")), + "total": self._safe_int(task_result.get("total"), len(task_items)), + "items": task_items, + }, + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_ingest_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + lines = [ + f"本地/PT 入库状态:{self._clean_text(title or hash_value) or '全部'}", + f"当前阶段:{stage}", + ] + if diagnosis_summary.get("evidence"): + lines.append("证据:") + for item in (diagnosis_summary.get("evidence") or [])[:5]: + lines.append(f"- {item}") + if diagnosis_summary.get("risk_reasons"): + lines.append("风险:") + for item in (diagnosis_summary.get("risk_reasons") or [])[:5]: + lines.append(f"- {item}") + lines.extend(self._format_followup_summary_lines(diagnosis_summary.get("followup_summary"))) + lifecycle_data["action"] = "mp_ingest_status" + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(lines), + "data": lifecycle_data, + } + + async def _assistant_mp_ingest_failures( + self, + *, + session: str, + cache_key: str, + title: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status="failed", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value="", + task_items=[], + download_items=[], + transfer_items=items, + force_failed=bool(items), + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value="", + preferred="query_mp_local_diagnose", + ) + lines = [f"本地/PT 失败线索:{self._clean_text(title) or '最近失败'}"] + if items: + for item in items[: min(len(items), 6)]: + lines.append( + f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | " + f"{item.get('status_text') or '-'} | {item.get('date') or '-'}" + ) + else: + lines.append("未找到匹配的整理/入库失败记录。") + self._save_session(cache_key, { + "kind": "assistant_mp_ingest_failures", + "stage": "ingest_failures", + "keyword": title or "failed", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_ingest_failures", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_failures", + "title": title, + "status": "failed", + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + "diagnosis_summary": diagnosis_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_ai_failed_samples( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_failed_samples_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="工作清单", + ) + lines = [f"AI 失败样本:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 失败样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_failed_samples", + "stage": "ai_failed_samples", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_failed_samples", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先看工作清单或样本洞察,再决定是否进入二次识别重放。", + }), + } + + async def _assistant_ai_sample_worklist( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_sample_worklist_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="样本洞察", + ) + lines = [f"AI 工作清单:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 工作清单样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_worklist", + "stage": "ai_sample_worklist", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_worklist", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_insights", + "follow_up_hint": "先看样本洞察确认哪些失败样本最值得重放或生成识别词。", + }), + } + + async def _assistant_ai_sample_insights( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 20, + top: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(100, self._safe_int(limit, 20))) + safe_top = max(1, min(10, self._safe_int(top, 5))) + result = self._assistant_ai_sample_insights_payload(limit=safe_limit, top=safe_top) + insights = dict((result.get("data") or {})) if isinstance(result.get("data"), dict) else {} + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + priority_samples = insights.get("priority_samples") if isinstance(insights.get("priority_samples"), list) else [] + decision_summary = self._assistant_ai_reingest_decision_summary( + items=priority_samples, + fallback_command="工作清单", + ) + lines = [f"AI 样本洞察:{self._clean_text(keyword) or '全局'}"] + total_count = self._safe_int(insights.get("total_count"), 0) + if total_count > 0: + lines.append(f"样本总数:{total_count}") + reason_counts = insights.get("reason_counts") if isinstance(insights.get("reason_counts"), list) else [] + if reason_counts: + lines.append("主要失败原因:") + for item in reason_counts[:safe_top]: + lines.append(f"- {self._clean_text(item.get('reason')) or '-'}:{self._safe_int(item.get('count'), 0)}") + repeated_groups = insights.get("repeated_groups") if isinstance(insights.get("repeated_groups"), list) else [] + if repeated_groups: + lines.append("重复出现的样本组:") + for item in repeated_groups[:safe_top]: + lines.append(f"- {self._clean_text(item.get('title')) or '-'}:{self._safe_int(item.get('count'), 0)}") + if priority_samples: + lines.append("优先处理样本:") + lines.extend(self._assistant_ai_sample_brief_lines(priority_samples, limit=safe_top)) + else: + lines.append("当前没有可分析的 AI 失败样本洞察。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_insights", + "stage": "ai_sample_insights", + "keyword": keyword or "all", + "items": insights, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_insights", + "ok": bool(result.get("success")), + "keyword": keyword, + "insights": insights, + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先挑优先样本,再确认是否进入 AI 二次识别重放。", + }), + } + + def _assistant_ai_replay_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + next_actions: List[str] = [ + "query_ai_sample_worklist", + "query_ai_failed_samples", + "query_ai_sample_insights", + ] + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + base_body = { + "session": session, + "session_id": session_id, + "compact": True, + } + templates: List[Dict[str, Any]] = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="继续查看这次二次识别相关的本地/PT 入库诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_local_diagnose", "keyword": keyword, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_ingest_status", "keyword": keyword, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续挑需要重放的失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,核对标题、路径和失败原因", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,判断失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + return next_actions, templates + + def _assistant_ai_reingest_decision_summary( + self, + *, + items: List[Dict[str, Any]], + fallback_command: str, + ) -> Dict[str, Any]: + fallback = self._clean_text(fallback_command) + first_index = 0 + for item in items or []: + first_index = self._safe_int((item or {}).get("index"), 0) + if first_index > 0: + break + if first_index <= 0: + return { + "decision_mode": "show_detail", + "decision_reason": "当前没有可直接重放的 AI 失败样本。", + "preferred_command": fallback, + "fallback_command": "", + "compact_commands": [fallback] if fallback else [], + "recommended_agent_behavior": "show_only" if fallback else "stop", + "auto_run_command": "", + "confirm_command": "", + "display_command": fallback, + } + replay_command = f"重放 {first_index}" + return { + "decision_mode": "make_plan", + "decision_reason": f"先为样本 #{first_index} 生成二次识别重放计划,再确认是否实际重放。", + "preferred_command": replay_command, + "fallback_command": fallback, + "compact_commands": [item for item in [replay_command, fallback] if item], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": replay_command, + "confirm_command": "确认", + "display_command": replay_command, + } + + def _assistant_ai_replay_sample_plan_response( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + reference = self._assistant_ai_sample_reference(cache_key=cache_key, sample_index=desired) + if not reference: + return { + "success": False, + "message": "未找到对应的 AI 失败样本。请先发送“工作清单”或“失败样本”查看当前样本编号。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "sample_not_found", + "sample_index": desired, + }), + } + title = self._assistant_ai_replay_target_title( + sample=reference, + target=(reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}), + ) + return self._save_assistant_pick_plan_response( + workflow="ai_replay_failed_sample", + session=session, + session_id=cache_key, + actions=[{ + "name": "replay_ai_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + }], + execute_body={ + "workflow": "ai_replay_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "dry_run": False, + }, + message="AI 二次识别重放计划已生成", + extra_data={ + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "source_sample": reference, + "target": reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}, + "keyword": title, + "decision_summary": { + "decision_mode": "execute_now", + "decision_reason": ( + "AI 二次识别重放计划已生成,确认后才会实际重放并尝试重新识别。" + if bool(remove_if_resolved) + else "AI 二次识别重放计划已生成,确认后会实际重放,但保留原失败样本。" + ), + "preferred_command": "确认", + "fallback_command": "工作清单", + "compact_commands": ["确认", "工作清单"], + "command_policy": "confirm_then_resume", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "wait_user_confirmation", + "auto_run_command": "", + "confirm_command": "确认", + "display_command": "确认", + }, + }, + ) + + def _assistant_ai_replay_execution_decision_summary( + self, + *, + ok: bool, + resolved: bool, + has_title: bool, + ) -> Dict[str, Any]: + if ok and resolved and has_title: + return { + "decision_mode": "show_detail", + "decision_reason": "这次二次识别已命中目标,下一步先看本地诊断确认是否已消除失败,再决定是否继续整理。", + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + } + if ok: + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放已完成,但暂未命中目标。先回看工作清单和样本洞察,再决定是否继续重放或补识别词。", + "preferred_command": "工作清单", + "fallback_command": "样本洞察", + "compact_commands": ["工作清单", "样本洞察"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "工作清单", + "confirm_command": "", + "display_command": "工作清单", + } + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放没有成功执行。先回看工作清单或失败样本,确认当前还能处理哪些样本。", + "preferred_command": "工作清单", + "fallback_command": "失败样本", + "compact_commands": ["工作清单", "失败样本"], + "command_policy": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + "auto_run_command": "", + "confirm_command": "", + "display_command": "工作清单", + } + + async def _assistant_ai_replay_failed_sample( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + result = self._assistant_ai_replay_failed_sample_payload( + sample_index=desired, + remove_if_resolved=bool(remove_if_resolved), + ) + ok = bool(result.get("success")) + payload = dict(result.get("data") or {}) if isinstance(result.get("data"), dict) else {} + source_sample = dict(payload.get("source_sample") or {}) if isinstance(payload.get("source_sample"), dict) else {} + target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} + title = self._assistant_ai_replay_target_title(sample=source_sample, target=target) + next_actions, action_templates = self._assistant_ai_replay_followups( + session=session, + session_id=cache_key, + keyword=title, + ) + if ok: + self._save_session(cache_key, { + "kind": "assistant_ai_replay", + "stage": "ai_replay_failed_sample", + "keyword": title or f"sample-{desired}", + "items": [source_sample] if source_sample else [], + "target_path": "", + "sample_index": desired, + "replay_result": { + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + }, + }) + lines = [f"AI 二次识别重放:样本 #{desired}"] + if title: + lines.append(f"目标:{title}") + if ok: + lines.append("结果:" + ("已命中目标并完成重放" if bool(payload.get("resolved")) else "已完成重放,但暂未命中目标")) + if payload.get("resolved_by_identifiers"): + lines.append("来源:当前识别词已直接命中目标。") + elif payload.get("resolved_by_recognizer"): + lines.append("来源:识别器重跑后命中了目标。") + if "sample_removed" in payload: + lines.append("样本移除:" + ("已移除" if payload.get("sample_removed") else "未移除")) + else: + lines.append(self._clean_text(result.get("message")) or "重放失败") + resolved = bool(payload.get("resolved")) + recommended_action = "query_mp_local_diagnose" if ok and resolved and title else "query_ai_sample_worklist" + follow_up_hint = ( + "先回到本地诊断或入库状态,确认这次重放是否已经消除失败。" + if ok and resolved and title + else "先看工作清单或失败样本,确认是否还有可重放样本。" + ) + decision_summary = self._assistant_ai_replay_execution_decision_summary( + ok=ok, + resolved=resolved, + has_title=bool(title), + ) + return { + "success": ok, + "message": "\n".join(line for line in lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": ok, + "sample_index": desired, + "source_sample": source_sample, + "target": target, + "identifier_preview": payload.get("identifier_preview") if isinstance(payload.get("identifier_preview"), dict) else {}, + "recognize_result": payload.get("recognize_result") if isinstance(payload.get("recognize_result"), dict) else {}, + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + "sample_removal_result": payload.get("sample_removal_result") if isinstance(payload.get("sample_removal_result"), dict) else {}, + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + "decision_summary": decision_summary, + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_recent_activity( + self, + *, + session: str, + cache_key: str, + limit: int = 10, + download_only: bool = False, + transfer_only: bool = False, + ) -> Dict[str, Any]: + safe_limit = max(1, min(20, self._safe_int(limit, 10))) + channel = self._ensure_feishu_channel() + download_result = {"success": True, "items": [], "total": 0} + transfer_result = {"success": True, "items": [], "total": 0} + if not transfer_only: + download_result = channel._query_download_history(title="", hash_value="", limit=safe_limit, page=1) + if not download_only: + transfer_result = channel._query_transfer_history(title="", status="all", limit=safe_limit, page=1) + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_recent_activity_summary( + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword="", + hash_value="", + preferred="query_mp_lifecycle_status", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword="", + hash_value="", + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = ["最近本地/PT 活动:"] + if download_items: + lines.append(f"最近下载:{len(download_items)} 条") + if transfer_items: + lines.append(f"最近入库:{len(transfer_items)} 条") + if not download_items and not transfer_items: + lines.append("当前没有可展示的最近下载或入库活动。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + self._save_session(cache_key, { + "kind": "assistant_mp_recent_activity", + "stage": "recent_activity", + "keyword": "recent_activity", + "items": { + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + return { + "success": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recent_activity", + "ok": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_local_diagnose( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + risk_reasons = diagnosis_summary.get("risk_reasons") or [] + evidence = diagnosis_summary.get("evidence") or [] + ai_worklist = await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=title, + limit=max(5, limit), + ) + ai_data = dict((ai_worklist or {}).get("data") or {}) + ai_items = ai_data.get("items") if isinstance(ai_data.get("items"), list) else [] + if ai_items: + evidence.append(f"AI 失败样本命中 {len(ai_items)} 条") + risk_reasons.append("存在可用于二次识别重放的 AI 失败样本") + message_lines = [ + f"本地诊断:{self._clean_text(title or hash_value) or '全部'}", + f"判断阶段:{stage}", + f"是否命中记录:{'是' if diagnosis_summary.get('matched') else '否'}", + ] + if evidence: + message_lines.append("诊断证据:") + for item in evidence[:6]: + message_lines.append(f"- {item}") + if risk_reasons: + message_lines.append("风险与失败线索:") + for item in risk_reasons[:6]: + message_lines.append(f"- {item}") + if ai_items: + message_lines.append("AI 失败样本:") + for item in self._assistant_ai_sample_brief_lines(ai_items, limit=3): + message_lines.append(f"- {item}") + if diagnosis_summary.get("follow_up_hint"): + message_lines.append(f"建议动作:{diagnosis_summary.get('follow_up_hint')}") + next_actions = [] + for name in lifecycle_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + for name in ai_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + action_templates = [] + for item in (lifecycle_data.get("action_templates") or []) + (ai_data.get("action_templates") or []): + if isinstance(item, dict): + action_templates.append(item) + diagnosis_summary["risk_reasons"] = risk_reasons[:6] + diagnosis_summary["evidence"] = evidence[:6] + lifecycle_data["diagnosis_summary"] = diagnosis_summary + lifecycle_data["action"] = "mp_local_diagnose" + lifecycle_data["ai_sample_worklist"] = { + "ok": bool(ai_data.get("ok")), + "count": self._safe_int(ai_data.get("count"), len(ai_items)), + "items": ai_items[:10], + } + lifecycle_data["next_actions"] = next_actions[:6] + lifecycle_data["action_templates"] = action_templates[:6] + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(message_lines), + "data": lifecycle_data, + } + + async def _assistant_mp_recommendations( + self, + *, + source: str = "tmdb_trending", + media_type: str = "all", + limit: int = 20, + session: str = "default", + cache_key: str = "", + ) -> Dict[str, Any]: + try: + from app.chain.recommend import RecommendChain + from app.schemas.types import MediaType, media_type_to_agent + except Exception as exc: + return { + "success": False, + "message": f"MP 推荐失败:当前环境缺少推荐依赖 {exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + max_limit = max(1, min(50, self._safe_int(limit, 20))) + source_name = self._clean_text(source) or "tmdb_trending" + media_type_name = self._clean_text(media_type) or "all" + chain = RecommendChain() + try: + def collect_items(raw_results: List[Dict[str, Any]], media_type_filter: str = "") -> List[Dict[str, Any]]: + current_media_type = media_type_filter or media_type_name + collected = [] + for raw_item in (raw_results or [])[:max_limit]: + if not isinstance(raw_item, dict): + continue + item_type = raw_item.get("type") + if current_media_type != "all": + enum_type = MediaType.from_agent(current_media_type) + agent_type = media_type_to_agent(item_type) + expected_types = {current_media_type} + if current_media_type == "movie": + expected_types.add("电影") + elif current_media_type == "tv": + expected_types.update({"电视剧", "剧集"}) + if enum_type and item_type != enum_type and agent_type not in expected_types: + continue + collected.append({ + "index": len(collected) + 1, + "title": raw_item.get("title"), + "year": raw_item.get("year"), + "type": media_type_to_agent(item_type), + "tmdb_id": raw_item.get("tmdb_id"), + "douban_id": raw_item.get("douban_id"), + "vote_average": raw_item.get("vote_average"), + "poster_path": raw_item.get("poster_path"), + "detail_link": raw_item.get("detail_link"), + }) + return collected + + results: List[Dict[str, Any]] = [] + if source_name == "tmdb_trending": + results = await chain.async_tmdb_trending(page=1) + elif source_name == "tmdb_movies": + results = await chain.async_tmdb_movies(page=1) + elif source_name == "tmdb_tvs": + results = await chain.async_tmdb_tvs(page=1) + elif source_name in {"douban_hot", "douban_movie_hot"}: + results = await chain.async_douban_movie_hot(page=1, count=max_limit) + if source_name == "douban_hot" and media_type_name in {"all", "tv"}: + results.extend(await chain.async_douban_tv_hot(page=1, count=max_limit)) + elif source_name == "douban_tv_hot": + results = await chain.async_douban_tv_hot(page=1, count=max_limit) + elif source_name == "douban_movie_showing": + results = await chain.async_douban_movie_showing(page=1, count=max_limit) + elif source_name == "douban_movie_top250": + results = await chain.async_douban_movie_top250(page=1, count=max_limit) + elif source_name == "douban_tv_animation": + results = await chain.async_douban_tv_animation(page=1, count=max_limit) + elif source_name == "bangumi_calendar": + results = await chain.async_bangumi_calendar(page=1, count=max_limit) + else: + return { + "success": False, + "message": f"不支持的推荐来源:{source_name}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + items = collect_items(results) + fallback_source = "" + if not items and source_name != "tmdb_trending": + fallback_source = "tmdb_trending" + fallback_media_type = media_type_name if media_type_name in {"movie", "tv"} else "all" + items = collect_items(await chain.async_tmdb_trending(page=1), fallback_media_type) + display_source = fallback_source or source_name + lines = [f"MP 热门推荐:{display_source},共 {len(items)} 条"] + if fallback_source: + lines.append(f"注:{source_name} 当前暂无结果,已自动回退 {fallback_source}。") + for item in items[:10]: + lines.append(f"{item.get('index')}. {item.get('title') or '-'} ({item.get('year') or '-'}) | {item.get('type') or '-'} | 评分 {item.get('vote_average') or '-'}") + lines.append("下一步:回复“选择 1 决策”进入统一资源决策。") + lines.append("如果已经明确意图,也可以直接发“选择 1 计划”或“选择 1 确认”;也支持直接回复“详情”“计划”“确认”,默认作用于当前榜单首项。") + lines.append("如果想走单源,也可以回复“选择 1”进入 MP 原生搜索,或“选择 1 影巢”“选择 1 盘搜”。") + decision_summary = self._assistant_mp_recommendation_decision_summary() + if cache_key: + self._save_session(cache_key, { + "kind": "assistant_mp_recommend", + "stage": "result", + "source": fallback_source or source_name, + "requested_source": source_name, + "media_type": media_type_name, + "keyword": "", + "items": items, + "target_path": "", + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": True, + "source_type": "moviepilot_recommendation", + "source": fallback_source or source_name, + "requested_source": source_name, + "fallback_source": fallback_source, + "media_type": media_type_name, + "items": items, + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }), + } + except Exception as exc: + logger.error(f"MP 推荐失败:{source_name} {exc}", exc_info=True) + return { + "success": False, + "message": f"MP 推荐失败:{exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + + def _format_mp_recommend_item_detail_text(self, item: Dict[str, Any]) -> str: + title = self._clean_text(item.get("title")) or "-" + year = self._clean_text(item.get("year")) or "-" + media_type = self._clean_text(item.get("type")) or "-" + tmdb_id = self._clean_text(item.get("tmdb_id")) or "-" + douban_id = self._clean_text(item.get("douban_id")) or "-" + vote = self._clean_text(item.get("vote_average")) or "-" + lines = [ + "MP 推荐条目详情", + f"标题:{title}", + f"年份:{year}", + f"类型:{media_type}", + f"评分:{vote}", + f"TMDB:{tmdb_id}", + f"豆瓣:{douban_id}", + "下一步:回复“决策”“计划”“确认”,或“盘搜”“影巢”“原生”。", + ] + return "\n".join(lines) + + def _assistant_mp_recommendation_decision_summary(self) -> Dict[str, Any]: + return { + "decision_mode": "show_detail", + "decision_reason": "推荐列表默认先看当前榜单首项详情,再决定是否生成计划或直接确认执行。", + "decision_hint": "当前推荐会话支持直接对首项继续:详情 / 决策 / 计划 / 确认,也支持切到盘搜 / 影巢 / 原生。", + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "show_only", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + } + + def _assistant_recommend_handoff_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + current_state = dict(state or {}) + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + selected_index = self._safe_int(handoff.get("selected_index"), 0) + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + return { + "source": self._clean_text(handoff.get("source")), + "requested_source": self._clean_text(handoff.get("requested_source")), + "media_type": self._clean_text(handoff.get("media_type")), + "selected_index": selected_index if selected_index > 0 else None, + "selected_title": self._clean_text(selected_item.get("title")), + "return_short_command": "回推荐", + "source_short_commands": { + "pansou": "盘搜", + "hdhive": "影巢", + "mp": "原生", + }, + } + + def _assistant_recommend_handoff_state( + self, + *, + source: str, + requested_source: str, + media_type: str, + selected_index: int, + selected_item: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "source": self._clean_text(source), + "requested_source": self._clean_text(requested_source or source), + "media_type": self._clean_text(media_type or "all"), + "selected_index": max(0, self._safe_int(selected_index, 0)), + "return_short_command": "回推荐", + } + if isinstance(selected_item, dict) and selected_item: + payload["selected_item"] = dict(selected_item) + return payload + + async def _assistant_restore_mp_recommendation_handoff( + self, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + limit: int = 20, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + result = await self._assistant_mp_recommendations( + source=self._clean_text(handoff.get("requested_source") or handoff.get("source") or "tmdb_trending"), + media_type=self._clean_text(handoff.get("media_type") or "all"), + limit=limit, + session=session, + cache_key=cache_key, + ) + if not result.get("success"): + return result + selected_index = max(0, self._safe_int(handoff.get("selected_index"), 0)) + if selected_index > 0: + refreshed_state = self._load_session(cache_key) or {} + items = refreshed_state.get("items") if isinstance(refreshed_state.get("items"), list) else [] + if selected_index <= len(items): + self._save_session(cache_key, { + **refreshed_state, + "selected_index": selected_index, + "selected_item": dict(items[selected_index - 1] or {}), + }) + message = str(result.get("message") or "").strip() + if selected_index > 0: + message = f"{message}\n已返回推荐列表,默认仍指向第 {selected_index} 项。".strip() + result["message"] = message + payload = dict(result.get("data") or {}) + if selected_index > 0: + payload["selected_index"] = selected_index + payload["return_short_command"] = "" + result["data"] = self._assistant_response_data(session=session, data=payload) + return result + + async def _assistant_switch_recommend_handoff_source( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可切换的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法切换源。请先发送:回推荐。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": self._clean_text(mode), + "keyword": keyword, + "media_type": media_type, + "recommend_handoff": dict(handoff), + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_run_recommend_source_compound( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + followup_action: str, + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + mode = self._clean_text(mode).lower() + followup_action = self._clean_text(followup_action) + kind = self._clean_text(state.get("kind")) + if mode not in {"pansou", "mp"}: + return { + "success": False, + "message": "当前只支持 盘搜/原生 的单条详情、计划、确认命令。", + "data": self._assistant_response_data(session=session, data={ + "action": "recommend_source_compound", + "ok": False, + "error_code": "unsupported_recommend_source_compound_mode", + }), + } + + if kind == "assistant_mp_recommend": + selected_index = max(1, self._safe_int(state.get("selected_index"), 0) or 1) + source_result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": selected_index, + "mode": mode, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if not source_result.get("success"): + return source_result + else: + kind_mode = { + "assistant_pansou": "pansou", + "assistant_mp": "mp", + "assistant_hdhive": "hdhive", + }.get(kind, "") + if kind_mode != mode: + source_result = await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=mode, + ) + if not source_result.get("success"): + return source_result + + return await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": 0, + "action": followup_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_route_recommend_handoff_to_smart_decision( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法回到统一资源决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": "smart_decision", + "keyword": keyword, + "media_type": media_type, + "origin": "mp_recommend", + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_confirm_recommend_handoff( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + pending_plan = self._find_workflow_plan( + session=session, + session_id=cache_key, + executed=False, + ) + handoff_state = dict(state or {}) + if pending_plan and not isinstance(handoff_state.get("recommend_handoff"), dict): + pending_handoff = pending_plan.get("recommend_handoff") if isinstance(pending_plan.get("recommend_handoff"), dict) else {} + if pending_handoff: + handoff_state["recommend_handoff"] = dict(pending_handoff) + if pending_plan: + result = await self.api_assistant_plan_execute(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "prefer_unexecuted": True, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + kind = self._clean_text(state.get("kind")) + if kind in {"assistant_pansou", "assistant_mp"}: + result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best_execute", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + return { + "success": False, + "message": "当前会话没有待确认计划。影巢候选阶段请先选择编号;如果想回统一搜索决策,请回复:决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "confirm_recommend_handoff", + "ok": False, + "error_code": "recommend_handoff_pending_plan_missing", + "preferred_command": "决策", + "fallback_command": "回推荐", + }), + } + + def _assistant_recommend_handoff_short_metadata( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + handoff = (state or {}).get("recommend_handoff") if isinstance((state or {}).get("recommend_handoff"), dict) else {} + source_short_commands = handoff.get("source_short_commands") if isinstance(handoff.get("source_short_commands"), dict) else {} + return { + "recommend_handoff": dict(handoff) if handoff else {}, + "return_short_command": self._clean_text(handoff.get("return_short_command") or "回推荐") if handoff else "", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": self._clean_text(source_short_commands.get("pansou") or "盘搜") if handoff else "", + "hdhive_short_command": self._clean_text(source_short_commands.get("hdhive") or "影巢") if handoff else "", + "mp_short_command": self._clean_text(source_short_commands.get("mp") or "原生") if handoff else "", + } + + def _assistant_recommend_handoff_plan_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "confirm", + "label": "待确认计划", + "decision_hint": "当前推荐条目已生成计划;先看详情,再决定是否确认执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "确认", + "fallback_command": "详情", + "compact_commands": ["确认", "详情"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_entry_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "source_entry", + "label": "已切入单源", + "decision_hint": "当前推荐条目已切入单源结果;先看详情,再决定是否生成计划。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_detail_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "detail", + "label": "已查看详情", + "decision_hint": "当前推荐条目详情已展开;可以先生成计划,确认无误后再执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": True, + "preferred_command": "计划", + "fallback_command": "确认", + "compact_commands": ["计划", "确认"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "计划", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_execute_failure_followup( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "category": "recommend_handoff", + "stage": "write_failed", + "label": "执行失败,建议换路", + "preferred_action": "return_to_smart_decision", + "decision_hint": "当前推荐条目的执行失败;优先回到统一资源决策,或回推荐后切换其它源。", + "command_policy": "safe_read_recovery", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "决策", + "fallback_command": meta.get("return_short_command") or "回推荐", + "compact_commands": ["决策", meta.get("return_short_command") or "回推荐"], + "recommended_commands": [ + "决策", + meta.get("return_short_command") or "回推荐", + meta.get("hdhive_short_command") or "影巢", + meta.get("mp_short_command") or "原生", + ], + "recommended_agent_behavior": "show_only", + **meta, + } + + def _persist_workflow_plans(self) -> None: + try: + items = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + )[:self._workflow_plan_limit] + self._workflow_plans = { + self._clean_text(item.get("plan_id")): item + for item in items + if self._clean_text(item.get("plan_id")) + } + self.save_data(key=self._workflow_plan_store_key, value=self._workflow_plans) + except Exception: + pass + + def _restore_workflow_plans(self) -> None: + try: + restored = self.get_data(self._workflow_plan_store_key) or {} + if isinstance(restored, dict): + self._workflow_plans = { + self._clean_text(plan_id): dict(payload) + for plan_id, payload in restored.items() + if self._clean_text(plan_id) and isinstance(payload, dict) + } + except Exception: + self._workflow_plans = {} + + def _save_workflow_plan( + self, + *, + workflow: str, + session: str, + session_id: str = "", + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + ) -> Dict[str, Any]: + plan_id = self._new_session_id("plan") + created_at = int(time.time()) + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plan = { + "plan_id": plan_id, + "workflow": self._clean_text(workflow), + "session": session_name, + "session_id": normalized_session_id, + "actions": [dict(item or {}) for item in (actions or [])], + "execute_body": dict(execute_body or {}), + "created_at": created_at, + "created_at_text": self._format_unix_time(created_at), + "executed_at": 0, + "executed_at_text": "", + "executed": False, + } + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + return dict(plan) + + def _save_assistant_pick_plan_response( + self, + *, + workflow: str, + session: str, + session_id: str, + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + message: str, + score_items: Optional[List[Dict[str, Any]]] = None, + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + plan = self._save_workflow_plan( + workflow=workflow, + session=session, + session_id=session_id, + actions=actions, + execute_body=execute_body, + ) + plan_id = self._clean_text(plan.get("plan_id")) + recommend_handoff = extra_data.get("recommend_handoff") if isinstance((extra_data or {}).get("recommend_handoff"), dict) else {} + if plan_id and recommend_handoff and isinstance(self._workflow_plans.get(plan_id), dict): + self._workflow_plans[plan_id]["recommend_handoff"] = dict(recommend_handoff) + self._persist_workflow_plans() + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": plan_id, + "session": session, + "session_id": session_id, + "prefer_unexecuted": True, + }, + ) + data = { + "action": "workflow_plan", + "ok": True, + "plan_id": plan_id, + "workflow": workflow, + "dry_run": True, + "workflow_actions": [dict(item or {}) for item in actions], + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan_id}, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "next_actions": ["execute_plan"], + "action_templates": [template], + "write_effect": "state", + } + if score_items: + data["score_summary"] = self._score_summary(score_items, limit=1) + if extra_data: + data.update(extra_data) + decision_summary = data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {} + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in decision_summary and key not in data: + data[key] = decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + choice = self._safe_int(data.get("choice"), 0) + confirm_hint = f"回复“执行计划”或“{choice}”确认执行。" if choice > 0 else "回复“执行计划”确认执行。" + return { + "success": True, + "message": f"{message}:{plan_id}\n未实际执行。{confirm_hint}", + "data": self._assistant_response_data(session=session, data=data), + } + + def _load_workflow_plan(self, plan_id: str) -> Optional[Dict[str, Any]]: + plan = (self._workflow_plans or {}).get(self._clean_text(plan_id)) + return dict(plan) if isinstance(plan, dict) else None + + def _find_workflow_plan( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + ) -> Optional[Dict[str, Any]]: + clean_plan_id = self._clean_text(plan_id) + if clean_plan_id: + return self._load_workflow_plan(clean_plan_id) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + for plan in plans: + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + return dict(plan) + return None + + def _find_pending_multi_plan( + self, + *, + session: str, + session_id: str, + rank: int = 0, + choice: int = 0, + group_id: str = "", + ) -> Optional[Dict[str, Any]]: + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + candidates: List[Dict[str, Any]] = [] + for plan in (self._workflow_plans or {}).values(): + if not isinstance(plan, dict) or bool(plan.get("executed")): + continue + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + plan_group = self._clean_text(plan.get("multi_plan_group") or execute_body.get("multi_plan_group")) + if group_id and plan_group != group_id: + continue + plan_rank = self._safe_int(plan.get("plan_rank") or execute_body.get("plan_rank"), 0) + plan_choice = self._safe_int(plan.get("plan_choice") or execute_body.get("choice") or execute_body.get("index"), 0) + if rank > 0 and plan_rank != rank: + continue + if choice > 0 and plan_choice != choice and plan_rank != choice: + continue + candidates.append(dict(plan)) + candidates.sort( + key=lambda item: ( + -self._safe_int(item.get("created_at"), 0), + self._safe_int(item.get("plan_rank") or (item.get("execute_body") or {}).get("plan_rank"), 999), + ) + ) + return candidates[0] if candidates else None + + def _workflow_plan_public_item(self, plan: Dict[str, Any], *, include_actions: bool = False) -> Dict[str, Any]: + current = dict(plan or {}) + actions = current.get("actions") or [] + item = { + "plan_id": self._clean_text(current.get("plan_id")), + "workflow": self._clean_text(current.get("workflow")), + "session": self._clean_text(current.get("session")), + "session_id": self._clean_text(current.get("session_id")), + "created_at": self._safe_int(current.get("created_at"), 0), + "created_at_text": self._clean_text(current.get("created_at_text")), + "executed": bool(current.get("executed")), + "executed_at": self._safe_int(current.get("executed_at"), 0), + "executed_at_text": self._clean_text(current.get("executed_at_text")), + "last_success": current.get("last_success"), + "last_message": self._clean_text(current.get("last_message")), + "action_count": len(actions) if isinstance(actions, list) else 0, + } + if include_actions: + item["actions"] = [dict(action or {}) for action in actions] if isinstance(actions, list) else [] + item["execute_body"] = dict(current.get("execute_body") or {}) + return item + + def _session_workflow_plan_public_data(self, *, session: str = "", session_id: str = "") -> Dict[str, Any]: + pending = self._find_workflow_plan(session=session, session_id=session_id, executed=False) + latest = pending or self._find_workflow_plan(session=session, session_id=session_id, executed=None) + if not latest: + return { + "has_plan": False, + "has_pending": False, + "latest": None, + } + return { + "has_plan": True, + "has_pending": bool(pending), + "latest": self._workflow_plan_public_item(latest, include_actions=False), + } + + def _assistant_plans_public_data( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + items: List[Dict[str, Any]] = [] + matching_total = 0 + for plan in plans: + if session_id_filter and self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + matching_total += 1 + if len(items) < max_limit: + items.append(self._workflow_plan_public_item(plan, include_actions=include_actions)) + return { + "total": matching_total, + "total_matching": matching_total, + "total_all": len(self._workflow_plans or {}), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "include_actions": bool(include_actions), + "items": items, + } + + def _format_assistant_plans_text( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> str: + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 保存计划。" + lines = [f"已保存计划:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "已执行" if item.get("executed") else "待执行" + line = ( + f"{index}. {item.get('plan_id') or '-'} | {status} | " + f"{item.get('workflow') or '-'} | {item.get('session') or '-'} | " + f"{item.get('action_count') or 0}步 | {item.get('created_at_text') or '-'}" + ) + if item.get("last_message"): + line = f"{line} | {item.get('last_message')}" + lines.append(line) + lines.append("下一步:可用 agent_resource_officer_execute_plan 执行 plan_id,或用 agent_resource_officer_plans_clear 清理。") + return "\n".join(lines) + + def _clear_workflow_plans( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + clean_plan_id = self._clean_text(plan_id) + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not any([clean_plan_id, session_id_filter, executed is not None, all_plans]): + return { + "ok": False, + "message": "请指定 plan_id、session/session_id、executed 过滤条件,或显式 all_plans=true", + "removed": 0, + "removed_ids": [], + } + removed_ids: List[str] = [] + for current_id, plan in list((self._workflow_plans or {}).items()): + if len(removed_ids) >= max_limit: + break + current = dict(plan or {}) + if clean_plan_id and self._clean_text(current_id) != clean_plan_id: + continue + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(current.get("executed")) != bool(executed): + continue + removed_ids.append(self._clean_text(current_id)) + for current_id in removed_ids: + self._workflow_plans.pop(current_id, None) + if removed_ids: + self._persist_workflow_plans() + return { + "ok": True, + "message": f"已清理 {len(removed_ids)} 条计划", + "removed": len(removed_ids), + "removed_ids": removed_ids, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "all_plans": bool(all_plans), + } + + def _record_assistant_execution( + self, + *, + action: str, + session: str = "default", + session_id: str = "", + success: bool = False, + message: str = "", + summary: Optional[Dict[str, Any]] = None, + ) -> None: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + entry = { + "id": self._new_session_id("exec"), + "time": int(time.time()), + "time_text": self._format_unix_time(int(time.time())), + "action": self._clean_text(action), + "session": session_name, + "session_id": normalized_session_id, + "success": bool(success), + "message_head": self._assistant_result_message_head(message), + "summary": dict(summary or {}), + } + self._execution_history.append(entry) + self._execution_history = self._execution_history[-self._execution_history_limit:] + self._persist_execution_history() + + def _assistant_history_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + items: List[Dict[str, Any]] = [] + for entry in reversed(self._execution_history or []): + current = dict(entry or {}) + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + items.append(current) + if len(items) >= max_limit: + break + return { + "total": len(self._execution_history or []), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "items": items, + } + + def _format_assistant_history_text( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> str: + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 执行历史。" + lines = [f"最近执行历史:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "成功" if item.get("success") else "失败" + line = f"{index}. {item.get('time_text') or '-'} | {status} | {item.get('action') or '-'} | {item.get('session') or '-'}" + if item.get("message_head"): + line = f"{line} | {item.get('message_head')}" + lines.append(line) + return "\n".join(lines) + + def _is_session_expired(self, payload: Optional[Dict[str, Any]]) -> bool: + session = dict(payload or {}) + updated_at = self._safe_int(session.get("updated_at"), 0) + if updated_at <= 0: + return False + return (int(time.time()) - updated_at) > self._session_retention_seconds + + @staticmethod + def _format_unix_time(value: Any) -> str: + try: + timestamp = int(value) + except Exception: + return "" + if timestamp <= 0: + return "" + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + except Exception: + return "" + + @staticmethod + def _group_resource_preview(items: List[Dict[str, Any]], per_group: Optional[int] = 6) -> List[Dict[str, Any]]: + groups: Dict[str, List[Dict[str, Any]]] = {"115": [], "quark": [], "other": []} + for item in items: + pan = str(item.get("pan_type") or "").lower() + if pan == "115": + key = "115" + elif pan == "quark": + key = "quark" + else: + key = "other" + if per_group is None or len(groups[key]) < per_group: + groups[key].append(item) + ordered = groups["115"] + groups["quark"] + if not ordered: + ordered = groups["other"] + preview: List[Dict[str, Any]] = [] + for index, item in enumerate(ordered, 1): + row = dict(item) + row["pick_index"] = index + preview.append(row) + return preview + + def _assistant_session_id(self, session: str) -> str: + session = self._clean_text(session) or "default" + return f"assistant::{session}" + + def _assistant_session_name_from_id(self, session_id: str) -> str: + clean_session_id = self._clean_text(session_id) + if clean_session_id.startswith("assistant::"): + return clean_session_id.split("assistant::", 1)[1] or "default" + return clean_session_id or "default" + + def _normalize_assistant_session_ref( + self, + *, + session: Any = None, + session_id: Any = None, + fallback: str = "default", + ) -> Tuple[str, str]: + clean_session_id = self._clean_text(session_id) + if clean_session_id: + session_name = self._assistant_session_name_from_id(clean_session_id) + return session_name, self._assistant_session_id(session_name) + session_name = self._clean_text(session) or fallback + return session_name, self._assistant_session_id(session_name) + + def _p115_status_snapshot(self) -> Dict[str, Any]: + health_ok, result, health_message = self._ensure_p115_service().health() + return { + "ready": health_ok, + "message": health_message or result.get("message") or "", + "direct_source": self._clean_text(result.get("direct_source")), + "helper_ready": bool(result.get("helper_ready")), + "client_type": self._p115_client_type, + "default_target_path": self._p115_default_path, + "cookie_mode": self._clean_text((result.get("cookie_state") or {}).get("mode")), + } + + def _format_p115_next_actions(self, status: Optional[Dict[str, Any]] = None) -> str: + current = dict(status or self._p115_status_snapshot()) + final_path = current.get("default_target_path") or self._p115_default_path + if current.get("ready"): + lines = [ + "下一步建议:", + f"1. 直接发:链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 也可以直接贴 115 链接,不写前缀也能识别", + "3. 搜资源可发:影巢搜索 片名", + "4. 外部搜资源可发:盘搜搜索 片名", + "5. 想复查登录状态可发:115状态", + ] + else: + lines = [ + "下一步建议:", + "1. 回复:115登录", + "2. 扫码确认后回复:检查115登录", + "3. 登录完成后可回复:115状态", + f"4. 然后可直接发 115 链接转存到 {final_path}", + "5. 也可以继续发:影巢搜索 片名", + ] + return "\n".join(lines) + + def _format_p115_transfer_failure( + self, + *, + detail: str = "", + target_path: str = "", + title: str = "115 转存失败", + ) -> str: + status = self._p115_status_snapshot() + final_path = target_path or status.get("default_target_path") or self._p115_default_path + clean_detail = self._clean_text(detail) + status_message = self._clean_text(status.get("message")) + lines = [title] + if clean_detail: + lines.append(f"原因:{clean_detail}") + if final_path: + lines.append(f"目标目录:{final_path}") + lines.append(f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}") + if status_message and status_message.lower() not in {"success", "ok"} and status_message != clean_detail: + lines.append(f"状态详情:{status_message}") + if status.get("ready"): + lines.append("建议:先回复 115状态 检查当前会话;如果还是失败,再回复 115登录 重新扫码。") + else: + lines.append("建议:先回复 115登录,扫码成功后再重试当前操作。") + return "\n".join(lines) + + @staticmethod + def _format_p115_resume_hint(title: str = "") -> str: + clean_title = str(title or "").strip() + prefix = f"已记住这次 115 任务({clean_title})。" if clean_title else "已记住这次 115 任务。" + return f"{prefix}\n登录成功后回复:检查115登录,我会自动继续处理。" + + def _save_pending_p115_share( + self, + session_id: str, + *, + share_url: str, + access_code: str = "", + target_path: str = "", + source: str = "", + title: str = "", + last_error: str = "", + ) -> None: + clean_url = self._clean_text(share_url) + if not clean_url: + return + state = self._load_session(session_id) or {} + previous = dict(state.get("pending_p115") or {}) + now = int(time.time()) + state["pending_p115"] = { + "kind": "share_route", + "share_url": clean_url, + "access_code": self._clean_text(access_code), + "target_path": target_path or self._p115_default_path, + "source": self._clean_text(source), + "title": self._clean_text(title), + "created_at": self._safe_int(previous.get("created_at"), now) or now, + "last_attempt_at": now, + "retry_count": max(0, self._safe_int(previous.get("retry_count"), 0)), + "last_error": self._clean_text(last_error) or self._clean_text(previous.get("last_error")), + } + if not state.get("kind"): + state["kind"] = "assistant_p115_pending" + state["stage"] = "pending_login" + self._save_session(session_id, state) + + def _clear_pending_p115_share(self, session_id: str) -> None: + state = self._load_session(session_id) + if not state or "pending_p115" not in state: + return + state.pop("pending_p115", None) + self._save_session(session_id, state) + + def _pending_p115_summary(self, state: Optional[Dict[str, Any]]) -> str: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return "" + title = self._clean_text(pending.get("title")) or "未命名任务" + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + source = self._clean_text(pending.get("source")) or "unknown" + created_at = self._format_unix_time(pending.get("created_at")) + last_attempt_at = self._format_unix_time(pending.get("last_attempt_at")) + retry_count = max(0, self._safe_int(pending.get("retry_count"), 0)) + last_error = self._clean_text(pending.get("last_error")) + lines = [ + "待继续的 115 任务:", + f"资源:{title}", + f"目录:{target_path}", + f"来源:{source}", + ] + if created_at: + lines.append(f"首次记录:{created_at}") + if last_attempt_at: + lines.append(f"最近尝试:{last_attempt_at}") + if retry_count: + lines.append(f"重试次数:{retry_count}") + if last_error: + lines.append(f"最近错误:{last_error}") + lines.append("可用命令:继续115任务 / 取消115任务") + return "\n".join(lines) + + def _pending_p115_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + pending = dict((state or {}).get("pending_p115") or {}) + if not self._clean_text(pending.get("share_url")): + return {"has_pending": False} + return { + "has_pending": True, + "title": self._clean_text(pending.get("title")) or "未命名任务", + "target_path": self._clean_text(pending.get("target_path")) or self._p115_default_path, + "source": self._clean_text(pending.get("source")) or "unknown", + "created_at": self._safe_int(pending.get("created_at"), 0), + "created_at_text": self._format_unix_time(pending.get("created_at")), + "last_attempt_at": self._safe_int(pending.get("last_attempt_at"), 0), + "last_attempt_at_text": self._format_unix_time(pending.get("last_attempt_at")), + "retry_count": max(0, self._safe_int(pending.get("retry_count"), 0)), + "last_error": self._clean_text(pending.get("last_error")), + } + + @staticmethod + def _assistant_find_action_template( + templates: Optional[List[Dict[str, Any]]], + names: List[str], + ) -> Optional[Dict[str, Any]]: + rows = [dict(item or {}) for item in (templates or []) if isinstance(item, dict)] + for current_name in names: + for item in rows: + if str(item.get("name") or "").strip() == current_name: + return item + return None + + def _assistant_recovery_public_data( + self, + *, + session_state: Optional[Dict[str, Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + state = dict(session_state or {}) + templates = [dict(item or {}) for item in (action_templates or state.get("action_templates") or []) if isinstance(item, dict)] + saved_plan = dict(state.get("saved_plan") or {}) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id") or saved_plan.get("plan_id")) + latest_plan_executed = bool(latest_plan.get("executed")) + pending_p115 = dict(state.get("pending_p115") or {}) + has_session = bool(state.get("has_session")) + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + session_name = self._clean_text(state.get("session")) or self._assistant_session_name_from_id(self._clean_text(state.get("session_id"))) + session_id = self._clean_text(state.get("session_id")) + mode = "" + reason = "" + template: Optional[Dict[str, Any]] = None + + if saved_plan.get("has_pending"): + mode = "resume_saved_plan" + reason = "当前会话存在待执行计划" + template = self._assistant_find_action_template(templates, [ + "execute_latest_plan", + "execute_plan", + "execute_session_latest_plan", + ]) + elif latest_plan_executed and latest_plan_id: + mode = "followup_executed_plan" + reason = "当前会话最近一条计划已执行,建议先做统一后续追踪" + template = self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动追踪下载、订阅或入库后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **({"session": session_name} if session_name else {}), + **({"session_id": session_id} if session_id else {}), + "plan_id": latest_plan_id, + }, + ) + elif pending_p115.get("has_pending"): + mode = "resume_pending_115" + reason = "当前会话存在待继续的 115 任务" + template = self._assistant_find_action_template(templates, [ + "resume_pending_115", + "check_115_login", + ]) + elif has_session and kind == "assistant_pansou": + mode = "continue_pansou" + reason = "当前会话停留在盘搜结果列表" + template = self._assistant_find_action_template(templates, ["pick_pansou_result"]) + elif has_session and kind == "assistant_mp_candidate": + mode = "continue_mp_candidate" + reason = "当前会话停留在 MP 媒体候选列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp": + mode = "continue_mp_search" + reason = "当前会话停留在 MP 原生搜索结果列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_best_result_detail", + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp_recommend": + mode = "continue_mp_recommend" + reason = "当前会话停留在 MP 热门推荐列表" + template = self._assistant_find_action_template(templates, [ + "pick_recommend_smart_decision", + "pick_recommend_smart_plan", + "pick_recommend_mp_search", + ]) + elif has_session and kind == "assistant_mp_download_tasks": + mode = "continue_mp_download_tasks" + reason = "当前会话停留在 MP 下载任务列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "pause_mp_download", + "resume_mp_download", + "delete_mp_download", + ]) + elif has_session and kind == "assistant_mp_download_history": + mode = "continue_mp_download_history" + reason = "当前会话停留在 MP 下载历史" + template = self._assistant_find_action_template(templates, [ + "query_mp_lifecycle_status", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_downloaders": + mode = "continue_mp_downloaders" + reason = "当前会话停留在 MP 下载器状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_sites", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_sites": + mode = "continue_mp_sites" + reason = "当前会话停留在 MP 站点状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_downloaders", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_subscribes": + mode = "continue_mp_subscribes" + reason = "当前会话停留在 MP 订阅列表" + template = self._assistant_find_action_template(templates, [ + "start_mp_subscribe", + "search_mp_subscribe", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_lifecycle_status": + mode = "continue_mp_lifecycle_status" + reason = "当前会话停留在 MP 生命周期追踪" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_hdhive" and stage == "candidate": + mode = "continue_hdhive_candidate" + reason = "当前会话停留在影巢候选列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_candidate"]) + elif has_session and kind == "assistant_hdhive" and stage == "resource": + mode = "continue_hdhive_resource" + reason = "当前会话停留在影巢资源列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_resource"]) + elif has_session and kind == "assistant_p115_login": + mode = "continue_115_login" + reason = "当前会话停留在 115 登录检查阶段" + template = self._assistant_find_action_template(templates, ["check_115_login", "show_115_status"]) + elif self._assistant_find_action_template(templates, ["preferences_save"]): + mode = "onboard_preferences" + reason = "智能体片源偏好未初始化,建议先询问并保存用户偏好" + template = self._assistant_find_action_template(templates, ["preferences_save"]) + else: + mode = "start_new" + reason = "当前没有待恢复的执行状态,可直接开始新任务" + template = self._assistant_find_action_template(templates, [ + "start_pansou_search", + "start_hdhive_search", + "start_115_login", + ]) + + can_resume = mode != "start_new" and bool(template) + return { + "mode": mode, + "reason": reason, + "can_resume": can_resume, + "recommended_action": self._clean_text((template or {}).get("name")), + "recommended_tool": self._clean_text((template or {}).get("tool")), + "action_template": template or None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in templates[:6] + if self._clean_text(item.get("name")) + ], + } + + def _assistant_session_public_data(self, session: str = "default") -> Dict[str, Any]: + session_name = self._clean_text(session) or "default" + session_id = self._assistant_session_id(session_name) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + state = self._load_session(session_id) or {} + if not state: + payload = { + "has_session": False, + "session": session_name, + "session_id": session_id, + "saved_plan": saved_plan, + "suggested_actions": ["execute_plan.session", "smart_entry"] if saved_plan.get("has_pending") else ["smart_entry"], + } + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + target_path = self._clean_text(state.get("target_path")) + payload: Dict[str, Any] = { + "has_session": True, + "session": session_name, + "session_id": session_id, + "kind": kind, + "stage": stage, + "updated_at": self._safe_int(state.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(state.get("updated_at")), + "target_path": target_path, + "keyword": self._clean_text(state.get("keyword")), + "media_type": self._clean_text(state.get("media_type")), + "year": self._clean_text(state.get("year")), + "pending_p115": self._pending_p115_public_data(state), + "saved_plan": saved_plan, + "suggested_actions": [], + } + + if kind == "assistant_pansou": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "channel": self._clean_text(item.get("channel")), + "title": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + } + for idx, item in enumerate(items[:6]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["smart_pick.choice", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=影巢", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(((item.get("torrent_info") or {}).get("title")) or item.get("title")), + "site": self._clean_text((item.get("torrent_info") or {}).get("site_name")), + "seeders": (item.get("torrent_info") or {}).get("seeders"), + "volume_factor": self._clean_text((item.get("torrent_info") or {}).get("volume_factor")), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["mp_download.choice", "mp_subscribe.keyword", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=盘搜", + "smart_entry.text=影巢", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + payload.update({ + "stage": "candidate", + "candidate_count": len(candidates), + "page": self._safe_int(state.get("page"), 1), + "page_size": self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size), + "candidates_preview": [ + { + "index": idx + 1, + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "tmdb_id": item.get("tmdb_id"), + } + for idx, item in enumerate(candidates[:10]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + elif kind == "assistant_mp_download_tasks": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "hash_short": self._clean_text(item.get("hash_short")), + "downloader": self._clean_text(item.get("downloader")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_download_control.pause", "mp_download_control.resume", "mp_download_control.delete", "session_clear"] + if items else + ["mp_media_search", "mp_download_history", "session_clear"] + ), + }) + elif kind == "assistant_mp_download_history": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + "download_hash_short": self._clean_text(item.get("download_hash_short")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_lifecycle_status", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_downloaders": + items = state.get("items") or [] + payload.update({ + "enabled_count": self._safe_int(state.get("enabled_count"), 0), + "result_count": len(items), + "items_preview": [ + { + "name": self._clean_text(item.get("name")), + "type": self._clean_text(item.get("type")), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + } + for item in items[:8] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_sites", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_sites": + items = state.get("items") or [] + payload.update({ + "status": self._clean_text(state.get("status")), + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "name": self._clean_text(item.get("name")), + "domain": self._clean_text(item.get("domain")), + "enabled": bool(item.get("enabled")), + "has_cookie": bool(item.get("has_cookie")), + "priority": item.get("priority"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_downloaders", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_subscribes": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "id": self._safe_int(item.get("id"), 0), + "title": self._clean_text(item.get("name")), + "year": self._clean_text(item.get("year")), + "state": self._clean_text(item.get("state")), + "lack_episode": item.get("lack_episode"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_subscribe_control.search", "mp_subscribe_control.pause", "mp_subscribe_control.resume", "mp_subscribe_control.delete", "session_clear"] + if items else + ["mp_subscribe.keyword", "mp_media_search", "session_clear"] + ), + }) + elif kind == "assistant_mp_lifecycle_status": + result_groups = state.get("items") if isinstance(state.get("items"), dict) else {} + task_items = result_groups.get("download_tasks") if isinstance(result_groups.get("download_tasks"), list) else [] + download_items = result_groups.get("download_history") if isinstance(result_groups.get("download_history"), list) else [] + transfer_items = result_groups.get("transfer_history") if isinstance(result_groups.get("transfer_history"), list) else [] + payload.update({ + "download_task_count": len(task_items), + "download_history_count": len(download_items), + "transfer_history_count": len(transfer_items), + "items_preview": [ + { + "kind": "download_task", + "title": self._clean_text(item.get("title")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for item in task_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "download_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + } + for item in download_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "transfer_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "status_text": self._clean_text(item.get("status_text")), + } + for item in transfer_items[:3] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_media_search", "mp_download_history", "session_clear"], + }) + elif kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + payload.update({ + "source": self._clean_text(state.get("source")), + "result_count": len(items), + "selected_index": selected_index if selected_index > 0 else None, + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "type": self._clean_text(item.get("type")), + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "douban_id": self._clean_text(item.get("douban_id")), + "vote_average": item.get("vote_average"), + } + for idx, item in enumerate(items[:10]) + if isinstance(item, dict) + ], + "suggested_actions": [ + "smart_pick.choice", + "smart_entry.text=电影", + "smart_entry.text=电视剧", + "smart_entry.text=豆瓣", + "smart_entry.text=热映", + "smart_entry.text=番剧", + "smart_pick.choice mode=hdhive", + "smart_pick.choice mode=pansou", + "session_clear", + ], + }) + if selected_index > 0: + payload["selected_item"] = dict(state.get("selected_item") or {}) + payload["suggested_actions"] = [ + *list(payload.get("suggested_actions") or []), + "smart_entry.text=详情", + "smart_entry.text=决策", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=原生", + "smart_entry.text=影巢", + "smart_entry.text=盘搜", + ] + elif kind == "assistant_update_check": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("_index") or item.get("index"), idx + 1), + "source_type": self._clean_text(item.get("_update_source")), + "title": self._clean_text(item.get("note") or item.get("title") or item.get("remark")), + "provider": self._clean_text(item.get("channel") or item.get("pan_type")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=计划", "session_clear"], + }) + elif kind == "assistant_hdhive": + payload["recommend_handoff"] = self._assistant_recommend_handoff_public_data(state) + if stage == "candidate": + candidates = state.get("candidates") or [] + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "page": current_page, + "page_size": page_size, + "total_candidates": len(candidates), + "total_pages": total_pages, + "candidates_preview": [ + { + "index": start + idx + 1, + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type")), + "actors": item.get("actors") or [], + } + for idx, item in enumerate(candidates[start:end]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif stage == "resource": + resources = state.get("resources") or [] + selected_candidate = dict(state.get("selected_candidate") or {}) + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "selected_candidate": { + "tmdb_id": self._clean_text(selected_candidate.get("tmdb_id")), + "title": self._clean_text(selected_candidate.get("title")), + "year": self._clean_text(selected_candidate.get("year")), + "media_type": self._clean_text(selected_candidate.get("media_type")), + "actors": selected_candidate.get("actors") or [], + }, + "page": current_page, + "page_size": page_size, + "total_resources": len(resources), + "total_pages": total_pages, + "resource_count_115": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "115"]), + "resource_count_quark": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "quark"]), + "resources_preview": [ + { + "index": self._safe_int(item.get("pick_index"), idx + 1), + "provider": self._clean_text(item.get("pan_type")), + "title": self._clean_text(item.get("title") or item.get("matched_title")), + "points": item.get("unlock_points"), + "points_text": self._resource_points_text(item), + "quality": self._clean_text(self._list_text(item.get("video_resolution")) or item.get("quality")), + "source": self._clean_text(self._list_text(item.get("source"))), + "size": self._clean_text(item.get("share_size") or item.get("size")), + "episodes": self._resource_episode_text(item), + "subtitle": self._resource_subtitle_text(item), + "remark": self._resource_remark_text(item), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(resources[start:end], start=start + 1) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(resources, limit=5), + "suggested_actions": ["smart_pick.choice", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_p115_login": + payload.update({ + "client_type": self._clean_text(state.get("client_type")) or self._p115_client_type, + "has_qrcode_session": bool(self._clean_text(state.get("uid")) and self._clean_text(state.get("time")) and self._clean_text(state.get("sign"))), + "suggested_actions": ["smart_entry.text=检查115登录", "p115_status", "session_clear"], + }) + else: + payload["suggested_actions"] = ["smart_entry", "session_clear"] + if saved_plan.get("has_pending"): + payload["suggested_actions"] = ["execute_plan.session", *list(payload.get("suggested_actions") or [])] + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + def _assistant_session_brief_public_data(self, session_id: str, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(state or {}) + name = str(session_id or "") + session_name = name.split("assistant::", 1)[1] if name.startswith("assistant::") else name or "default" + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=name) + result: Dict[str, Any] = { + "session": session_name, + "session_id": name or self._assistant_session_id(session_name), + "kind": self._clean_text(payload.get("kind")), + "stage": self._clean_text(payload.get("stage")), + "updated_at": self._safe_int(payload.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(payload.get("updated_at")), + "keyword": self._clean_text(payload.get("keyword")), + "target_path": self._clean_text(payload.get("target_path")), + "has_pending_p115": bool(self._clean_text(((payload.get("pending_p115") or {}).get("share_url")))), + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": saved_plan.get("latest"), + } + if result["kind"] == "assistant_pansou": + result["result_count"] = len(payload.get("items") or []) + elif result["kind"] == "assistant_mp_recommend": + result["result_count"] = len(payload.get("items") or []) + result["source"] = self._clean_text(payload.get("source")) + elif result["kind"] == "assistant_hdhive": + if result["stage"] == "candidate": + candidates = payload.get("candidates") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + result["total_candidates"] = len(candidates) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["stage"] == "resource": + selected = dict(payload.get("selected_candidate") or {}) + resources = payload.get("resources") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), 10)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + result["selected_title"] = self._clean_text(selected.get("title")) + result["selected_year"] = self._clean_text(selected.get("year")) + result["total_resources"] = len(resources) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["kind"] == "assistant_p115_login": + result["client_type"] = self._clean_text(payload.get("client_type")) or self._p115_client_type + result["recovery"] = self._assistant_recovery_public_data( + session_state={ + **result, + "pending_p115": self._pending_p115_public_data(payload), + "saved_plan": saved_plan, + }, + action_templates=[ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": result.get("session_id"), "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": result.get("session_id")}, + ), + ], + ) + return result + + def _assistant_plan_only_session_brief_public_data(self, session_id: str) -> Dict[str, Any]: + session_name = self._assistant_session_name_from_id(session_id) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + latest = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest.get("plan_id")) + latest_executed = bool(latest.get("executed")) + return { + "session": session_name, + "session_id": self._assistant_session_id(session_name), + "kind": "assistant_workflow_plan", + "stage": "planned", + "updated_at": self._safe_int(latest.get("created_at"), 0), + "updated_at_text": self._clean_text(latest.get("created_at_text")), + "keyword": "", + "target_path": "", + "has_pending_p115": False, + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": latest or None, + "recovery": { + "mode": "resume_saved_plan" if saved_plan.get("has_pending") else "followup_executed_plan" if latest_executed and latest_plan_id else "inspect_plan_only_session", + "reason": "当前会话只有 dry_run 计划,尚未生成交互会话缓存", + "can_resume": bool(saved_plan.get("has_pending") or (latest_executed and latest_plan_id)), + "recommended_action": "execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + "recommended_tool": "agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + "action_template": self._assistant_action_template( + name="execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + description="按 session_id 执行该会话最近一条待执行计划" if saved_plan.get("has_pending") else "按最近已执行计划自动追踪下载、订阅或入库后续状态" if latest_executed and latest_plan_id else "查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute" if saved_plan.get("has_pending") else "/api/v1/plugin/AgentResourceOfficer/assistant/action" if latest_executed and latest_plan_id else "/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True} if saved_plan.get("has_pending") else {"session_id": self._assistant_session_id(session_name), "name": "query_execution_followup", "plan_id": latest_plan_id} if latest_executed and latest_plan_id else {"session_id": self._assistant_session_id(session_name)}, + ), + "alternatives": ["execute_session_latest_plan", "inspect_session"] if saved_plan.get("has_pending") else ["query_execution_followup", "inspect_session"] if latest_executed and latest_plan_id else ["inspect_session"], + }, + } + + @staticmethod + def _assistant_action_template( + *, + name: str, + description: str, + endpoint: str, + body: Dict[str, Any], + method: str = "POST", + tool: str = "", + ) -> Dict[str, Any]: + body_payload = dict(body or {}) + compact_paths = [ + "/assistant/action", + "/assistant/actions", + "/assistant/workflow", + "/assistant/plan/execute", + "/assistant/recover", + "/assistant/route", + "/assistant/pick", + "/assistant/session", + "/assistant/sessions", + "/assistant/history", + "/assistant/plans", + "/assistant/readiness", + "/assistant/capabilities", + ] + if "compact" not in body_payload and any(path in endpoint for path in compact_paths): + body_payload["compact"] = True + action_body: Dict[str, Any] = {"name": name} + for key in [ + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "status", + "hash", + "target", + "control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "top", + "page", + "plan_id", + "prefer_unexecuted", + "preferences", + "compact", + "mode", + "source", + ]: + if key in body_payload: + action_body[key] = body_payload.get(key) + return { + "name": name, + "description": description, + "endpoint": endpoint, + "method": method, + "tool": tool, + "body": body_payload, + "action_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "action_tool": "agent_resource_officer_execute_action", + "action_body": action_body, + } + + @staticmethod + def _assistant_compact_action_templates( + primary: Optional[Dict[str, Any]] = None, + templates: Optional[List[Dict[str, Any]]] = None, + *, + limit: int = 6, + ) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + seen: set[str] = set() + for item in [primary, *(templates or [])]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(dict(item)) + if len(result) >= max(1, limit): + break + return result + + @staticmethod + def _assistant_compact_next_actions( + primary: Optional[List[Any]] = None, + secondary: Optional[List[Any]] = None, + *, + limit: int = 6, + ) -> List[str]: + result: List[str] = [] + seen: set[str] = set() + for item in [*(primary or []), *(secondary or [])]: + name = str(item or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(name) + if len(result) >= max(1, limit): + break + return result + + def _assistant_action_templates(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: + session_name = self._clean_text(data.get("session")) or "default" + session_id = self._clean_text(data.get("session_id")) or self._assistant_session_id(session_name) + base_route = { + "session": session_name, + "session_id": session_id, + } + base_pick = { + "session": session_name, + "session_id": session_id, + } + base_state = { + "session": session_name, + "session_id": session_id, + } + templates: List[Dict[str, Any]] = [] + preference_status = self._assistant_preferences_status_brief(session=session_name) + preference_template = self._assistant_action_template( + name="preferences_save", + description="保存智能体片源偏好;首次接入建议先询问用户后再保存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + tool="agent_resource_officer_preferences", + body={**base_state, "preferences": self._assistant_default_preferences_template()}, + ) + + if not data.get("has_session"): + templates = [] + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + saved_plan = dict(data.get("saved_plan") or {}) + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + templates.extend([ + self._assistant_action_template( + name="start_pansou_search", + description="发起新的盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "pansou", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="start_hdhive_search", + description="发起新的影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "hdhive", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="query_mp_media_detail", + description="使用 MoviePilot 原生识别确认媒体信息和 TMDB/Douban/IMDB ID", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_media_detail", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_recommendations", + description="查看 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "start_mp_recommendations", "source": "tmdb_trending", "media_type": "all"}, + ), + self._assistant_action_template( + name="query_mp_download_tasks", + description="查看 MP 下载任务状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_tasks", "status": "downloading"}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看 MP 下载历史,并关联整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="聚合查看 MP 下载任务、下载历史和整理/入库历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_lifecycle_status", "keyword": "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="query_mp_sites", + description="查看 MP 站点启用状态和 Cookie 是否存在,不返回 Cookie 明文", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看 MP 订阅列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_subscribes", "status": "all", "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看 MP 最近整理/入库历史,用于判断下载后是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_transfer_history", "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="start_115_login", + description="发起新的 115 扫码登录", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_start"}, + ), + ]) + return templates + + kind = self._clean_text(data.get("kind")) + stage = self._clean_text(data.get("stage")) + pending = dict(data.get("pending_p115") or {}) + saved_plan = dict(data.get("saved_plan") or {}) + target_path = self._clean_text(data.get("target_path")) + + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + + templates.append( + self._assistant_action_template( + name="inspect_session_state", + description="重新获取当前会话详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body=base_state, + ) + ) + + if kind == "assistant_pansou": + templates.extend([ + self._assistant_action_template( + name="pick_pansou_result", + description="按编号选择盘搜结果继续转存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._p115_default_path}, + ), + self._assistant_action_template( + name="plan_pansou_result", + description="按编号生成盘搜转存计划,不立即写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._p115_default_path}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前盘搜结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_pansou_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + self._assistant_action_template( + name="handoff_pansou_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_detail", + description="查看当前盘搜首选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_plan", + description="为当前盘搜首选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_pansou_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前盘搜首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_pansou_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_mp": + templates.extend([ + self._assistant_action_template( + name="query_mp_best_result_detail", + description="查看当前 MP 搜索结果里评分最高的 PT 候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_best_result_detail"}, + ), + self._assistant_action_template( + name="pick_mp_best_download", + description="按当前评分最高的 MP 搜索结果生成下载计划;不会静默下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_best_download"}, + ), + self._assistant_action_template( + name="query_mp_search_result_detail", + description="按编号查看 MP 原生搜索结果详情和 PT 评分理由", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_search_result_detail", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="pick_mp_download", + description="按编号为 MP 原生搜索结果生成下载计划;不会立即下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_download", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe", + description="按当前关键词生成 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe_search", + description="按当前关键词生成“订阅并搜索”计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe_search", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前原生搜索结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_mp_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_mp_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_mp_best_detail", + description="查看当前 MP 搜索里评分最高的候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_mp_best_plan", + description="为当前 MP 搜索里评分最高的候选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_mp_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前 MP 首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_mp_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + ]) + elif kind == "assistant_mp_download_tasks": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="pause_mp_download", + description="按编号暂停下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_download", + description="按编号恢复下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_download", + description="按编号删除下载任务;默认不删除文件", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "delete", "target": "<1-N>", "delete_files": False}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="当前没有下载中任务,改查下载历史和整理状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_download_history": + templates.extend([ + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": data.get("keyword") or "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_downloaders": + templates.extend([ + self._assistant_action_template( + name="query_mp_sites", + description="查看 PT 站点启用状态和 Cookie 是否存在", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_sites": + templates.extend([ + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_subscribes": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="search_mp_subscribe", + description="按编号触发订阅搜索;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "search", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="pause_mp_subscribe", + description="按编号暂停订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_subscribe", + description="按编号恢复订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_subscribe", + description="按编号删除订阅", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "delete", "target": "<1-N>"}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="start_mp_subscribe", + description="按关键词生成新的 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_lifecycle_status": + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="继续查看 MP 下载历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "title": data.get("keyword") or "", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_recommend": + templates.extend([ + self._assistant_action_template( + name="pick_recommend_smart_decision", + description="按编号选择推荐条目并进入统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_decision"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_plan", + description="按编号选择推荐条目并直接生成统一资源计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_plan"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_execute", + description="按编号选择推荐条目并直接进入统一资源执行链", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_execute"}, + ), + self._assistant_action_template( + name="pick_recommend_mp_search", + description="按编号选择推荐条目并进入 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "mp"}, + ), + self._assistant_action_template( + name="pick_recommend_hdhive_search", + description="按编号选择推荐条目并进入影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "hdhive"}, + ), + self._assistant_action_template( + name="pick_recommend_pansou_search", + description="按编号选择推荐条目并进入盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "pansou"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "candidate": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_candidate", + description="按编号选择影巢候选影片进入资源列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="candidate_detail", + description="补充当前候选页详情,例如主演", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "detail"}, + ), + self._assistant_action_template( + name="candidate_next_page", + description="翻到候选下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢候选对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "resource": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_resource", + description="按编号选择影巢资源,解锁并路由到对应网盘", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="plan_hdhive_resource", + description="按编号生成影巢解锁/转存计划,不立即扣分或写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="resource_next_page", + description="翻到影巢资源下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢资源列表对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_p115_login": + templates.extend([ + self._assistant_action_template( + name="check_115_login", + description="检查 115 扫码是否已确认,并在成功后自动继续待任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_check"}, + ), + self._assistant_action_template( + name="show_115_status", + description="查看当前 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_status"}, + ), + ]) + + if pending.get("has_pending"): + templates.extend([ + self._assistant_action_template( + name="resume_pending_115", + description="继续当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_resume"}, + ), + self._assistant_action_template( + name="cancel_pending_115", + description="取消当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_cancel"}, + ), + ]) + + templates.append( + self._assistant_action_template( + name="clear_current_session", + description="清理当前会话缓存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session/clear", + tool="agent_resource_officer_session_clear", + body=base_state, + ) + ) + return templates + + def _assistant_sessions_public_data( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> Dict[str, Any]: + kind_filter = self._clean_text(kind) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + items_by_session: Dict[str, Dict[str, Any]] = {} + for session_id, payload in (self._session_cache or {}).items(): + if not str(session_id).startswith("assistant::"): + continue + session = dict(payload or {}) + if self._is_session_expired(session): + continue + brief = self._assistant_session_brief_public_data(str(session_id), session) + items_by_session[brief.get("session_id") or str(session_id)] = brief + for plan in (self._workflow_plans or {}).values(): + current = dict(plan or {}) + plan_session_id = self._clean_text(current.get("session_id")) + if not plan_session_id: + continue + brief = items_by_session.get(plan_session_id) + if brief: + if not brief.get("has_saved_plan"): + refreshed = self._assistant_session_brief_public_data(plan_session_id, self._load_session(plan_session_id) or {}) + items_by_session[plan_session_id] = refreshed + continue + items_by_session[plan_session_id] = self._assistant_plan_only_session_brief_public_data(plan_session_id) + items: List[Dict[str, Any]] = [] + for brief in items_by_session.values(): + if kind_filter and brief.get("kind") != kind_filter: + continue + if has_pending_p115 is not None and bool(brief.get("has_pending_p115")) != bool(has_pending_p115): + continue + items.append(brief) + items.sort(key=lambda item: self._safe_int(item.get("updated_at"), 0), reverse=True) + return { + "total": len(items), + "limit": max_limit, + "items": items[:max_limit], + "filters": { + "kind": kind_filter, + "has_pending_p115": has_pending_p115, + }, + "action_templates": [ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": "", "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": ""}, + ), + self._assistant_action_template( + name="clear_session_by_id", + description="按 session_id 清理单个会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"session_id": ""}, + ), + ], + "recovery": ( + dict((items[:max_limit][0] or {}).get("recovery") or {}) + if (items[:max_limit] and isinstance(items[:max_limit][0], dict)) + else { + "mode": "start_new", + "reason": "当前没有活跃会话,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + } + + def _assistant_recover_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + requested_session = self._clean_text(session) + requested_session_id = self._clean_text(session_id) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + if requested_session or requested_session_id: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=requested_session or "default", + session_id=requested_session_id, + ) + state = self._assistant_session_public_data(session=session_name) + return { + "scope": "session", + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "selected_session": { + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "kind": state.get("kind"), + "stage": state.get("stage"), + "keyword": state.get("keyword"), + "has_pending_plan": bool((state.get("saved_plan") or {}).get("has_pending")), + "has_pending_p115": bool((state.get("pending_p115") or {}).get("has_pending")), + }, + "session_state": state, + "sessions": None, + "recovery": dict(state.get("recovery") or self._assistant_recovery_public_data(session_state=state)), + } + + sessions = self._assistant_sessions_public_data(limit=max_limit) + items = [dict(item or {}) for item in (sessions.get("items") or []) if isinstance(item, dict)] + selected: Optional[Dict[str, Any]] = None + + current_recovery = dict(sessions.get("recovery") or {}) + template_body = dict(((current_recovery.get("action_template") or {}).get("body") or {})) + preferred_session_id = self._clean_text(template_body.get("session_id")) + if preferred_session_id: + selected = next((item for item in items if self._clean_text(item.get("session_id")) == preferred_session_id), None) + if not selected: + selected = next((item for item in items if bool((item.get("recovery") or {}).get("can_resume"))), None) + if not selected: + selected = next((item for item in items if item.get("has_pending_plan") or item.get("has_pending_p115")), None) + if not selected and items: + selected = items[0] + + if selected: + session_name = self._clean_text(selected.get("session")) or "default" + selected_session_id = self._clean_text(selected.get("session_id")) or self._assistant_session_id(session_name) + state = self._assistant_session_public_data(session=session_name) + recovery = dict(state.get("recovery") or selected.get("recovery") or current_recovery) + selected = {**selected, "recovery": recovery} + return { + "scope": "global", + "session": session_name, + "session_id": selected_session_id, + "selected_session": selected, + "session_state": state, + "sessions": sessions, + "recovery": recovery, + } + + state = self._assistant_session_public_data(session="default") + return { + "scope": "global", + "session": "default", + "session_id": state.get("session_id") or self._assistant_session_id("default"), + "selected_session": None, + "session_state": state, + "sessions": sessions, + "recovery": dict(state.get("recovery") or current_recovery), + } + + def _format_assistant_recover_text(self, data: Dict[str, Any]) -> str: + recovery = dict((data or {}).get("recovery") or {}) + selected = dict((data or {}).get("selected_session") or {}) + lines = [ + "Agent影视助手 恢复入口", + f"范围:{(data or {}).get('scope') or 'session'}", + f"会话:{(data or {}).get('session') or 'default'}", + f"模式:{recovery.get('mode') or 'unknown'}", + f"原因:{recovery.get('reason') or '-'}", + f"可恢复:{'是' if recovery.get('can_resume') else '否'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + if recovery.get("recommended_tool"): + lines.append(f"推荐 Tool:{recovery.get('recommended_tool')}") + if selected.get("kind") or selected.get("keyword"): + detail = " / ".join( + str(item) + for item in [ + selected.get("kind"), + selected.get("stage"), + selected.get("keyword"), + ] + if item + ) + lines.append(f"当前状态:{detail}") + if recovery.get("can_resume"): + lines.append("如需直接恢复,可调用 assistant/recover 并传 execute=true。") + return "\n".join(lines) + + def _assistant_recover_response_data(self, data: Dict[str, Any], compact: bool = False) -> Dict[str, Any]: + if not compact: + return self._assistant_response_data(session=(data or {}).get("session") or "default", data=data) + + payload = dict(data or {}) + session_state = dict(payload.pop("session_state", {}) or {}) + payload.pop("sessions", None) + recovery = dict(payload.get("recovery") or {}) + selected = payload.get("selected_session") + if isinstance(selected, dict) and selected.get("recovery"): + selected = dict(selected) + selected.pop("recovery", None) + payload["selected_session"] = selected + + session_name = self._clean_text(payload.get("session") or session_state.get("session")) or "default" + session_id = self._clean_text(payload.get("session_id") or session_state.get("session_id")) or self._assistant_session_id(session_name) + action_template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + session_templates = session_state.get("action_templates") if isinstance(session_state.get("action_templates"), list) else [] + payload.update({ + "protocol_version": "assistant.v1", + "compact": True, + "session": session_name, + "session_id": session_id, + "next_actions": [ + item for item in [ + recovery.get("recommended_action"), + *(session_state.get("suggested_actions") or []), + ] + if item + ][:6], + "action_templates": self._assistant_compact_action_templates(action_template, session_templates), + }) + return payload + + def _assistant_session_compact_data(self, session_state: Dict[str, Any]) -> Dict[str, Any]: + state = dict(session_state or {}) + recovery = dict(state.get("recovery") or {}) + saved_plan = dict(state.get("saved_plan") or {}) + pending_p115 = dict(state.get("pending_p115") or {}) + payload: Dict[str, Any] = { + "protocol_version": "assistant.v1", + "action": "session_state", + "ok": True, + "compact": True, + "has_session": bool(state.get("has_session")), + "session": self._clean_text(state.get("session")) or "default", + "session_id": self._clean_text(state.get("session_id")), + "kind": self._clean_text(state.get("kind")), + "stage": self._clean_text(state.get("stage")), + "keyword": self._clean_text(state.get("keyword")), + "target_path": self._clean_text(state.get("target_path")), + "updated_at": state.get("updated_at"), + "updated_at_text": state.get("updated_at_text"), + "saved_plan": { + "has_pending": bool(saved_plan.get("has_pending")), + "plan_id": self._clean_text((saved_plan.get("latest") or {}).get("plan_id") or saved_plan.get("plan_id")), + }, + "pending_p115": { + "has_pending": bool(pending_p115.get("has_pending")), + "target_path": self._clean_text(pending_p115.get("target_path")), + "retry_count": pending_p115.get("retry_count"), + }, + "recovery": recovery, + "next_actions": state.get("suggested_actions") or [], + "action_templates": self._assistant_compact_action_templates( + recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None, + state.get("action_templates") if isinstance(state.get("action_templates"), list) else [], + ), + } + for key in [ + "result_count", + "total_candidates", + "page", + "total_pages", + "selected_candidate", + "total_resources", + "resource_count_115", + "resource_count_quark", + "score_summary", + "client_type", + ]: + if key in state: + payload[key] = state.get(key) + return payload + + def _assistant_sessions_compact_data(self, sessions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(sessions_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + recovery = dict(item.get("recovery") or {}) + items.append({ + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "keyword": self._clean_text(item.get("keyword")), + "target_path": self._clean_text(item.get("target_path")), + "updated_at": item.get("updated_at"), + "updated_at_text": item.get("updated_at_text"), + "has_pending_plan": bool(item.get("has_pending_plan")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "recovery_mode": self._clean_text(recovery.get("mode")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + }) + recovery = dict(data.get("recovery") or {}) + return { + "protocol_version": "assistant.v1", + "action": "sessions", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "filters": data.get("filters") or {}, + "items": items, + "recovery": recovery, + "next_actions": [recovery.get("recommended_action")] if recovery.get("recommended_action") else [], + "action_templates": [recovery.get("action_template")] if isinstance(recovery.get("action_template"), dict) else [], + } + + def _assistant_history_compact_data(self, history_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(history_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + summary = dict(item.get("summary") or {}) + steps = summary.get("steps") + items.append({ + "time_text": self._clean_text(item.get("time_text")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "workflow": self._clean_text(summary.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "message_head": self._clean_text(item.get("message_head")), + "steps": steps if isinstance(steps, int) else None, + }) + return { + "protocol_version": "assistant.v1", + "action": "history", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "items": items, + } + + def _assistant_plans_compact_data(self, plans_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plans_data or {}) + items: List[Dict[str, Any]] = [] + first_pending: Optional[Dict[str, Any]] = None + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + plan_id = self._clean_text(item.get("plan_id")) + executed = bool(item.get("executed")) + compact_item = { + "plan_id": plan_id, + "workflow": self._clean_text(item.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "executed": executed, + "action_count": self._safe_int(item.get("action_count"), 0), + "created_at_text": self._clean_text(item.get("created_at_text")), + "last_success": item.get("last_success"), + "last_message": self._clean_text(item.get("last_message")), + } + items.append(compact_item) + if not executed and first_pending is None and plan_id: + first_pending = compact_item + templates: List[Dict[str, Any]] = [] + if first_pending: + templates.append(self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": first_pending.get("plan_id"), + "session": first_pending.get("session"), + "session_id": first_pending.get("session_id"), + "prefer_unexecuted": True, + }, + )) + return { + "protocol_version": "assistant.v1", + "action": "plans", + "ok": True, + "compact": True, + "total": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_matching": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_all": data.get("total_all") if data.get("total_all") is not None else (data.get("total") or 0), + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "executed": data.get("executed"), + "include_actions": False, + "items": items, + "next_actions": ["execute_plan"] if first_pending else [], + "action_templates": templates, + } + + def _assistant_compact_action_results(self, rows: Any) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for item in rows or []: + if not isinstance(item, dict): + continue + results.append({ + "index": item.get("index"), + "name": self._clean_text(item.get("name")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "ok": bool(item.get("ok")) if "ok" in item else bool(item.get("success")), + "message_head": self._clean_text(item.get("message_head")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "next_actions": item.get("next_actions") or [], + }) + if isinstance(item.get("score_summary"), dict): + results[-1]["score_summary"] = item.get("score_summary") + if isinstance(item.get("diagnosis_summary"), dict): + results[-1]["diagnosis_summary"] = item.get("diagnosis_summary") + return results + + def _assistant_plan_execute_followup( + self, + *, + workflow: str, + session: str, + session_id: str, + session_state: Optional[Dict[str, Any]] = None, + ok: bool, + plan_id: str = "", + ) -> Dict[str, Any]: + workflow_name = self._clean_text(workflow) + if not ok and workflow_name != "smart_resource_plan": + return {"next_actions": [], "action_templates": [], "recommended_action": "", "follow_up_hint": ""} + + workflow_stage = workflow_name + state = dict(session_state or {}) + session_name = self._clean_text(session or state.get("session")) or "default" + session_cache = self._clean_text(session_id or state.get("session_id")) or self._assistant_session_id(session_name) + keyword = self._clean_text(state.get("keyword")) + plan_execute_body = dict(state.get("plan_execute_body") or {}) + if workflow_name == "smart_resource_plan": + inferred_source = self._clean_text( + state.get("source_type") + or ((state.get("best_candidate") or {}).get("source_type")) + or plan_execute_body.get("mode") + ).lower() + if inferred_source == "mp": + inferred_source = "mp_pt" + if inferred_source == "mp_pt": + workflow_name = "mp_best_download" + elif inferred_source in {"pansou", "hdhive"}: + workflow_name = inferred_source + base_route = { + "session": session_name, + "session_id": session_cache, + } + base_state = { + "session": session_name, + "session_id": session_cache, + } + keyword_value = keyword or "<关键词>" + next_actions: List[str] = [] + templates: List[Dict[str, Any]] = [] + follow_up_hint = "" + clean_plan_id = self._clean_text(plan_id) + + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动去查下载历史,再继续判断是否已整理、已入库或失败。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询下载后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看下载历史,并继续追踪整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "keyword": keyword_value, "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总失败线索并给出本地/PT 入库诊断建议", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + next_actions = ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查订阅列表,再决定是否继续搜索或进入入库追踪。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询订阅后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看订阅列表,确认当前订阅是否已生效", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_subscribes", "status": "all", "keyword": keyword, "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": keyword_value}, + ), + ] + elif workflow_name in {"share_transfer", "pansou_transfer_selected", "hdhive_unlock", "hdhive_unlock_selected", "pansou", "hdhive"}: + next_actions = ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查整理/入库历史,失败时再切到本地诊断。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询云盘转存后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看最近整理/入库历史,确认转存资源是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_transfer_history", "keyword": keyword, "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总下载后未入库或整理失败的诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + if workflow_stage == "smart_resource_plan" and keyword: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_local_diagnose"] + follow_up_hint = "推荐先跟进当前发现链的落库状态;它会先看统一后续,再判断是否已入库或需要本地诊断。" + templates = [ + templates[0], + self._assistant_action_template( + name="query_mp_ingest_status", + description="按推荐标题查看当前是否已下载、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + templates[-1], + ] + elif workflow_name == "ai_replay_failed_sample": + next_actions = ["query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + follow_up_hint = "先回看 AI 工作清单和失败样本,确认这次二次识别是否减少了失败样本。" + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + follow_up_hint = "先看本地诊断或入库状态,再回看 AI 工作清单确认失败样本是否已减少。" + templates = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="查看这次二次识别关联的本地/PT 入库诊断", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续处理剩余失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,确认当前仍有哪些标题或路径失败", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,确认失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + + category = "cloud_write" + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + category = "mp_download" + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + category = "mp_subscribe" + elif workflow_name == "ai_replay_failed_sample": + category = "ai_reingest" + followup_summary = self._assistant_followup_summary( + category=category, + stage=self._clean_text(workflow_stage), + recommended_action=next_actions[0] if next_actions else "", + follow_up_hint=follow_up_hint, + next_actions=next_actions, + action_templates=templates, + keyword=keyword, + ) + return { + "next_actions": next_actions, + "action_templates": templates, + "recommended_action": next_actions[0] if next_actions else "", + "follow_up_hint": follow_up_hint, + "followup_summary": followup_summary, + } + + async def _assistant_execution_followup( + self, + request: Request, + *, + session: str, + session_id: str, + plan_id: str = "", + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + target_plan_id = self._clean_text(plan_id) + plan = self._find_workflow_plan( + plan_id=target_plan_id, + session=session_name, + session_id=cache_key, + executed=True if not target_plan_id else None, + ) + if target_plan_id and plan and not bool(plan.get("executed")): + return { + "success": False, + "message": f"计划 {target_plan_id} 还没有执行,暂时无法做执行后追踪。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "plan_not_executed", + "plan_id": target_plan_id, + }), + } + if not plan: + latest_any = self._find_workflow_plan(session=session_name, session_id=cache_key, executed=None) + error_code = "executed_plan_not_found" + if latest_any and not bool(latest_any.get("executed")): + error_code = "latest_plan_not_executed" + return { + "success": False, + "message": "当前会话没有可追踪的已执行计划,请先执行下载、订阅或转存计划。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": error_code, + "plan_id": target_plan_id, + }), + } + + source_plan = self._workflow_plan_public_item(plan, include_actions=False) + state = self._load_session(self._clean_text(plan.get("session_id"))) or {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(plan.get("workflow")), + session=self._clean_text(plan.get("session")) or session_name, + session_id=self._clean_text(plan.get("session_id")) or cache_key, + session_state=state, + ok=bool(plan.get("last_success")) if plan.get("last_success") is not None else True, + plan_id=self._clean_text(plan.get("plan_id")), + ) + action_templates = [item for item in (followup.get("action_templates") or []) if isinstance(item, dict)] + resolved_template = next( + (item for item in action_templates if self._clean_text(item.get("name")) != "query_execution_followup"), + {}, + ) + resolved_name = self._clean_text(resolved_template.get("name")) + if not resolved_name: + return { + "success": False, + "message": "当前执行计划没有可自动继续的只读追踪动作。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "followup_not_available", + "plan_id": self._clean_text(plan.get("plan_id")), + "source_plan": source_plan, + }), + } + action_body = dict(resolved_template.get("action_body") or resolved_template.get("body") or {}) + action_body["compact"] = False + action_body["apikey"] = self._extract_apikey(request, {}) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "execution_followup", + "ok": bool(result.get("success")), + "plan_id": self._clean_text(plan.get("plan_id")), + "workflow": self._clean_text(plan.get("workflow")), + "source_plan": source_plan, + "recommended_action": self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(followup.get("follow_up_hint")), + "resolved_followup_action": resolved_name, + "followup_summary": followup.get("followup_summary") or {}, + }) + message_lines = [ + f"已执行后续追踪:{resolved_name}", + self._clean_text(result.get("message")), + ] + message_lines.extend(self._format_followup_summary_lines(followup.get("followup_summary"))) + return { + "success": bool(result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + async def _assistant_smart_followup( + self, + request: Request, + *, + session: str, + session_id: str, + keyword: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + title = self._clean_text(keyword) + hash_text = self._clean_text(hash_value) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=cache_key) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id")) + latest_executed = bool(latest_plan.get("executed")) + + if title or hash_text: + result = await self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=title, + hash_value=hash_text, + limit=limit, + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_lifecycle_status", + "smart_mode": "keyword_lifecycle", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按关键词查看本地/PT 状态", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if latest_executed and latest_plan_id: + result = await self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=latest_plan_id, + ) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": self._clean_text(payload.get("resolved_followup_action")) or "execution_followup", + "smart_mode": "executed_plan_followup", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按最近已执行计划继续追踪", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if saved_plan.get("has_pending"): + template = self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True}, + ) + return { + "success": False, + "message": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "smart_followup", + "ok": False, + "error_code": "latest_plan_not_executed", + "recommended_action": "execute_session_latest_plan", + "follow_up_hint": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "action_templates": [template], + "smart_mode": "pending_plan_blocked", + }), + } + + result = await self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=max(5, limit), + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_recent_activity", + "smart_mode": "recent_activity_fallback", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:当前没有指定片名,也没有已执行计划,先看最近活动", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + def _assistant_actions_compact_data(self, actions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(actions_data or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "execute_actions", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=self._clean_text(data.get("error_code")), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=self._assistant_result_message_head(data.get("message") or data.get("message_head")), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in ["decision_mode", "decision_reason"]: + if key in data: + payload[key] = data.get(key) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return payload + + @staticmethod + def _assistant_compact_command_summary(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + data = dict(payload or {}) + score_summary = data.get("score_summary") if isinstance(data.get("score_summary"), dict) else {} + candidates = [ + ("error_summary", data.get("error_summary") if isinstance(data.get("error_summary"), dict) else {}), + ("followup_summary", data.get("followup_summary") if isinstance(data.get("followup_summary"), dict) else {}), + ("decision_summary", data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}), + ("score_summary", score_summary.get("decision") if isinstance(score_summary.get("decision"), dict) else {}), + ] + for source, summary in candidates: + if not isinstance(summary, dict) or not summary: + continue + compact_commands = [ + AgentResourceOfficer._clean_text(item) + for item in (summary.get("compact_commands") or summary.get("recommended_commands") or []) + if AgentResourceOfficer._clean_text(item) + ] + preferred_command = AgentResourceOfficer._clean_text(summary.get("preferred_command")) + fallback_command = AgentResourceOfficer._clean_text(summary.get("fallback_command")) + if preferred_command or compact_commands: + return { + "command_source": source, + "command_policy": AgentResourceOfficer._clean_text(summary.get("command_policy")) or ("confirm_then_resume" if bool(summary.get("preferred_requires_confirmation")) else "safe_read_only"), + "preferred_requires_confirmation": bool(summary.get("preferred_requires_confirmation")), + "fallback_requires_confirmation": bool(summary.get("fallback_requires_confirmation")), + "can_auto_run_preferred": bool(summary.get("can_auto_run_preferred")) if "can_auto_run_preferred" in summary else not bool(summary.get("preferred_requires_confirmation")), + "preferred_command": preferred_command or (compact_commands[0] if compact_commands else ""), + "fallback_command": fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), + "compact_commands": compact_commands[:2], + "recommended_agent_behavior": AgentResourceOfficer._clean_text(summary.get("recommended_agent_behavior")), + "auto_run_command": AgentResourceOfficer._clean_text(summary.get("auto_run_command")), + "confirm_command": AgentResourceOfficer._clean_text(summary.get("confirm_command")), + "display_command": AgentResourceOfficer._clean_text(summary.get("display_command")), + "detail_short_command": AgentResourceOfficer._clean_text(summary.get("detail_short_command")), + "decision_short_command": AgentResourceOfficer._clean_text(summary.get("decision_short_command")), + "plan_short_command": AgentResourceOfficer._clean_text(summary.get("plan_short_command")), + "confirm_short_command": AgentResourceOfficer._clean_text(summary.get("confirm_short_command")), + "pansou_short_command": AgentResourceOfficer._clean_text(summary.get("pansou_short_command")), + "hdhive_short_command": AgentResourceOfficer._clean_text(summary.get("hdhive_short_command")), + "mp_short_command": AgentResourceOfficer._clean_text(summary.get("mp_short_command")), + } + return {} + + def _assistant_plan_execute_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + success_count = len([item for item in results if item.get("success")]) + last_result = results[-1] if results else {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(data.get("workflow")), + session=self._clean_text(data.get("session") or session_state.get("session")) or "default", + session_id=self._clean_text(data.get("session_id") or session_state.get("session_id")), + session_state=session_state, + ok=bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + plan_id=self._clean_text(data.get("plan_id")), + ) + payload = { + "protocol_version": "assistant.v1", + "action": "execute_plan", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or "write", + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "plan_id": self._clean_text(data.get("plan_id")), + "workflow": self._clean_text(data.get("workflow")), + "plan_auto_selected": bool(data.get("plan_auto_selected")), + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "plan_executed_at": data.get("plan_executed_at"), + "plan_executed_at_text": data.get("plan_executed_at_text"), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "result_summary": { + "success_count": success_count, + "failure_count": max(len(results) - success_count, 0), + "last_action": self._clean_text(last_result.get("action")), + "last_message_head": self._clean_text(last_result.get("message_head")), + }, + "recommended_action": self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")), + "next_actions": self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or session_state.get("suggested_actions") or [], + ), + "action_templates": self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ), + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=payload.get("recommended_action"), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("recovery"), dict): + payload["recovery"] = data.get("recovery") + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_single_action_compact_response(self, name: str, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or self._clean_text(name) or "execute_action", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action")) or self._clean_text(name)), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "name": self._clean_text(name), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + for key in ["plan_id", "workflow", "plan_auto_selected", "has_session", "has_pending"]: + if key in data: + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in [ + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "decision_mode", + "decision_reason", + "detail_short_command", + "decision_short_command", + "plan_short_command", + "confirm_short_command", + "pansou_short_command", + "hdhive_short_command", + "mp_short_command", + "auto_run_command", + "confirm_command", + "display_command", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + ]: + if key in data: + payload[key] = data.get(key) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_interaction_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "assistant_interaction", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action"))), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "keyword": self._clean_text(session_state.get("keyword")), + "target_path": self._clean_text(session_state.get("target_path")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target") or session_state.get("keyword")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("insights"), dict): + payload["insights"] = data.get("insights") + if isinstance(data.get("recommend_handoff"), dict): + payload["recommend_handoff"] = data.get("recommend_handoff") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in [ + "provider", + "source", + "requested_source", + "fallback_source", + "media_type", + "page", + "total_pages", + "selected_candidate", + "selected_resource", + "plan_id", + "workflow", + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "smart_plan_auto_selected", + "smart_execute_auto_selected", + "decision_mode", + "decision_reason", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + "selected_index", + "return_short_command", + ]: + if key in data: + payload[key] = data.get(key) + for key in ["best_candidate"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("items"), list): + payload["items"] = data.get("items") + for key in ["items", "candidates", "resources"]: + if isinstance(data.get(key), list): + payload[f"{key}_count"] = len(data.get(key) or []) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_workflow_plan_compact_data(self, plan_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plan_data or {}) + session_state = dict(data.get("session_state") or {}) + plan_id = self._clean_text(data.get("plan_id")) + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的 dry_run 计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"plan_id": plan_id}, + ) if plan_id else None + return { + "protocol_version": "assistant.v1", + "action": "workflow_plan", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "plan_id": plan_id, + "workflow": self._clean_text(data.get("workflow")), + "dry_run": True, + "estimated_steps": data.get("estimated_steps") or 0, + "ready_to_execute": bool(data.get("ready_to_execute")), + "execute_plan_endpoint": data.get("execute_plan_endpoint"), + "execute_plan_body": data.get("execute_plan_body") or {"plan_id": plan_id}, + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "preference_status": data.get("preference_status") or {}, + "needs_onboarding": bool((data.get("preference_status") or {}).get("needs_onboarding")), + "score_summary": data.get("score_summary") or {}, + "decision_summary": data.get("decision_summary") or {}, + "best_candidate": data.get("best_candidate") or {}, + "sources_checked": data.get("sources_checked") or [], + "available_sources": data.get("available_sources") or [], + "blocked_sources": data.get("blocked_sources") or [], + "effective_preferences": data.get("effective_preferences") or {}, + "session_preference_overrides": data.get("session_preference_overrides") or {}, + "smart_plan_auto_selected": bool(data.get("smart_plan_auto_selected")), + "detail_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("detail_short_command")), + "decision_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_short_command")), + "plan_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("plan_short_command")), + "confirm_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("confirm_short_command")), + "pansou_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("pansou_short_command")), + "hdhive_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("hdhive_short_command")), + "mp_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("mp_short_command")), + "next_actions": ["execute_plan"] if plan_id else [], + "action_templates": [template] if template else [], + } + + def _format_assistant_sessions_text( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> str: + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有活跃的 Agent影视助手 会话。" + lines = [ + f"当前活跃会话:{data.get('total') or 0} 个", + "可直接用 assistant/session 查看单个会话详情,也可按 session_id 直接恢复最近计划。", + ] + for idx, item in enumerate(items, 1): + line = f"{idx}. {item.get('session')} | {item.get('kind') or '-'} | {item.get('stage') or '-'}" + if item.get("keyword"): + line = f"{line} | {item.get('keyword')}" + lines.append(line) + detail_parts: List[str] = [] + if item.get("target_path"): + detail_parts.append(f"目录:{item.get('target_path')}") + if item.get("updated_at_text"): + detail_parts.append(f"更新:{item.get('updated_at_text')}") + if item.get("has_pending_p115"): + detail_parts.append("含待继续115任务") + if item.get("has_pending_plan"): + detail_parts.append("含待执行计划") + if item.get("selected_title"): + detail_parts.append(f"已选:{item.get('selected_title')}") + if item.get("result_count"): + detail_parts.append(f"结果:{item.get('result_count')}") + if item.get("total_candidates"): + detail_parts.append(f"候选:{item.get('total_candidates')}") + if item.get("total_resources"): + detail_parts.append(f"资源:{item.get('total_resources')}") + if detail_parts: + lines.append(" " + " | ".join(detail_parts)) + return "\n".join(lines) + + def _clear_assistant_sessions( + self, + *, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + cleared_ids: List[str] = [] + if self._clean_text(session_id) or self._clean_text(session): + _, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if cache_key in self._session_cache: + self._session_cache.pop(cache_key, None) + cleared_ids.append(cache_key) + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + kind_filter = self._clean_text(kind) + for current_session_id, payload in list((self._session_cache or {}).items()): + if len(cleared_ids) >= max_limit: + break + if not str(current_session_id).startswith("assistant::"): + continue + current = dict(payload or {}) + expired = self._is_session_expired(current) + if stale_only and not expired: + continue + if not stale_only and expired: + continue + if not all_sessions: + if kind_filter and self._clean_text(current.get("kind")) != kind_filter: + continue + if has_pending_p115 is not None: + has_pending = bool(self._clean_text(((current.get("pending_p115") or {}).get("share_url")))) + if has_pending != bool(has_pending_p115): + continue + if not kind_filter and has_pending_p115 is None and not stale_only: + continue + self._session_cache.pop(current_session_id, None) + cleared_ids.append(str(current_session_id)) + + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + def _format_assistant_session_summary(self, session: str = "default") -> str: + data = self._assistant_session_public_data(session=session) + if not data.get("has_session"): + return "\n".join([ + "当前没有活跃会话。", + "可直接调用 smart_entry 发起新操作,例如:", + "1. text=盘搜搜索 大君夫人", + "2. text=影巢搜索 蜘蛛侠", + "3. text=链接 https://115cdn.com/s/xxxx path=/待整理", + ]) + + lines = [ + "当前会话状态", + f"会话:{data.get('session')}", + f"类型:{data.get('kind') or '-'}", + f"阶段:{data.get('stage') or '-'}", + ] + if data.get("keyword"): + lines.append(f"关键词:{data.get('keyword')}") + if data.get("target_path"): + lines.append(f"目录:{data.get('target_path')}") + if data.get("updated_at_text"): + lines.append(f"最近更新:{data.get('updated_at_text')}") + if data.get("kind") == "assistant_pansou": + lines.append(f"结果数:{data.get('result_count') or 0}") + lines.append("下一步:调用 smart_pick,传入 choice=编号") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "candidate": + lines.append(f"候选数:{data.get('total_candidates') or 0}") + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=编号,或 action=详情 / 下一页") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "resource": + selected = data.get("selected_candidate") or {} + if selected.get("title"): + lines.append(f"已选影片:{selected.get('title')} ({selected.get('year') or '-'})") + lines.append(f"资源数:{data.get('total_resources') or 0}") + if data.get("page") and data.get("total_pages"): + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=资源编号,或 action=下一页") + elif data.get("kind") == "assistant_p115_login": + lines.append(f"扫码客户端:{data.get('client_type') or self._p115_client_type}") + lines.append("下一步:调用 smart_entry,传入 text=检查115登录") + + pending = data.get("pending_p115") or {} + if pending.get("has_pending"): + lines.append("存在待继续的 115 任务") + lines.append(f"任务:{pending.get('title')}") + lines.append(f"待转目录:{pending.get('target_path')}") + + actions = data.get("suggested_actions") or [] + if actions: + lines.append("建议动作:" + " / ".join(str(item) for item in actions if item)) + return "\n".join(lines) + + def _session_key_for_tool(self, session: str = "default") -> str: + clean_session = self._clean_text(session) or "default" + if clean_session.startswith("assistant::"): + return clean_session + return self._assistant_session_id(clean_session) + + def _execute_pending_p115_share( + self, + *, + session_id: str, + state: Dict[str, Any], + trigger: str, + ) -> Tuple[bool, str, Dict[str, Any]]: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return False, "", {} + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + transfer_ok, result, transfer_message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(pending.get("access_code")), + path=target_path, + trigger=trigger, + ) + if transfer_ok: + self._clear_pending_p115_share(session_id) + message = "\n".join( + [ + "115 转存已完成", + f"目录:{result.get('path') or target_path}", + f"结果:{transfer_message or result.get('message') or 'success'}", + ] + ) + return True, message, {"provider": "115", "result": result} + + failure_message = self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path, + ) + current_state = self._load_session(session_id) or dict(state or {}) + pending["retry_count"] = max(0, self._safe_int(pending.get("retry_count"), 0)) + 1 + pending["last_attempt_at"] = int(time.time()) + pending["last_error"] = failure_message + current_state["pending_p115"] = pending + if not current_state.get("kind"): + current_state["kind"] = "assistant_p115_pending" + current_state["stage"] = "pending_login" + self._save_session(session_id, current_state) + return False, failure_message, {"provider": "115", "result": result} + + async def _resume_pending_p115_share( + self, + request: Request, + body: Dict[str, Any], + *, + session_id: str, + state: Dict[str, Any], + ) -> Tuple[bool, str, Dict[str, Any]]: + return self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 115 登录后自动继续", + ) + + def _format_p115_status_summary(self, *, title: str = "115 当前状态") -> str: + status = self._p115_status_snapshot() + lines = [ + title, + f"可用状态:{'可用' if status.get('ready') else '待修复'}", + f"默认目录:{status.get('default_target_path') or self._p115_default_path}", + f"扫码客户端:{status.get('client_type') or self._p115_client_type}", + ] + if status.get("direct_source"): + lines.append(f"直转来源:{status.get('direct_source')}") + elif status.get("helper_ready"): + lines.append("直转来源:P115StrmHelper") + if status.get("cookie_mode") == "client_cookie": + lines.append("当前会话:已保存扫码会话") + elif status.get("cookie_mode") == "invalid_cookie": + lines.append("当前会话:已配置但看起来不是扫码会话") + else: + lines.append("当前会话:复用 115 助手客户端") + if status.get("message") and not status.get("ready"): + lines.append(f"详情:{status.get('message')}") + lines.append(self._format_p115_next_actions(status)) + return "\n".join(lines) + + def _format_p115_help_text(self) -> str: + status = self._p115_status_snapshot() + final_path = status.get("default_target_path") or self._p115_default_path + lines = [ + "115 使用帮助", + f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}", + f"默认目录:{final_path}", + "如果 115 转存因登录问题失败,我会记住这次任务;扫码成功后回复 检查115登录,会自动继续执行。", + "常用示例:", + f"1. 链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 影巢搜索 蜘蛛侠", + "3. 盘搜搜索 大君夫人", + "4. 115登录", + "5. 检查115登录", + "6. 115状态", + "7. 继续115任务 / 取消115任务", + self._format_p115_next_actions(status), + ] + return "\n".join(lines) + + def _format_assistant_help_text(self, session: str = "default") -> str: + session_name = self._clean_text(session) or "default" + lines = [ + "Agent影视助手 使用帮助", + f"当前会话:{session_name}", + "推荐优先使用原生 Tool:agent_resource_officer_smart_entry 与 agent_resource_officer_smart_pick。", + "smart_entry 常用示例:", + "1. text=盘搜搜索 大君夫人", + "2. text=搜索 大君夫人 默认走盘搜", + "3. text=云盘搜索 大君夫人 只走盘搜 + 影巢", + "4. text=影巢搜索 蜘蛛侠", + "5. text=MP搜索 蜘蛛侠 或 PT搜索 蜘蛛侠", + "6. text=115登录", + "7. text=检查115登录", + "8. text=链接 https://115cdn.com/s/xxxx path=/待整理", + "9. text=链接 https://pan.quark.cn/s/xxxx 位置=分享", + "10. text=转存 蜘蛛侠 默认等同 115转存;text=下载 蜘蛛侠 只走 MP/PT,先展示候选和 PT 资源,不自动提交下载", + "11. text=下载任务;暂停下载 1 / 恢复下载 1 / 删除下载 1 会先生成计划", + "12. text=站点状态;下载器状态 用于排查 PT 搜索/下载环境", + "13. text=记录 片名 用于判断资源是否提交过下载并进入整理流程", + "14. text=状态 片名 一次查看下载任务、下载历史和入库历史", + "15. text=识别 片名 使用 MoviePilot 原生识别确认 TMDB/Douban/IMDB 信息", + "16. text=订阅列表;搜索订阅 1 / 暂停订阅 1 / 恢复订阅 1 / 删除订阅 1 会先生成计划", + "17. text=入库记录;入库失败 片名 用于判断下载后是否已经整理落库", + "18. text=执行计划 执行当前会话最近待执行计划;text=执行 plan-xxxx 精确执行指定计划", + "19. text=偏好 / 保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库 / 重置偏好", + "20. text=后续 / 最近 / 入库 片名 / 诊断 片名 是更省 token 的本地/PT 跟踪短命令", + "21. text=跟进 / 跟进 片名 是统一跟进入口:有已执行计划时自动跟执行后状态,有片名时直接看生命周期", + "smart_pick 常用示例:", + "1. choice=1", + "2. action=详情", + "3. action=下一页", + "MP 搜索结果里,choice=1 会先展示 PT 详情和评分理由;确认下载再发 text=下载1。", + "MP 搜索结果里,action=最佳 会展示当前评分最高候选,适合智能体省 token 决策。", + "MP 搜索结果里,text=下载最佳 会按当前最高分候选生成下载计划,不会静默下载。", + "说明:同一个 session 会自动串起候选列表、资源列表、115 待任务与扫码续跑。", + self._format_p115_next_actions(self._p115_status_snapshot()), + ] + pending_summary = self._pending_p115_summary(self._load_session(self._assistant_session_id(session_name)) or {}) + if pending_summary: + lines.extend(["", pending_summary]) + return "\n".join(line for line in lines if line) + + def _assistant_capabilities_public_data(self) -> Dict[str, Any]: + return { + "version": self.plugin_version, + "defaults": { + "hdhive_path": self._hdhive_default_path, + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "p115_client_type": self._p115_client_type, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "pt_min_seeders": self._default_assistant_preferences().get("pt_min_seeders"), + "auto_ingest": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_enabled": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_score_threshold": self._default_assistant_preferences().get("auto_ingest_score_threshold"), + "confirm_score_threshold": self._default_assistant_preferences().get("confirm_score_threshold"), + }, + "smart_entry": { + "supports_text": True, + "supports_structured_fields": True, + "modes": ["mp", "pansou", "hdhive"], + "actions": [ + "assistant_help", + "p115_qrcode_start", + "p115_qrcode_check", + "p115_status", + "p115_help", + "p115_pending", + "p115_resume", + "p115_cancel", + "hdhive_checkin", + "hdhive_checkin_history", + "mp_media_detail", + "mp_download_tasks", + "mp_download_history", + "mp_lifecycle_status", + "mp_download_control", + "mp_downloaders", + "mp_sites", + "mp_subscribes", + "mp_subscribe_control", + "mp_transfer_history", + "mp_download", + "mp_download_best", + "mp_subscribe", + "mp_subscribe_search", + "mp_recommendations", + "execute_plan", + "plans_list", + "plans_clear", + "scoring_policy", + "preferences_get", + "preferences_save", + "preferences_reset", + ], + "structured_fields": [ + "session", + "session_id", + "path", + "mode", + "keyword", + "url", + "access_code", + "media_type", + "year", + "client_type", + "is_gambler", + "action", + "plan_id", + "status", + "hash", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "downloader", + "download_control", + "subscribe_control", + "delete_files", + "page", + "compact", + ], + }, + "assistant_preferences": { + "fields": ["session", "session_id", "user_key", "preferences", "reset", "compact"], + "description": "智能体片源偏好画像:云盘与 PT 分源评分都会读取这里;无偏好时建议先完成一次偏好询问。", + }, + "scoring_policy": self._assistant_scoring_policy_public_data(), + "smart_pick": { + "fields": ["session", "session_id", "choice", "action", "path", "compact"], + "actions": ["detail", "next_page"], + }, + "assistant_session": { + "fields": ["session", "session_id", "compact"], + "description": "compact=true 时返回低 token 会话快照,不嵌套完整 session_state。", + }, + "assistant_capabilities": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 能力清单,不嵌套完整 session_state。", + }, + "assistant_readiness": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 就绪状态,不嵌套完整 session_state。", + }, + "assistant_sessions": { + "fields": ["kind", "has_pending_p115", "compact", "limit"], + "description": "compact=true 时返回低 token 会话列表,不嵌套 default session_state。", + }, + "assistant_history": { + "fields": ["session", "session_id", "compact", "limit"], + "description": "compact=true 时返回低 token 执行历史,不嵌套 default session_state。", + }, + "assistant_action": { + "fields": [ + "name", + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "source", + "status", + "hash", + "target", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "control", + "subscribe_control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "page", + "plan_id", + "prefer_unexecuted", + "compact", + ], + "description": "compact=true 时返回低 token 单动作摘要,不嵌套完整 session_state。", + }, + "assistant_actions": { + "fields": [ + "actions", + "session", + "session_id", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 批量执行摘要,不嵌套完整 session_state。", + }, + "assistant_workflow": self._assistant_workflow_catalog(), + "assistant_plan_execute": { + "fields": [ + "plan_id", + "session", + "session_id", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 计划执行摘要,不嵌套完整 session_state。", + }, + "assistant_plans": { + "fields": [ + "session", + "session_id", + "executed", + "include_actions", + "compact", + "limit", + ], + "description": "compact=true 时返回低 token 计划列表,不嵌套 default session_state。", + }, + "assistant_plans_clear": { + "fields": [ + "plan_id", + "session", + "session_id", + "executed", + "all_plans", + "limit", + ], + }, + "assistant_recover": { + "fields": [ + "session", + "session_id", + "execute", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + "limit", + ], + "description": "单入口恢复协议:不传 session 时自动挑选最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可返回低 token 回执。", + }, + "assistant_maintain": { + "fields": [ + "execute", + "limit", + ], + "description": "低风险维护入口:execute=false 只返回建议;execute=true 清理过期会话和已执行计划,不清理待执行计划。", + }, + "assistant_request_templates": { + "fields": [ + "limit", + ], + "description": "轻量请求模板入口:返回外部智能体常用 assistant 请求模板,适合缓存为调用说明。", + }, + "session_tools": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/toolbox", + "assistant/request_templates", + "assistant/selfcheck", + "assistant/readiness", + "assistant/history", + "assistant/action", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + "assistant/plans/clear", + "assistant/recover", + "assistant/sessions", + "assistant/sessions/clear", + "assistant/session", + "assistant/session/clear", + ], + "response_envelope": { + "fields": [ + "protocol_version", + "action", + "ok", + "session", + "session_id", + "session_state", + "next_actions", + "action_templates", + ], + "description": "assistant/route 与 assistant/pick 返回的 data 中会统一附带当前会话状态、建议下一步动作与可直接调用的动作模板,上层智能体可直接按结构化字段继续编排。", + }, + "agent_tools": [ + "agent_resource_officer_capabilities", + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_toolbox", + "agent_resource_officer_request_templates", + "agent_resource_officer_selfcheck", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_history", + "agent_resource_officer_execute_action", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_plans", + "agent_resource_officer_plans_clear", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_preferences", + "agent_resource_officer_help", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + "agent_resource_officer_sessions", + "agent_resource_officer_sessions_clear", + "agent_resource_officer_session_state", + "agent_resource_officer_session_clear", + ], + } + + def _assistant_capabilities_compact_data(self, capabilities_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(capabilities_data or {}) + workflow_catalog = dict(data.get("assistant_workflow") or {}) + workflows = [ + self._clean_text(item.get("name")) + for item in workflow_catalog.get("workflows") or [] + if isinstance(item, dict) and self._clean_text(item.get("name")) + ] + compact_endpoints = [ + "assistant/capabilities", + "assistant/startup", + "assistant/maintain", + "assistant/request_templates", + "assistant/readiness", + "assistant/recover", + "assistant/session", + "assistant/sessions", + "assistant/history", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + ] + return { + "protocol_version": "assistant.v1", + "action": "capabilities", + "ok": True, + "compact": True, + "version": data.get("version"), + "defaults": data.get("defaults") or {}, + "smart_entry_modes": (data.get("smart_entry") or {}).get("modes") or [], + "smart_entry_actions": (data.get("smart_entry") or {}).get("actions") or [], + "smart_pick_actions": (data.get("smart_pick") or {}).get("actions") or [], + "workflows": workflows, + "request_templates": bool(data.get("assistant_request_templates")), + "scoring_policy": data.get("scoring_policy") or {}, + "recommended_start": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/selfcheck", + "assistant/toolbox", + "assistant/request_templates", + "assistant/readiness?compact=true", + ], + "compact_endpoints": compact_endpoints, + "agent_tools": data.get("agent_tools") or [], + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_readiness", "smart_entry", "assistant_workflow"], + } + + def _format_assistant_capabilities_text(self) -> str: + data = self._assistant_capabilities_public_data() + defaults = data.get("defaults") or {} + lines = [ + "Agent影视助手 能力说明", + f"版本:{data.get('version')}", + "推荐上层调用顺序:", + "1. 先看 capabilities 或 assistant/startup", + "2. 如需恢复会话,可先看 assistant/sessions", + "3. 再调用 smart_entry", + "4. 之后用 assistant/session 或 session_state 判断下一步", + "5. 最后再调用 smart_pick 或 session_clear", + "默认目录:", + f"- 影巢:{defaults.get('hdhive_path')}", + f"- 115:{defaults.get('p115_path')}", + f"- 夸克:{defaults.get('quark_path')}", + f"- 115 客户端:{defaults.get('p115_client_type')}", + f"影巢资源入口:{'开启' if defaults.get('hdhive_resource_enabled') else '关闭'};单资源积分上限:{defaults.get('hdhive_max_unlock_points')} 分(0 表示不限制)", + "默认评分策略:", + f"- PT 最低做种数:{defaults.get('pt_min_seeders')}", + f"- 建议确认分数线:{defaults.get('confirm_score_threshold')}", + f"- 自动入库:{'开启' if defaults.get('auto_ingest_enabled') else '关闭'};自动入库分数线:{defaults.get('auto_ingest_score_threshold')}", + "启动聚合包:assistant/startup,一次返回 pulse、自检、核心工具、端点和恢复建议,适合外部智能体开场调用", + "轻量启动探针:assistant/pulse,返回版本、关键服务状态与最佳恢复建议,适合外部智能体每次开场调用", + "轻量工具清单:assistant/toolbox,返回推荐工具、端点、工作流和命令示例,适合外部智能体初始化系统提示", + "轻量协议自检:assistant/selfcheck,返回 compact 模板、布尔解析和低 token 入口健康状态", + "启动探针:assistant/readiness,可直接判断外部智能体是否可以开始调用;compact=true 可减少嵌套回执", + "执行历史:assistant/history,可查看最近 action/workflow 的成功状态和摘要;compact=true 可减少嵌套回执", + "smart_entry 结构化字段:session / session_id / path / mode / keyword / url / access_code / media_type / year / client_type / action / plan_id", + "smart_entry 结构化模式:mp / pansou / hdhive", + "smart_entry 动作:assistant_help / p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel", + "smart_entry 与 smart_pick 支持 compact=true,可减少搜索与选择链路嵌套回执", + "smart_pick 字段:session / session_id / choice / action / path / compact", + "smart_pick 动作:detail / next_page", + "动作执行入口:assistant/action,可直接执行 action_templates 里的 name + body;compact=true 可减少嵌套回执", + "批量动作入口:assistant/actions,可一次执行多步 action_body;compact=true 可减少嵌套回执", + "预设工作流入口:assistant/workflow,可用 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / share_transfer / p115_status 等短参数场景;compact=true 可减少嵌套回执", + "计划执行入口:assistant/plan/execute,可执行 dry_run 返回的 plan_id;compact=true 可减少嵌套回执", + "自然语言计划确认:smart_entry 支持“执行计划”和“执行 plan-xxxx”,用于确认已生成的下载、订阅或控制计划", + "计划管理入口:assistant/plans 与 assistant/plans/clear,可查询或清理 dry_run 保存的计划;compact=true 可减少嵌套回执", + "单入口恢复:assistant/recover,可自动选择最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可减少回执字段", + "统一回执字段:protocol_version / action / ok / session / session_id / session_state / next_actions / action_templates", + ] + return "\n".join(lines) + + def _assistant_readiness_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + sessions = self._assistant_sessions_public_data(limit=10) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_resource_enabled: + warnings.append("影巢资源搜索/解锁已关闭,外部智能体应改用 MP 搜索或盘搜") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置,影巢相关工作流不可用") + if not p115_status.get("ready"): + warnings.append("115 当前不可用,需要先扫码或修复执行层") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置,夸克转存可能需要先刷新") + + ready_for_external_agent = bool(self._enabled) + pending_plans = self._assistant_plans_public_data(executed=False, limit=5) + pending_plan_templates = [ + self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": item.get("plan_id"), + "session": item.get("session"), + "session_id": item.get("session_id"), + "prefer_unexecuted": True, + }, + ) + for item in (pending_plans.get("items") or []) + if isinstance(item, dict) and self._clean_text(item.get("plan_id")) + ] + return { + "version": self.plugin_version, + "enabled": self._enabled, + "ready_for_external_agent": ready_for_external_agent, + "can_start": ready_for_external_agent, + "services": { + "p115": p115_status, + "hdhive": { + "configured": bool(self._hdhive_api_key), + "base_url": self._hdhive_base_url, + "default_path": self._hdhive_default_path, + }, + "quark": { + "configured": bool(self._quark_cookie), + "default_path": self._quark_default_path, + "auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + }, + }, + "active_sessions": { + "total": sessions.get("total") or 0, + "preview": sessions.get("items") or [], + }, + "saved_plans": { + "total": len(self._workflow_plans or {}), + "pending": len(pending_plans.get("items") or []), + "pending_preview": pending_plans.get("items") or [], + "action_templates": pending_plan_templates, + }, + "recovery": ( + { + "mode": "resume_saved_plan", + "reason": "当前存在待执行计划,可直接恢复", + "can_resume": True, + "recommended_action": self._clean_text((pending_plan_templates[0] or {}).get("name")) if pending_plan_templates else "", + "recommended_tool": self._clean_text((pending_plan_templates[0] or {}).get("tool")) if pending_plan_templates else "", + "action_template": pending_plan_templates[0] if pending_plan_templates else None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in pending_plan_templates[:5] + if self._clean_text(item.get("name")) + ], + } + if pending_plan_templates + else { + "mode": "start_new", + "reason": "当前没有待恢复计划,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + "recommended_entrypoints": [ + "GET /api/v1/plugin/AgentResourceOfficer/assistant/startup", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/readiness", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/actions", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/route", + ], + "recommended_tools": [ + "agent_resource_officer_startup", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_run_workflow", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_smart_entry", + ], + "warnings": warnings, + "suggested_first_call": { + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "body": { + "name": "pansou_search", + "session": "external-agent-demo", + "keyword": "片名", + }, + }, + } + + def _assistant_readiness_compact_data(self, readiness_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(readiness_data or {}) + services = dict(data.get("services") or {}) + p115 = dict(services.get("p115") or {}) + hdhive = dict(services.get("hdhive") or {}) + quark = dict(services.get("quark") or {}) + recovery = dict(data.get("recovery") or {}) + saved_plans = dict(data.get("saved_plans") or {}) + template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + return { + "protocol_version": "assistant.v1", + "action": "readiness", + "ok": bool(data.get("can_start")), + "compact": True, + "version": data.get("version"), + "enabled": bool(data.get("enabled")), + "can_start": bool(data.get("can_start")), + "services": { + "p115_ready": bool(p115.get("ready")), + "hdhive_configured": bool(hdhive.get("configured")), + "quark_configured": bool(quark.get("configured")), + }, + "active_sessions_total": (data.get("active_sessions") or {}).get("total") or 0, + "saved_plans_total": saved_plans.get("total") or 0, + "saved_plans_pending": saved_plans.get("pending") or 0, + "recovery": { + "mode": self._clean_text(recovery.get("mode")), + "can_resume": bool(recovery.get("can_resume")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + "recommended_tool": self._clean_text(recovery.get("recommended_tool")), + "reason": self._clean_text(recovery.get("reason")), + }, + "warnings": data.get("warnings") or [], + "next_actions": [ + item for item in [ + recovery.get("recommended_action") if recovery.get("can_resume") else "", + "assistant_workflow", + "smart_entry", + ] + if item + ], + "action_templates": [template] if template else [], + } + + def _format_assistant_readiness_text(self) -> str: + data = self._assistant_readiness_public_data() + services = data.get("services") or {} + p115 = services.get("p115") or {} + hdhive = services.get("hdhive") or {} + quark = services.get("quark") or {} + lines = [ + "Agent影视助手 启动就绪", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"外部智能体:{'可以启动' if data.get('can_start') else '暂不可启动'}", + f"115:{'可用' if p115.get('ready') else '不可用'}", + f"影巢:{'已配置' if hdhive.get('configured') else '未配置'}", + f"夸克:{'已配置' if quark.get('configured') else '未配置'}", + f"活跃会话:{(data.get('active_sessions') or {}).get('total') or 0}", + f"待执行计划:{(data.get('saved_plans') or {}).get('pending') or 0}", + "推荐入口:assistant/workflow 或 assistant/actions", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_pulse_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + recovery_data = self._assistant_recover_public_data(limit=10) + recovery_data.update({ + "action": "recover", + "ok": True, + "execute_requested": False, + "executed": False, + }) + recovery_compact = self._assistant_recover_response_data(recovery_data, compact=True) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置") + if not p115_status.get("ready"): + warnings.append("115 当前不可用") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置") + return { + "protocol_version": "assistant.v1", + "action": "pulse", + "ok": bool(self._enabled), + "version": self.plugin_version, + "enabled": self._enabled, + "can_start": bool(self._enabled), + "services": { + "p115_ready": bool(p115_status.get("ready")), + "p115_direct_ready": bool(p115_status.get("direct_ready")), + "hdhive_configured": bool(self._hdhive_api_key), + "quark_configured": bool(self._quark_cookie), + }, + "warnings": warnings, + "session": recovery_compact.get("session"), + "session_id": recovery_compact.get("session_id"), + "recovery": recovery_compact.get("recovery") or {}, + "selected_session": recovery_compact.get("selected_session"), + "next_actions": recovery_compact.get("next_actions") or [], + "action_templates": recovery_compact.get("action_templates") or [], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + }, + } + + def _format_assistant_pulse_text(self) -> str: + data = self._assistant_pulse_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + lines = [ + "Agent影视助手 轻量启动状态", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'}", + f"影巢:{'已配置' if services.get('hdhive_configured') else '未配置'}", + f"夸克:{'已配置' if services.get('quark_configured') else '未配置'}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_maintenance_snapshot(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + pending_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and not bool(item.get("executed")) + ) + executed_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and bool(item.get("executed")) + ) + stale_session_count = sum( + 1 + for item in (self._session_cache or {}).values() + if isinstance(item, dict) and self._is_session_expired(item) + ) + action_templates = [ + self._assistant_action_template( + name="clear_stale_sessions", + description="清理过期 assistant 会话,不影响仍有效的当前会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"stale_only": True, "limit": max_limit}, + ), + self._assistant_action_template( + name="clear_executed_plans", + description="清理已执行的保存计划,不影响待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plans/clear", + tool="agent_resource_officer_plans_clear", + body={"executed": True, "limit": max_limit}, + ), + ] + recommended_actions = [ + item.get("name") + for item in action_templates + if ( + (item.get("name") == "clear_stale_sessions" and stale_session_count > 0) + or (item.get("name") == "clear_executed_plans" and executed_plan_count > 0) + ) + ] + return { + "active_sessions": len(self._session_cache or {}), + "stale_sessions": stale_session_count, + "saved_plans_total": len(self._workflow_plans or {}), + "saved_plans_pending": pending_plan_count, + "saved_plans_executed": executed_plan_count, + "recommended_actions": recommended_actions, + "action_templates": action_templates, + "safe_to_execute": bool(recommended_actions), + "dry_run_method": "GET", + "execute_method": "POST", + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "execute_body": {"execute": True, "limit": max_limit}, + "execution_note": "GET 只返回 dry-run;只有 POST execute=true 会实际清理,并写入 assistant/history。", + "limit": max_limit, + } + + def _assistant_maintain_public_data(self, execute: bool = False, limit: int = 100) -> Dict[str, Any]: + before = self._assistant_maintenance_snapshot(limit=limit) + executed_actions: List[Dict[str, Any]] = [] + if execute: + if before.get("stale_sessions", 0) > 0: + result = self._clear_assistant_sessions(stale_only=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_stale_sessions", + "success": True, + "removed": result.get("cleared_count") or 0, + }) + if before.get("saved_plans_executed", 0) > 0: + result = self._clear_workflow_plans(executed=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_executed_plans", + "success": bool(result.get("ok")), + "removed": result.get("removed") or 0, + }) + after = self._assistant_maintenance_snapshot(limit=limit) + return { + "protocol_version": "assistant.v1", + "action": "maintain", + "ok": True, + "compact": True, + "version": self.plugin_version, + "execute_requested": bool(execute), + "executed": bool(executed_actions), + "executed_actions": executed_actions, + "before": before, + "after": after, + "next_actions": after.get("recommended_actions") or [], + "action_templates": after.get("action_templates") or [], + } + + def _format_assistant_maintain_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_maintain_public_data(execute=False) + before = payload.get("before") or {} + after = payload.get("after") or before + lines = [ + "Agent影视助手 低风险维护", + f"版本:{payload.get('version')}", + f"执行:{'是' if payload.get('execute_requested') else '否'}", + "维护前:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**before), + "维护后:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**after), + ] + executed_actions = payload.get("executed_actions") or [] + if executed_actions: + lines.append("已执行:" + " / ".join(f"{item.get('name')}({item.get('removed')})" for item in executed_actions)) + recommended = after.get("recommended_actions") or [] + if recommended: + lines.append("仍建议:" + " / ".join(str(item) for item in recommended if item)) + return "\n".join(lines) + + def _assistant_startup_public_data(self) -> Dict[str, Any]: + pulse = self._assistant_pulse_public_data() + selfcheck = self._assistant_selfcheck_public_data() + toolbox = self._assistant_toolbox_public_data() + maintenance = self._assistant_maintenance_snapshot(limit=100) + request_templates = self._assistant_request_templates_public_data(limit=100) + tools = toolbox.get("tools") or {} + endpoints = toolbox.get("endpoints") or {} + key_names = [ + "startup", + "maintain", + "pulse", + "selfcheck", + "request_templates", + "recover", + "workflow", + "route", + "pick", + "execute_action", + "execute_actions", + ] + key_tools = {name: tools.get(name) for name in key_names if tools.get(name)} + key_endpoints = { + name: endpoints.get(name) + for name in ["startup", "maintain", "pulse", "selfcheck", "request_templates", "recover", "workflow", "action", "actions", "route", "pick"] + if endpoints.get(name) + } + recovery = pulse.get("recovery") or {} + recommended_templates_recipe = "continue" if bool(recovery.get("can_resume")) else "bootstrap" + recommended_templates_reason = ( + "检测到可恢复会话,优先读取继续会话流程。" + if recommended_templates_recipe == "continue" + else "未检测到必须恢复的会话,优先读取安全启动流程。" + ) + return { + "protocol_version": "assistant.v1", + "action": "startup", + "ok": bool(pulse.get("can_start")) and bool(selfcheck.get("ok")), + "compact": True, + "version": self.plugin_version, + "services": pulse.get("services") or {}, + "warnings": pulse.get("warnings") or [], + "session": pulse.get("session"), + "session_id": pulse.get("session_id"), + "recovery": pulse.get("recovery") or {}, + "selected_session": pulse.get("selected_session"), + "action_templates": pulse.get("action_templates") or [], + "maintenance": maintenance, + "selfcheck": { + "ok": bool(selfcheck.get("ok")), + "checks": selfcheck.get("checks") or {}, + }, + "defaults": toolbox.get("defaults") or {}, + "startup_order": toolbox.get("startup_order") or [], + "tools": key_tools, + "endpoints": key_endpoints, + "workflows": [item.get("name") for item in (toolbox.get("workflows") or []) if item.get("name")], + "actions": toolbox.get("actions") or [], + "command_examples": (toolbox.get("command_examples") or [])[:6], + "request_templates": request_templates, + "request_templates_schema_version": self.request_templates_schema_version, + "recommended_request_templates": self._assistant_recommended_request_templates_data( + recipe=recommended_templates_recipe, + reason=recommended_templates_reason, + ), + "next_actions": pulse.get("next_actions") or ["assistant_recover", "assistant_workflow", "smart_entry"], + "recommended_endpoints": key_endpoints, + } + + def _assistant_recommended_request_templates_data(self, recipe: str = "bootstrap", reason: str = "") -> Dict[str, Any]: + recipe_name = self._clean_text(recipe) or "bootstrap" + return { + "recipe": recipe_name, + "reason": self._clean_text(reason), + "include_templates": False, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/request_templates?apikey={MP_API_TOKEN}", + "tool": "agent_resource_officer_request_templates", + "tool_args": { + "recipe": recipe_name, + "include_templates": False, + }, + } + + def _format_assistant_startup_text(self) -> str: + data = self._assistant_startup_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + checks = (data.get("selfcheck") or {}).get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 启动聚合包", + f"版本:{data.get('version')}", + f"可启动:{'是' if data.get('ok') else '否'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'};影巢:{'已配' if services.get('hdhive_configured') else '未配'};夸克:{'已配' if services.get('quark_configured') else '未配'}", + f"自检:{'通过' if not failed else '失败 ' + ', '.join(failed)}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + f"可执行模板:{len(data.get('action_templates') or [])} 个", + "状态:活跃会话 {active_sessions};过期会话 {stale_sessions};保存计划 {saved_plans_total};待执行计划 {saved_plans_pending};已执行计划 {saved_plans_executed}".format(**(data.get("maintenance") or {})), + "下一步:优先按 recovery 建议执行;没有待恢复任务时使用 workflow 或 smart_entry", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + maintenance = data.get("maintenance") or {} + recommended_actions = maintenance.get("recommended_actions") or [] + if recommended_actions: + lines.append("维护建议:" + " / ".join(str(item) for item in recommended_actions if item)) + return "\n".join(lines) + + def _assistant_toolbox_public_data(self) -> Dict[str, Any]: + workflows = [dict(item or {}) for item in (self._assistant_workflow_catalog().get("workflows") or []) if isinstance(item, dict)] + return { + "protocol_version": "assistant.v1", + "action": "toolbox", + "ok": True, + "version": self.plugin_version, + "defaults": { + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "hdhive_path": self._hdhive_default_path, + "p115_client_type": self._p115_client_type, + }, + "startup_order": [ + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_selfcheck", + "agent_resource_officer_request_templates", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + ], + "endpoints": { + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "request_templates": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "action": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + "route": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "pick": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + }, + "tools": { + "startup": "agent_resource_officer_startup", + "maintain": "agent_resource_officer_maintain", + "pulse": "agent_resource_officer_pulse", + "toolbox": "agent_resource_officer_toolbox", + "request_templates": "agent_resource_officer_request_templates", + "selfcheck": "agent_resource_officer_selfcheck", + "recover": "agent_resource_officer_recover", + "workflow": "agent_resource_officer_run_workflow", + "route": "agent_resource_officer_smart_entry", + "pick": "agent_resource_officer_smart_pick", + "execute_action": "agent_resource_officer_execute_action", + "execute_actions": "agent_resource_officer_execute_actions", + }, + "workflows": [ + { + "name": item.get("name"), + "fields": item.get("fields") or [], + } + for item in workflows + ], + "actions": [ + "start_pansou_search", + "start_smart_resource_search", + "start_smart_resource_decision", + "start_smart_resource_plan", + "start_smart_resource_execute", + "pick_pansou_result", + "start_hdhive_search", + "pick_hdhive_candidate", + "candidate_detail", + "candidate_next_page", + "pick_hdhive_resource", + "route_share", + "start_115_login", + "check_115_login", + "show_115_status", + "resume_pending_115", + "execute_latest_plan", + "execute_session_latest_plan", + "query_mp_download_tasks", + "query_mp_download_history", + "query_mp_lifecycle_status", + "query_mp_ingest_status", + "query_execution_followup", + "query_mp_media_detail", + "query_mp_search_result_detail", + "query_mp_best_result_detail", + "pick_mp_best_download", + "query_mp_downloaders", + "query_mp_sites", + "query_mp_subscribes", + "query_mp_transfer_history", + "query_mp_ingest_failures", + "query_mp_recent_activity", + "query_mp_local_diagnose", + "clear_stale_sessions", + "clear_executed_plans", + ], + "command_examples": [ + "盘搜搜索 大君夫人", + "智能搜索 蜘蛛侠", + "影巢搜索 蜘蛛侠", + "1大君夫人", + "2蜘蛛侠", + "链接 https://pan.quark.cn/s/xxxx path=/飞书", + "选择 1", + "详情", + "下一页", + "识别 蜘蛛侠", + "115登录", + ], + "request_templates": self._assistant_request_templates_public_data(limit=100), + "request_templates_schema_version": self.request_templates_schema_version, + } + + def _assistant_request_templates_public_data(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + return { + "startup_probe": { + "description": "读取启动聚合包,适合外部智能体开场获取状态、端点、工具和恢复建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "tool": "agent_resource_officer_startup", + "tool_args": {}, + "query": {}, + }, + "selfcheck_probe": { + "description": "执行协议自检,确认模板、compact、布尔解析和核心入口是否健康。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "tool": "agent_resource_officer_selfcheck", + "tool_args": {}, + "query": {}, + }, + "maintain_preview": { + "description": "预览低风险维护建议,不执行清理;适合高频探测。", + "side_effect": "dry_run", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": False, "limit": max_limit}, + "query": {"execute": True, "limit": max_limit}, + }, + "maintain_execute": { + "description": "执行低风险维护,清理过期会话和已执行计划;会写入 assistant/history。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": True, "limit": max_limit}, + "body": {"execute": True, "limit": max_limit}, + }, + "preferences_get": { + "description": "读取智能体片源偏好画像;未初始化时应先询问用户偏好。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": {"session": "assistant", "compact": True}, + "query": {"session": "assistant", "compact": True}, + }, + "preferences_save": { + "description": "保存智能体片源偏好画像;影响云盘与 PT 评分、自动化建议和安全阈值。", + "side_effect": "state", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + "body": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + }, + "scoring_policy": { + "description": "读取插件内置云盘/PT 评分策略、硬门槛和 score_summary 使用约定;只读,可缓存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "medium_lived", + "cache_ttl_seconds": 3600, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "tool": "agent_resource_officer_capabilities", + "tool_args": {"compact": True}, + "query": {"compact": True}, + "response_field": "scoring_policy", + }, + "workflow_dry_run": { + "description": "生成并保存工作流计划,不实际执行;适合先让用户确认。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "static_template", + "cache_ttl_seconds": 3600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": { + "name": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + "body": { + "workflow": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + }, + "mp_search": { + "description": "执行 MP 原生搜索,返回 PT 候选与 PT 评分摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "smart_search": { + "description": "按用户偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只返回推荐意见,不直接下载或转存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_decision": { + "description": "按用户偏好统一执行盘搜 -> 影巢 -> MP/PT 决策,并给出查看详情、生成计划或直接执行的首选建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_plan": { + "description": "按用户偏好自动搜索并为当前首选生成待确认 plan_id,不直接执行下载、解锁或转存。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_execute": { + "description": "按用户偏好自动搜索当前首选并立即执行写入动作;仅在用户已明确要求直接执行时使用。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_media_detail": { + "description": "使用 MoviePilot 原生识别确认片名、年份、类型和 TMDB/Douban/IMDB ID;适合搜索前消歧。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_search_detail": { + "description": "执行 MP 原生搜索并查看指定编号的 PT 详情和评分理由;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + }, + "mp_search_best": { + "description": "执行 MP 原生搜索并展示当前评分最高的 PT 候选详情;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "mp_best_download_plan": { + "description": "在已有 MP 搜索会话中按当前最高分候选生成下载计划;不会直接下载。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + "body": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + }, + "mp_search_download_plan": { + "description": "MP 原生搜索并选择编号下载;写入动作默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_tasks": { + "description": "查询 MP 下载任务状态,可按下载中、等待、已暂停等状态过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_download_control_plan": { + "description": "暂停、恢复或删除 MP 下载任务;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_history": { + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_lifecycle_status": { + "description": "一次查询 MP 下载任务、下载历史和整理/入库历史,适合追踪资源当前卡在哪一步。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_ingest_status": { + "description": "按片名或 hash 输出下载到入库的当前阶段,并附带结构化 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_downloaders": { + "description": "查询 MP 下载器配置摘要,不返回密码、Cookie 或 Token。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_downloaders", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_downloaders", "session": "assistant", "compact": True}, + }, + "mp_sites": { + "description": "查询 MP PT 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + }, + "mp_subscribe_plan": { + "description": "按关键词创建 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribe_search_plan": { + "description": "创建 MP 订阅并立即触发搜索;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribes": { + "description": "查询 MP 订阅列表,可按状态、类型和关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_subscribe_control_plan": { + "description": "搜索、暂停、恢复或删除 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_transfer_history": { + "description": "查询 MP 最近整理/入库历史,判断下载后是否已经落库;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_ingest_failures": { + "description": "聚合最近整理/入库失败记录,并返回失败态 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_failed_samples": { + "description": "读取 AI 识别增强插件保存的失败样本,可按关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_worklist": { + "description": "读取 AI 失败样本工作清单,适合先挑需要二次识别重放的样本;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_insights": { + "description": "读取 AI 失败样本洞察,查看主要失败原因、重复样本组和优先处理项。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + }, + "ai_replay_failed_sample_plan": { + "description": "对指定 AI 失败样本生成二次识别重放计划;确认后才会真正执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_recent_activity": { + "description": "查看最近下载和最近入库活动,适合先发现目标再进入单资源追踪。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_local_diagnose": { + "description": "面向“为什么没入库/卡在哪”的一站式只读诊断入口。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_recommend": { + "description": "读取 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_recommend_search": { + "description": "读取 MP 原生推荐并按编号继续搜索;mode 可选 smart_decision、smart_plan、smart_execute、mp、hdhive、pansou。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + }, + "smart_discovery": { + "description": "读取 MP 原生热门推荐,并优先引导到统一资源决策链。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "saved_plan_execute": { + "description": "执行已保存的 dry_run 工作流计划,可按 session 自动选择未执行计划。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "tool": "agent_resource_officer_execute_plan", + "tool_args": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + "body": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + }, + "execution_followup": { + "description": "按最近已执行计划自动追踪下载、订阅或入库后续状态,由插件决定先查哪个只读动作。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + }, + "smart_followup": { + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "action_execute": { + "description": "按动作名执行单个 action template,适合无映射继续执行。", + "side_effect": "depends_on_action", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + }, + "route_text": { + "description": "统一自然语言入口,适合 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体直接转发用户文本。", + "side_effect": "depends_on_text", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + "body": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + }, + "pick_continue": { + "description": "按编号继续当前会话,适合盘搜、影巢候选或资源列表选择。", + "side_effect": "depends_on_session", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + "tool": "agent_resource_officer_smart_pick", + "tool_args": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + "body": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + }, + } + + def _assistant_request_template_names(self, value: Any) -> List[str]: + if isinstance(value, (list, tuple, set)): + rows = value + else: + rows = re.split(r"[,,\s]+", self._clean_text(value)) + names: List[str] = [] + for item in rows: + name = self._clean_text(item) + if name and name not in names: + names.append(name) + return names + + def _assistant_request_templates_response_data( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> Dict[str, Any]: + all_templates = self._assistant_request_templates_public_data(limit=limit) + recipe_templates_map = { + "safe_bootstrap": ["startup_probe", "selfcheck_probe", "maintain_preview"], + "plan_then_confirm": ["workflow_dry_run", "saved_plan_execute"], + "post_execute_followup": ["smart_followup", "execution_followup", "mp_download_history", "mp_lifecycle_status", "mp_subscribes", "mp_transfer_history"], + "continue_existing_session": ["pick_continue"], + "maintenance_cycle": ["maintain_preview", "maintain_execute"], + "external_agent_quickstart": ["startup_probe", "route_text", "pick_continue"], + "preferences_onboarding": ["preferences_get", "scoring_policy", "preferences_save"], + "smart_search": ["smart_search", "preferences_get", "scoring_policy"], + "smart_decision": ["smart_decision", "preferences_get", "scoring_policy"], + "smart_search_plan": ["smart_search_plan", "preferences_get", "scoring_policy", "saved_plan_execute"], + "smart_search_execute": ["smart_search_execute", "preferences_get", "scoring_policy", "post_execute_followup"], + "mp_pt_mainline": [ + "mp_media_detail", + "mp_search", + "mp_search_detail", + "mp_search_best", + "mp_search_download_plan", + "mp_best_download_plan", + "mp_download_tasks", + "mp_download_control_plan", + "mp_download_history", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_downloaders", + "mp_sites", + "mp_subscribe_plan", + "mp_subscribe_search_plan", + "mp_subscribes", + "mp_subscribe_control_plan", + "mp_transfer_history", + "mp_ingest_failures", + "mp_recent_activity", + "mp_local_diagnose", + "saved_plan_execute", + ], + "mp_recommendation": [ + "smart_discovery", + "mp_recommend", + "mp_recommend_search", + "mp_search", + "mp_search_best", + "mp_search_download_plan", + "saved_plan_execute", + ], + "ai_reingest_readonly": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + ], + "ai_reingest": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "ai_replay_failed_sample_plan", + "saved_plan_execute", + "execution_followup", + ], + "local_ingest": [ + "smart_followup", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_download_history", + "mp_transfer_history", + "mp_ingest_failures", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "mp_recent_activity", + "mp_local_diagnose", + ], + } + recipe_aliases = { + "bootstrap": "safe_bootstrap", + "safe": "safe_bootstrap", + "start": "safe_bootstrap", + "启动": "safe_bootstrap", + "plan": "plan_then_confirm", + "dry_run": "plan_then_confirm", + "confirm": "plan_then_confirm", + "计划": "plan_then_confirm", + "followup": "post_execute_followup", + "follow": "post_execute_followup", + "post_execute": "post_execute_followup", + "post-execute": "post_execute_followup", + "after_execute": "post_execute_followup", + "after-execute": "post_execute_followup", + "执行后": "post_execute_followup", + "执行后追踪": "post_execute_followup", + "后续追踪": "post_execute_followup", + "跟进": "post_execute_followup", + "进展": "post_execute_followup", + "continue": "continue_existing_session", + "pick": "continue_existing_session", + "resume": "continue_existing_session", + "继续": "continue_existing_session", + "选择": "continue_existing_session", + "maintain": "maintenance_cycle", + "maintenance": "maintenance_cycle", + "cleanup": "maintenance_cycle", + "维护": "maintenance_cycle", + "external_agent": "external_agent_quickstart", + "external-agent": "external_agent_quickstart", + "agent": "external_agent_quickstart", + "外部智能体": "external_agent_quickstart", + "微信智能体": "external_agent_quickstart", + "workbuddy": "external_agent_quickstart", + "work_buddy": "external_agent_quickstart", + "workbody": "external_agent_quickstart", + "work_body": "external_agent_quickstart", + "preferences": "preferences_onboarding", + "preference": "preferences_onboarding", + "prefs": "preferences_onboarding", + "pref": "preferences_onboarding", + "片源偏好": "preferences_onboarding", + "偏好画像": "preferences_onboarding", + "评分偏好": "preferences_onboarding", + "mp": "mp_pt_mainline", + "pt": "mp_pt_mainline", + "mp_native": "mp_pt_mainline", + "mp-native": "mp_pt_mainline", + "mp_pt": "mp_pt_mainline", + "mp-pt": "mp_pt_mainline", + "pt_mainline": "mp_pt_mainline", + "pt-mainline": "mp_pt_mainline", + "原生mp": "mp_pt_mainline", + "mp原生": "mp_pt_mainline", + "原生搜索": "mp_pt_mainline", + "pt下载": "mp_pt_mainline", + "下载订阅": "mp_pt_mainline", + "local_ingest": "local_ingest", + "local-ingest": "local_ingest", + "ingest": "local_ingest", + "local": "local_ingest", + "本地入库": "local_ingest", + "入库诊断": "local_ingest", + "ai_reingest": "ai_reingest", + "ai-reingest": "ai_reingest", + "失败样本": "ai_reingest_readonly", + "失败样本诊断": "ai_reingest_readonly", + "识别重放": "ai_reingest", + "recommend": "mp_recommendation", + "recommendation": "mp_recommendation", + "discover": "mp_recommendation", + "discovery": "mp_recommendation", + "智能发现": "mp_recommendation", + "热门发现": "mp_recommendation", + "mp_recommend": "mp_recommendation", + "mp-recommend": "mp_recommendation", + "推荐": "mp_recommendation", + "热门": "mp_recommendation", + "smart_search": "smart_search", + "smart-search": "smart_search", + "smart": "smart_search", + "智能搜索": "smart_search", + "smart_decision": "smart_decision", + "smart-decision": "smart_decision", + "decision": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart_search_plan": "smart_search_plan", + "smart-search-plan": "smart_search_plan", + "smartplan": "smart_search_plan", + "智能计划": "smart_search_plan", + "smart_search_execute": "smart_search_execute", + "smart-search-execute": "smart_search_execute", + "smartexecute": "smart_search_execute", + "智能执行": "smart_search_execute", + } + requested_recipe = self._clean_text(recipe) + selected_recipe = recipe_aliases.get(requested_recipe, requested_recipe) + invalid_recipe = requested_recipe if requested_recipe and selected_recipe not in recipe_templates_map else "" + selected_names = self._assistant_request_template_names(names) + if not selected_names and selected_recipe in recipe_templates_map: + selected_names = list(recipe_templates_map[selected_recipe]) + invalid_names = [name for name in selected_names if name not in all_templates] + templates = { + name: all_templates[name] + for name in selected_names + if name in all_templates + } if selected_names else all_templates + confirmation_required = [ + name + for name, item in templates.items() + if bool((item or {}).get("requires_confirmation")) + ] + safe_without_confirmation = [ + name + for name, item in templates.items() + if not bool((item or {}).get("requires_confirmation")) + ] + write_side_effects = [ + name + for name, item in templates.items() + if self._clean_text((item or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ] + cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) > 0 + ] + non_cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) <= 0 + ] + auth = { + "mode": "query_apikey", + "query_param": "apikey", + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}", + "description": "调用插件 HTTP 接口时推荐使用 ?apikey=你的MP_API_TOKEN;MP Tool 调用不需要此参数。", + } + external_agent_execution_policy_contract = { + "auto_continue": "直接执行 auto_run_command。", + "auto_continue_then_wait_confirmation": "先执行 auto_run_command,再停止并向用户展示 confirm_command。", + "wait_user_confirmation": "不要自动执行;先向用户展示 confirm_command 或 display_command。", + "show_only": "只展示 display_command,不要自动继续。", + "stop": "当前没有适合自动继续的命令,不要继续执行。", + } + external_agent_execution_loop_contract = [ + { + "step": "startup", + "template": "startup_probe", + "when": "新会话开始时先读取启动聚合包。", + }, + { + "step": "decide", + "template": "startup_probe", + "when": "结合恢复信息和推荐 recipe,确定先继续会话还是开始新流程。", + }, + { + "step": "route", + "template": "route_text", + "when": "处理用户的自然语言资源指令。", + }, + { + "step": "policy", + "template": "", + "when": "读取 recommended_agent_behavior、auto_run_command、confirm_command 决定自动继续、确认或停止。", + }, + { + "step": "followup", + "template": "execution_followup", + "when": "执行计划后继续追踪下载、入库或失败诊断。", + }, + ] + entry_patterns = { + "external_agent": { + "label": "外部智能体", + "client_role": "客户端调度层", + "start_with": "startup", + "decide_with": "decide --summary-only", + "route_with": "route --summary-only", + "followup_with": "followup --summary-only", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "notes": "适用于 WorkBuddy、Hermes、OpenClaw(小龙虾)等外部智能体;优先使用 Skill/helper。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体", + "client_role": "客户端调度层", + "start_with": "assistant/request_templates", + "decide_with": "agent_resource_officer_request_templates", + "route_with": "agent_resource_officer_smart_entry", + "followup_with": "agent_resource_officer_execution_followup", + "read_fields": [ + "recommended_recipe_detail", + "recommended_agent_behavior", + "preferred_command", + "compact_commands", + ], + "notes": "优先走 Agent Tool / request_templates,不在模型侧直拼底层资源 API。", + }, + "feishu_channel": { + "label": "飞书入口", + "client_role": "客户端消息入口", + "start_with": "feishu message -> route", + "decide_with": "插件内置命令解析", + "route_with": "route/pick/followup", + "followup_with": "followup / smart_followup", + "read_fields": [ + "recommended_agent_behavior", + "preferred_command", + "fallback_command", + "compact_commands", + ], + "notes": "飞书只负责把消息送进同一套 assistant 协议;确认策略与外部智能体保持一致。", + }, + } + orchestration_contract = { + "service_role": "Agent影视助手 / AgentResourceOfficer 负责服务端能力执行。", + "client_role": "外部智能体、MP 内置智能体、飞书入口负责客户端调度与展示。", + "recommended_first_call": "startup", + "recommended_decision_call": "decide --summary-only", + "recommended_route_call": "route --summary-only", + "recommended_followup_call": "followup --summary-only", + "recommended_read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "confirmation_rule": "写入动作默认确认制;只有明确标记可自动继续的只读步骤才自动续跑。", + } + entry_playbooks = { + "external_agent": { + "label": "外部智能体最小执行流", + "transport": "skill_helper_or_http", + "steps": [ + { + "step": "startup", + "helper_command": "python3 scripts/aro_request.py startup", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "读取启动状态、恢复建议和推荐 recipe。", + "read_fields": ["recommended_request_templates", "recovery", "services"], + }, + { + "step": "decide", + "helper_command": "python3 scripts/aro_request.py decide --summary-only", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "决定继续会话、初始化偏好还是直接进入 route。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "confirm_command"], + }, + { + "step": "route", + "helper_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/route?apikey={MP_API_TOKEN}", + }, + "purpose": "执行自然语言主入口。", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "preferred_command", + "compact_commands", + ], + }, + { + "step": "followup", + "helper_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/action?apikey={MP_API_TOKEN}", + }, + "purpose": "执行计划后继续追踪下载、入库或失败诊断。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "followup_summary"], + }, + ], + "auto_rule": "优先读取 recommended_agent_behavior;只读步骤可自动续跑,写入步骤默认确认。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体最小执行流", + "transport": "moviepilot_agent_tool", + "steps": [ + { + "step": "request_templates", + "tool": "agent_resource_officer_request_templates", + "tool_args": {"recipe": "external_agent", "compact": True}, + "purpose": "读取最小流程、确认策略和推荐入口。", + "read_fields": ["orchestration_contract", "entry_patterns", "recommended_recipe_detail"], + }, + { + "step": "route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": {"text": "<用户原始指令>"}, + "purpose": "处理搜索、链接、登录状态等主入口。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "followup", + "tool": "agent_resource_officer_execution_followup", + "tool_args": {}, + "purpose": "执行计划后继续查看下载、入库和失败状态。", + "read_fields": ["followup_summary", "preferred_command", "compact_commands"], + }, + ], + "auto_rule": "依然遵守服务端 compact 协议;不要在模型侧拼底层资源站点 API。", + }, + "feishu_channel": { + "label": "飞书入口最小执行流", + "transport": "feishu_channel", + "steps": [ + { + "step": "message_in", + "channel": "feishu", + "purpose": "用户消息进入内置 Channel。", + "read_fields": ["session", "text", "reply_target"], + }, + { + "step": "route", + "internal": "route / pick / followup", + "purpose": "复用同一套 assistant 协议,不维护单独状态机。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "reply", + "channel": "feishu", + "purpose": "按确认策略回消息、展示编号或提示下一步。", + "read_fields": ["display_command", "confirm_command", "message"], + }, + ], + "auto_rule": "飞书只负责消息承载;写入动作依然由插件服务端统一确认。", + }, + } + recommended_sequence = [ + { + "step": "bootstrap", + "template": "startup_probe", + "when": "每次新会话开始时先读取启动聚合包。", + }, + { + "step": "healthcheck", + "template": "selfcheck_probe", + "when": "当外部智能体需要确认协议健康或怀疑环境变化时执行。", + }, + { + "step": "maintenance_preview", + "template": "maintain_preview", + "when": "长会话或多轮执行前先看是否有低风险维护建议。", + }, + { + "step": "plan", + "template": "workflow_dry_run", + "when": "需要先生成计划、等待用户确认或减少重复大 JSON 时使用。", + }, + { + "step": "execute_saved_plan", + "template": "saved_plan_execute", + "when": "已确认 dry_run 计划后执行。", + }, + { + "step": "post_execute_followup", + "template": "execution_followup", + "when": "计划执行成功后,自动决定先查下载历史、生命周期、订阅或入库历史。", + }, + { + "step": "continue_session", + "template": "pick_continue", + "when": "盘搜、影巢候选或资源列表需要按编号继续时使用。", + }, + { + "step": "preferences_onboarding", + "template": "preferences_get", + "when": "外部智能体首次接入时,先读取偏好和评分策略,再保存用户片源偏好。", + }, + { + "step": "mp_pt_mainline", + "template": "mp_search", + "when": "需要 MP 原生 PT 搜索、评分、下载计划、订阅或任务追踪时使用。", + }, + { + "step": "mp_recommendation", + "template": "mp_recommend", + "when": "需要 TMDB、豆瓣、Bangumi 等 MP 原生推荐,并继续搜索时使用。", + }, + ] + recipes = [ + { + "name": "safe_bootstrap", + "description": "新会话安全启动:先拿启动聚合包,再自检,再看维护建议。", + "templates": recipe_templates_map["safe_bootstrap"], + }, + { + "name": "plan_then_confirm", + "description": "先生成计划,等待用户确认后再执行保存计划。", + "templates": recipe_templates_map["plan_then_confirm"], + }, + { + "name": "post_execute_followup", + "description": "执行计划后的统一只读跟踪:由插件自动选择最合适的后续查询动作。", + "templates": recipe_templates_map["post_execute_followup"], + }, + { + "name": "continue_existing_session", + "description": "已有盘搜、影巢或资源会话时,直接按编号继续。", + "templates": recipe_templates_map["continue_existing_session"], + }, + { + "name": "maintenance_cycle", + "description": "先预览维护建议,再在确认后执行低风险维护。", + "templates": recipe_templates_map["maintenance_cycle"], + }, + { + "name": "external_agent_quickstart", + "description": "外部智能体接入:启动探测后,把用户文本交给统一入口,再按编号继续。", + "templates": recipe_templates_map["external_agent_quickstart"], + }, + { + "name": "preferences_onboarding", + "description": "首次接入时先读取偏好、评分策略,再保存用户的片源偏好画像。", + "templates": recipe_templates_map["preferences_onboarding"], + }, + { + "name": "smart_decision", + "description": "统一搜索并返回明确的下一步决策:查看详情、生成计划或直接执行。", + "templates": recipe_templates_map["smart_decision"], + }, + { + "name": "smart_search_plan", + "description": "统一搜索决策后,直接为当前首选生成待确认计划;仍需后续执行计划才会真正写入。", + "templates": recipe_templates_map["smart_search_plan"], + }, + { + "name": "smart_search_execute", + "description": "统一搜索决策后,直接执行当前首选写入动作;只适用于用户明确要求立即执行的场景。", + "templates": recipe_templates_map["smart_search_execute"], + }, + { + "name": "mp_pt_mainline", + "description": "MP 原生 PT 主线:识别、搜索、评分、下载计划、任务、订阅、站点和入库追踪。", + "templates": recipe_templates_map["mp_pt_mainline"], + }, + { + "name": "mp_recommendation", + "description": "MP 原生推荐主线:读取热门推荐,再按编号进入 MP、影巢或盘搜搜索。", + "templates": recipe_templates_map["mp_recommendation"], + }, + ] + recipe_summaries: List[Dict[str, Any]] = [] + for recipe in recipes: + template_names = [ + self._clean_text(item) + for item in (recipe.get("templates") or []) + if self._clean_text(item) + ] + current_templates = [ + templates[name] + for name in template_names + if isinstance(templates.get(name), dict) + ] + recipe_summaries.append({ + **recipe, + "requires_confirmation": any(bool(item.get("requires_confirmation")) for item in current_templates), + "has_write_effect": any( + self._clean_text(item.get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + for item in current_templates + ), + "cache_ttl_seconds": min( + [self._safe_int(item.get("cache_ttl_seconds"), 0) for item in current_templates] or [0] + ), + }) + recommended_recipe = "safe_bootstrap" + recommended_recipe_reason = "默认优先安全启动,先读取启动聚合包、自检并查看维护建议。" + selected_set = set(selected_names or []) + if selected_recipe in recipe_templates_map: + recommended_recipe = selected_recipe + recommended_recipe_reason = f"当前请求显式指定 recipe={selected_recipe}。" + elif {"workflow_dry_run", "saved_plan_execute"} & selected_set: + recommended_recipe = "plan_then_confirm" + recommended_recipe_reason = "当前模板集合包含 dry_run 或执行保存计划,更适合先计划后确认。" + elif "pick_continue" in selected_set: + recommended_recipe = "continue_existing_session" + recommended_recipe_reason = "当前模板集合包含 pick_continue,说明更像继续既有会话。" + elif {"preferences_get", "preferences_save", "scoring_policy"} & selected_set: + recommended_recipe = "preferences_onboarding" + recommended_recipe_reason = "当前模板集合包含偏好或评分策略模板,优先推荐偏好初始化流程。" + elif {"maintain_preview", "maintain_execute"} & selected_set: + recommended_recipe = "maintenance_cycle" + recommended_recipe_reason = "当前模板集合包含维护模板,优先推荐维护流程。" + recommended_recipe_detail = next( + (item for item in recipe_summaries if item.get("name") == recommended_recipe), + {}, + ) + recommended_recipe_templates = [ + name + for name in (recommended_recipe_detail.get("templates") or []) + if name in all_templates + ] + first_template = recommended_recipe_templates[0] if recommended_recipe_templates else "" + first_template_data = all_templates.get(first_template) or {} + recommended_recipe_calls = [] + for template_name in recommended_recipe_templates: + template_data = all_templates.get(template_name) or {} + if not template_data: + continue + recommended_recipe_calls.append({ + "template": template_name, + "auth": auth, + "method": template_data.get("method"), + "endpoint": template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(template_data.get("endpoint")), + ), + "query": template_data.get("query") or {}, + "body": template_data.get("body") or {}, + "tool": template_data.get("tool"), + "tool_args": template_data.get("tool_args") or {}, + "requires_confirmation": bool(template_data.get("requires_confirmation")), + "side_effect": template_data.get("side_effect"), + }) + recommended_recipe_detail = { + **recommended_recipe_detail, + "templates": recommended_recipe_templates, + "first_template": first_template, + "confirmation_required_templates": [ + name + for name in recommended_recipe_templates + if bool((all_templates.get(name) or {}).get("requires_confirmation")) + ], + "write_templates": [ + name + for name in recommended_recipe_templates + if self._clean_text((all_templates.get(name) or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ], + "first_call": { + "template": first_template, + "auth": auth, + "method": first_template_data.get("method"), + "endpoint": first_template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(first_template_data.get("endpoint")), + ), + "query": first_template_data.get("query") or {}, + "body": first_template_data.get("body") or {}, + "tool": first_template_data.get("tool"), + "tool_args": first_template_data.get("tool_args") or {}, + "requires_confirmation": bool(first_template_data.get("requires_confirmation")), + "side_effect": first_template_data.get("side_effect"), + } if first_template_data else {}, + "calls": recommended_recipe_calls, + } + if recommended_recipe == "external_agent_quickstart": + recommended_recipe_detail["execution_policy_contract"] = external_agent_execution_policy_contract + recommended_recipe_detail["execution_loop_contract"] = external_agent_execution_loop_contract + recommended_recipe_detail["entry_patterns"] = entry_patterns + recommended_recipe_detail["orchestration_contract"] = orchestration_contract + recommended_recipe_detail["entry_playbooks"] = entry_playbooks + confirmation_templates = recommended_recipe_detail.get("confirmation_required_templates") or [] + recommended_recipe_detail["first_confirmation_template"] = confirmation_templates[0] if confirmation_templates else "" + recommended_recipe_detail["confirmation_message"] = ( + f"执行 {recommended_recipe} 的 {recommended_recipe_detail['first_confirmation_template']} 前需要用户确认。" + if confirmation_templates + else f"{recommended_recipe} 当前推荐流程无需用户确认。" + ) + return { + "protocol_version": "assistant.v1", + "action": "request_templates", + "ok": True, + "compact": True, + "version": self.plugin_version, + "schema_version": self.request_templates_schema_version, + "auth": auth, + "templates_included": bool(include_templates), + "request_templates": templates if include_templates else {}, + "available_names": list(all_templates.keys()), + "available_recipes": list(recipe_templates_map.keys()), + "recipe_aliases": recipe_aliases, + "selected_names": selected_names, + "invalid_names": invalid_names, + "requested_recipe": requested_recipe, + "selected_recipe": selected_recipe if selected_recipe in recipe_templates_map else "", + "invalid_recipe": invalid_recipe, + "execution_policy": { + "safe_without_confirmation": safe_without_confirmation, + "confirmation_required": confirmation_required, + "write_side_effects": write_side_effects, + "cacheable_templates": cacheable_templates, + "non_cacheable_templates": non_cacheable_templates, + }, + "recommended_sequence": recommended_sequence, + "recipes": recipe_summaries, + "recommended_recipe": recommended_recipe, + "recommended_recipe_reason": recommended_recipe_reason, + "recommended_recipe_detail": recommended_recipe_detail, + "external_agent_execution_policy_contract": external_agent_execution_policy_contract, + "external_agent_execution_loop_contract": external_agent_execution_loop_contract, + "entry_patterns": entry_patterns, + "orchestration_contract": orchestration_contract, + "entry_playbooks": entry_playbooks, + } + + def _format_assistant_request_templates_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_request_templates_response_data() + templates = payload.get("request_templates") or {} + lines = [ + "Agent影视助手 请求模板", + f"版本:{payload.get('version')}", + ] + detail = payload.get("recommended_recipe_detail") or {} + first_call = detail.get("first_call") or {} + if payload.get("recommended_recipe"): + lines.append(f"推荐流程:{payload.get('recommended_recipe')}") + if detail.get("first_template"): + lines.append( + "首步:{template} -> {method} {endpoint}".format( + template=detail.get("first_template"), + method=first_call.get("method") or "", + endpoint=first_call.get("endpoint") or "", + ).strip() + ) + if detail.get("confirmation_message"): + lines.append(f"确认提示:{detail.get('confirmation_message')}") + if detail.get("execution_policy_contract"): + lines.append("外部智能体执行分支:" + " / ".join(str(key) for key in (detail.get("execution_policy_contract") or {}).keys())) + if detail.get("execution_loop_contract"): + lines.append( + "外部智能体最小循环:" + + " -> ".join(self._clean_text(item.get("step")) for item in (detail.get("execution_loop_contract") or []) if self._clean_text(item.get("step"))) + ) + orchestration_contract = payload.get("orchestration_contract") or detail.get("orchestration_contract") or {} + if orchestration_contract: + lines.append( + "最小执行流:{startup} -> {decide} -> {route} -> policy -> {followup}".format( + startup=self._clean_text(orchestration_contract.get("recommended_first_call")) or "startup", + decide=self._clean_text(orchestration_contract.get("recommended_decision_call")) or "decide --summary-only", + route=self._clean_text(orchestration_contract.get("recommended_route_call")) or "route --summary-only", + followup=self._clean_text(orchestration_contract.get("recommended_followup_call")) or "followup --summary-only", + ) + ) + read_fields = [ + self._clean_text(item) + for item in (orchestration_contract.get("recommended_read_fields") or []) + if self._clean_text(item) + ] + if read_fields: + lines.append("优先读取字段:" + " / ".join(read_fields)) + entry_patterns = payload.get("entry_patterns") or detail.get("entry_patterns") or {} + for key in ["external_agent", "mp_builtin_agent", "feishu_channel"]: + item = entry_patterns.get(key) or {} + if not item: + continue + lines.append( + "{label}:{start} -> {route}".format( + label=self._clean_text(item.get("label")) or key, + start=self._clean_text(item.get("start_with")) or "", + route=self._clean_text(item.get("route_with")) or "", + ) + ) + entry_playbooks = payload.get("entry_playbooks") or detail.get("entry_playbooks") or {} + external_playbook = (entry_playbooks.get("external_agent") or {}).get("steps") or [] + if external_playbook: + lines.append( + "外部智能体脚手架:" + + " -> ".join( + self._clean_text(item.get("step")) + for item in external_playbook + if self._clean_text(item.get("step")) + ) + ) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "pick_continue", + ]: + item = templates.get(name) or {} + if item: + tool = self._clean_text(item.get("tool")) + suffix = f" -> {tool}" if tool else "" + lines.append(f"{name}: {item.get('method')} {item.get('endpoint')}{suffix}") + return "\n".join(lines) + + def _format_assistant_toolbox_text(self) -> str: + data = self._assistant_toolbox_public_data() + workflows = data.get("workflows") or [] + lines = [ + "Agent影视助手 轻量工具清单", + f"版本:{data.get('version')}", + "推荐启动顺序:" + " -> ".join(str(item) for item in (data.get("startup_order") or [])[:5]), + "常用工作流:" + " / ".join(str(item.get("name")) for item in workflows if item.get("name")), + "默认目录:115={p115_path};夸克={quark_path};影巢={hdhive_path}".format(**(data.get("defaults") or {})), + ] + return "\n".join(lines) + + def _assistant_selfcheck_public_data(self) -> Dict[str, Any]: + action_template = self._assistant_action_template( + name="show_115_status", + description="自检模板:查看 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={"session": "selfcheck"}, + ) + route_template = self._assistant_action_template( + name="start_hdhive_search", + description="自检模板:发起影巢搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={"session": "selfcheck", "keyword": "蜘蛛侠", "media_type": "movie"}, + ) + bool_cases = { + "true": self._parse_bool_value("true", False), + "false": self._parse_bool_value("false", True), + "one": self._parse_bool_value("1", False), + "zero": self._parse_bool_value("0", True), + "off": self._parse_bool_value("off", True), + "default_true": self._parse_bool_value(None, True), + "default_false": self._parse_bool_value(None, False), + } + compact_templates_ok = all([ + (action_template.get("body") or {}).get("compact") is True, + (action_template.get("action_body") or {}).get("compact") is True, + (route_template.get("body") or {}).get("compact") is True, + (route_template.get("action_body") or {}).get("compact") is True, + ]) + bool_parse_ok = ( + bool_cases["true"] is True + and bool_cases["false"] is False + and bool_cases["one"] is True + and bool_cases["zero"] is False + and bool_cases["off"] is False + and bool_cases["default_true"] is True + and bool_cases["default_false"] is False + ) + pulse = self._assistant_pulse_public_data() + toolbox = self._assistant_toolbox_public_data() + startup_request_templates = self._assistant_recommended_request_templates_data() + startup_continue_request_templates = self._assistant_recommended_request_templates_data( + recipe="continue", + reason="selfcheck", + ) + maintain = self._assistant_maintain_public_data(execute=False) + request_templates = self._assistant_request_templates_public_data(limit=100) + filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute,missing_template", + ) + recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="startup_probe,maintain_preview,workflow_dry_run,saved_plan_execute,pick_continue,maintain_execute", + include_templates=False, + ) + recipe_filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="plan", + include_templates=False, + ) + external_recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="external_agent", + include_templates=False, + ) + policy_only_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute", + include_templates=False, + ) + maintenance_templates = maintain.get("action_templates") or [] + maintenance_template_names = { + self._clean_text(item.get("name")) + for item in maintenance_templates + if isinstance(item, dict) + } + maintenance_templates_compact_ok = all( + (item.get("body") or {}).get("compact") is True + and (item.get("action_body") or {}).get("compact") is True + for item in maintenance_templates + if isinstance(item, dict) + ) + maintain_dry_run_ok = ( + maintain.get("action") == "maintain" + and maintain.get("execute_requested") is False + and maintain.get("executed") is False + and {"clear_stale_sessions", "clear_executed_plans"}.issubset(maintenance_template_names) + ) + protocol_ok = ( + pulse.get("protocol_version") == "assistant.v1" + and toolbox.get("protocol_version") == "assistant.v1" + and pulse.get("action") == "pulse" + and toolbox.get("action") == "toolbox" + and maintain.get("protocol_version") == "assistant.v1" + ) + startup_request_templates_ok = ( + startup_request_templates.get("recipe") == "bootstrap" + and startup_request_templates.get("include_templates") is False + and startup_request_templates.get("tool") == "agent_resource_officer_request_templates" + and "{MP_API_TOKEN}" in self._clean_text(startup_request_templates.get("url_template")) + and startup_continue_request_templates.get("recipe") == "continue" + and ((startup_continue_request_templates.get("tool_args") or {}).get("recipe")) == "continue" + and bool(self._clean_text(startup_continue_request_templates.get("reason"))) + ) + request_templates_ok = all( + isinstance(request_templates.get(name), dict) + and self._clean_text((request_templates.get(name) or {}).get("endpoint")) + and self._clean_text((request_templates.get(name) or {}).get("method")) + and self._clean_text((request_templates.get(name) or {}).get("tool")) + and self._clean_text((request_templates.get(name) or {}).get("description")) + and self._clean_text((request_templates.get(name) or {}).get("side_effect")) + and isinstance((request_templates.get(name) or {}).get("requires_confirmation"), bool) + and self._clean_text((request_templates.get(name) or {}).get("cache_scope")) + and isinstance((request_templates.get(name) or {}).get("cache_ttl_seconds"), int) + and isinstance((request_templates.get(name) or {}).get("tool_args"), dict) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "route_text", + "pick_continue", + ] + ) + request_templates_filter_ok = ( + list((filtered_request_templates.get("request_templates") or {}).keys()) == ["maintain_execute"] + and filtered_request_templates.get("selected_names") == ["maintain_execute", "missing_template"] + and filtered_request_templates.get("invalid_names") == ["missing_template"] + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("body") or {}).get("limit") == 5 + ) + request_templates_policy_ok = ( + "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("write_side_effects") or []) + ) + request_templates_schema_ok = filtered_request_templates.get("schema_version") == self.request_templates_schema_version + request_templates_cache_ok = ( + ((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_scope") == "no_cache" + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_ttl_seconds")) == 0 + ) + request_templates_sequence_ok = ( + isinstance(filtered_request_templates.get("recommended_sequence"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "startup_probe" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "saved_plan_execute" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + ) + request_templates_recipes_ok = ( + isinstance(recipe_request_templates.get("recipes"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "safe_bootstrap" + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "plan_then_confirm" + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recipe_summary_ok = ( + any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "safe_bootstrap" + and item.get("requires_confirmation") is False + and item.get("has_write_effect") is False + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "plan_then_confirm" + and item.get("requires_confirmation") is True + and item.get("has_write_effect") is True + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recommended_recipe_ok = ( + filtered_request_templates.get("recommended_recipe") == "maintenance_cycle" + and bool(self._clean_text(filtered_request_templates.get("recommended_recipe_reason"))) + and recipe_request_templates.get("recommended_recipe") == "plan_then_confirm" + ) + request_templates_recipe_filter_ok = ( + recipe_filtered_request_templates.get("requested_recipe") == "plan" + and recipe_filtered_request_templates.get("selected_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("selected_names") == ["workflow_dry_run", "saved_plan_execute"] + and recipe_filtered_request_templates.get("recommended_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("templates_included") is False + and "plan_then_confirm" in (recipe_filtered_request_templates.get("available_recipes") or []) + and ((recipe_filtered_request_templates.get("recipe_aliases") or {}).get("plan")) == "plan_then_confirm" + ) + recommended_recipe_detail = filtered_request_templates.get("recommended_recipe_detail") or {} + request_templates_recommended_recipe_detail_ok = ( + recommended_recipe_detail.get("name") == "maintenance_cycle" + and recommended_recipe_detail.get("first_template") == "maintain_preview" + and "maintain_execute" in (recommended_recipe_detail.get("confirmation_required_templates") or []) + and "maintain_execute" in (recommended_recipe_detail.get("write_templates") or []) + and recommended_recipe_detail.get("first_confirmation_template") == "maintain_execute" + and "maintain_execute" in self._clean_text(recommended_recipe_detail.get("confirmation_message")) + and ((recommended_recipe_detail.get("first_call") or {}).get("template")) == "maintain_preview" + and (((recommended_recipe_detail.get("first_call") or {}).get("auth") or {}).get("mode")) == "query_apikey" + and self._clean_text((recommended_recipe_detail.get("first_call") or {}).get("url_template")).endswith( + "/assistant/maintain?apikey={MP_API_TOKEN}" + ) + and ((recommended_recipe_detail.get("first_call") or {}).get("method")) == "GET" + and ((recommended_recipe_detail.get("first_call") or {}).get("tool")) == "agent_resource_officer_maintain" + and [ + (item or {}).get("template") + for item in (recommended_recipe_detail.get("calls") or []) + ] == ["maintain_preview", "maintain_execute"] + and all( + (((item or {}).get("auth") or {}).get("query_param")) == "apikey" + for item in (recommended_recipe_detail.get("calls") or []) + ) + and all( + "{MP_API_TOKEN}" in self._clean_text((item or {}).get("url_template")) + for item in (recommended_recipe_detail.get("calls") or []) + ) + ) + request_templates_policy_only_ok = ( + policy_only_request_templates.get("templates_included") is False + and (policy_only_request_templates.get("request_templates") or {}) == {} + and policy_only_request_templates.get("selected_names") == ["maintain_execute"] + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("non_cacheable_templates") or []) + ) + external_recipe_detail = external_recipe_request_templates.get("recommended_recipe_detail") or {} + request_templates_external_agent_contract_ok = ( + external_recipe_request_templates.get("selected_recipe") == "external_agent_quickstart" + and external_recipe_request_templates.get("recommended_recipe") == "external_agent_quickstart" + and bool((external_recipe_request_templates.get("external_agent_execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_request_templates.get("external_agent_execution_loop_contract") or []) >= 5 + and ((external_recipe_request_templates.get("orchestration_contract") or {}).get("recommended_first_call")) == "startup" + and bool(((external_recipe_request_templates.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with")) + and len((((external_recipe_request_templates.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or [])) >= 4 + and bool((external_recipe_detail.get("execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_detail.get("execution_loop_contract") or []) >= 5 + and ((external_recipe_detail.get("orchestration_contract") or {}).get("recommended_route_call")) == "route --summary-only" + and bool(((external_recipe_detail.get("entry_patterns") or {}).get("feishu_channel") or {}).get("route_with")) + and bool(((((external_recipe_detail.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool")) + and any( + self._clean_text(item.get("step")) == "policy" + for item in (external_recipe_detail.get("execution_loop_contract") or []) + if isinstance(item, dict) + ) + ) + start_new_recovery = self._assistant_recovery_public_data( + session_state={"has_session": False}, + action_templates=[{"name": "start_pansou_search", "tool": "agent_resource_officer_smart_entry"}], + ) + start_new_recovery_ok = ( + start_new_recovery.get("mode") == "start_new" + and start_new_recovery.get("can_resume") is False + and start_new_recovery.get("recommended_action") == "start_pansou_search" + ) + executed_plan_recovery = self._assistant_recovery_public_data( + session_state={ + "has_session": True, + "session": "selfcheck", + "session_id": "assistant::selfcheck", + "saved_plan": { + "has_pending": False, + "has_plan": True, + "latest": { + "plan_id": "plan-selfcheck-executed", + "executed": True, + }, + }, + }, + ) + executed_plan_recovery_ok = ( + executed_plan_recovery.get("mode") == "followup_executed_plan" + and executed_plan_recovery.get("can_resume") is True + and executed_plan_recovery.get("recommended_action") == "query_execution_followup" + ) + execute_plan_followup_samples = { + workflow: self._assistant_plan_execute_followup( + workflow=workflow, + session="selfcheck", + session_id="selfcheck-session", + session_state={ + "keyword": keyword, + "session": "selfcheck", + "session_id": "selfcheck-session", + }, + ok=True, + plan_id=f"plan-selfcheck-{workflow}", + ) + for workflow, keyword in [ + ("mp_best_download", "蜘蛛侠"), + ("mp_subscribe", "钢铁侠"), + ("hdhive_unlock_selected", "复仇者联盟"), + ("ai_replay_failed_sample", "地狱乐"), + ] + } + execute_plan_followups_ok = ( + [item.get("name") for item in (execute_plan_followup_samples.get("mp_best_download") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_ingest_status", "query_mp_download_history", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("mp_best_download") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_best_download") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_best_download") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("mp_subscribe") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + and (execute_plan_followup_samples.get("mp_subscribe") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_subscribe") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_subscribe") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("action_templates") or []] + == ["query_mp_local_diagnose", "query_mp_ingest_status", "query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + and (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("recommended_action") == "query_mp_local_diagnose" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("preferred_command") == "诊断" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("fallback_command") == "入库状态" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("recommended_agent_behavior") == "auto_continue" + ) + ai_replay_execution_summary_resolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=True, + has_title=True, + ) + ai_replay_execution_summary_unresolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_failed = self._assistant_ai_replay_execution_decision_summary( + ok=False, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_ok = ( + ai_replay_execution_summary_resolved.get("preferred_command") == "诊断" + and ai_replay_execution_summary_resolved.get("recommended_agent_behavior") == "auto_continue" + and ai_replay_execution_summary_unresolved.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_unresolved.get("fallback_command") == "样本洞察" + and ai_replay_execution_summary_failed.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_failed.get("recommended_agent_behavior") == "show_only" + ) + checks = { + "compact_templates": compact_templates_ok, + "bool_parser": bool_parse_ok, + "protocol": protocol_ok, + "maintain_dry_run": maintain_dry_run_ok, + "maintenance_templates_compact": maintenance_templates_compact_ok, + "request_templates": request_templates_ok, + "request_templates_filter": request_templates_filter_ok, + "request_templates_policy": request_templates_policy_ok, + "request_templates_schema": request_templates_schema_ok, + "request_templates_cache": request_templates_cache_ok, + "request_templates_sequence": request_templates_sequence_ok, + "request_templates_recipes": request_templates_recipes_ok, + "request_templates_recipe_summary": request_templates_recipe_summary_ok, + "request_templates_recommended_recipe": request_templates_recommended_recipe_ok, + "request_templates_recipe_filter": request_templates_recipe_filter_ok, + "request_templates_recommended_recipe_detail": request_templates_recommended_recipe_detail_ok, + "request_templates_policy_only": request_templates_policy_only_ok, + "request_templates_external_agent_contract": request_templates_external_agent_contract_ok, + "startup_request_templates": startup_request_templates_ok, + "start_new_recovery_not_resumable": start_new_recovery_ok, + "executed_plan_recovery": executed_plan_recovery_ok, + "execute_plan_followups": execute_plan_followups_ok, + "ai_replay_execution_summary": ai_replay_execution_summary_ok, + "toolbox_startup_endpoint": bool((toolbox.get("endpoints") or {}).get("startup")), + "toolbox_maintain_endpoint": bool((toolbox.get("endpoints") or {}).get("maintain")), + "toolbox_request_templates_endpoint": bool((toolbox.get("endpoints") or {}).get("request_templates")), + "toolbox_maintain_tool": bool((toolbox.get("tools") or {}).get("maintain")), + "toolbox_request_templates_tool": bool((toolbox.get("tools") or {}).get("request_templates")), + "toolbox_selfcheck_endpoint": bool((toolbox.get("endpoints") or {}).get("selfcheck")), + } + ok = all(bool(value) for value in checks.values()) + return { + "protocol_version": "assistant.v1", + "action": "selfcheck", + "ok": ok, + "compact": True, + "version": self.plugin_version, + "checks": checks, + "bool_cases": bool_cases, + "template_samples": { + "action": { + "name": action_template.get("name"), + "body_compact": (action_template.get("body") or {}).get("compact"), + "action_body_compact": (action_template.get("action_body") or {}).get("compact"), + }, + "route": { + "name": route_template.get("name"), + "body_compact": (route_template.get("body") or {}).get("compact"), + "action_body_compact": (route_template.get("action_body") or {}).get("compact"), + }, + "request_templates": { + name: { + "method": (request_templates.get(name) or {}).get("method"), + "endpoint": (request_templates.get(name) or {}).get("endpoint"), + } + for name in ["maintain_execute", "workflow_dry_run", "saved_plan_execute"] + }, + "execute_plan_followups": { + workflow: { + "next_actions": (sample or {}).get("next_actions") or [], + "recommended_action": self._clean_text((sample or {}).get("recommended_action")), + "follow_up_hint": self._clean_text((sample or {}).get("follow_up_hint")), + "template_names": [ + self._clean_text(item.get("name")) + for item in ((sample or {}).get("action_templates") or []) + if isinstance(item, dict) and self._clean_text(item.get("name")) + ], + } + for workflow, sample in execute_plan_followup_samples.items() + }, + }, + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_pulse", "assistant_toolbox", "assistant_readiness"], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "readiness": "/api/v1/plugin/AgentResourceOfficer/assistant/readiness?compact=true", + }, + } + + def _format_assistant_selfcheck_text(self) -> str: + data = self._assistant_selfcheck_public_data() + checks = data.get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 协议自检", + f"版本:{data.get('version')}", + f"结果:{'通过' if data.get('ok') else '失败'}", + ] + if failed: + lines.append("失败项:" + " / ".join(failed)) + return "\n".join(lines) + + def _assistant_response_data( + self, + *, + session: str, + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload = dict(data or {}) + session_name = self._clean_text(session) or "default" + session_state = self._assistant_session_public_data(session=session_name) + payload["action"] = self._clean_text(payload.get("action")) or "assistant_response" + payload["ok"] = bool(payload.get("ok", True)) + payload["write_effect"] = payload.get("write_effect") or self._assistant_write_effect_for_action(payload["action"]) + payload["error_code"] = self._clean_text(payload.get("error_code")) or ("" if payload["ok"] else "assistant_error") + payload["protocol_version"] = "assistant.v1" + payload["session"] = session_name + payload["session_id"] = session_state.get("session_id") or self._assistant_session_id(session_name) + payload["session_state"] = session_state + payload["preference_status"] = payload.get("preference_status") or self._assistant_preferences_status_brief(session=session_name) + payload["next_actions"] = payload.get("next_actions") or session_state.get("suggested_actions") or [] + if payload["preference_status"].get("needs_onboarding") and "preferences.init" not in payload["next_actions"]: + payload["next_actions"] = ["preferences.init", *list(payload["next_actions"] or [])] + payload["action_templates"] = payload.get("action_templates") or session_state.get("action_templates") or [] + payload["recovery"] = payload.get("recovery") or session_state.get("recovery") or self._assistant_recovery_public_data(session_state=session_state) + if payload.get("action") == "mp_recommendations" or self._clean_text(session_state.get("kind")) == "assistant_mp_recommend": + payload["source"] = self._clean_text(payload.get("source") or session_state.get("source")) + payload["requested_source"] = self._clean_text(payload.get("requested_source") or session_state.get("requested_source")) + payload["fallback_source"] = self._clean_text(payload.get("fallback_source") or session_state.get("fallback_source")) + payload["media_type"] = self._clean_text(payload.get("media_type") or session_state.get("media_type")) + recommend_handoff = session_state.get("recommend_handoff") if isinstance(session_state.get("recommend_handoff"), dict) else {} + if recommend_handoff: + payload["recommend_handoff"] = recommend_handoff + payload["return_short_command"] = self._clean_text(payload.get("return_short_command") or recommend_handoff.get("return_short_command") or "回推荐") + return payload + + @staticmethod + def _assistant_write_effect_for_action(action: str) -> str: + action_name = str(action or "").strip() + if action_name in { + "share_route", + "hdhive_unlock", + "transfer_115", + "quark_transfer", + "quark_clear_default_dir", + "p115_clear_default_dir", + "p115_resume", + "p115_cancel", + "p115_qrcode_start", + "p115_qrcode_check", + "hdhive_checkin", + "mp_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_search", + "pick_mp_download", + "start_mp_subscribe", + "start_mp_subscribe_search", + "ai_replay_failed_sample", + "preferences_save", + "preferences_reset", + "execute_actions", + "execute_plan", + "maintain", + }: + return "write" + if action_name in {"workflow_plan", "plans_clear", "session_clear", "sessions_clear"}: + return "state" + return "read" + + def _merge_assistant_structured_input(self, body: Dict[str, Any], parsed: Dict[str, str]) -> Dict[str, str]: + merged = dict(parsed or {}) + body = dict(body or {}) + + mode = self._clean_text(body.get("mode")) + if mode in {"mp", "mp_download_title", "pansou", "hdhive", "smart", "smart_decision", "smart_plan", "smart_execute", "cloud_transfer_execute"}: + merged["mode"] = mode + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if keyword: + merged["keyword"] = keyword + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if share_url: + merged["url"] = share_url + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + if access_code: + merged["access_code"] = access_code + path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + if path: + merged["path"] = path + media_type = self._clean_text(body.get("media_type") or body.get("type")).lower() + if media_type: + merged["type"] = media_type + year = self._clean_text(body.get("year")) + if year: + merged["year"] = year + cloud_provider = self._clean_text(body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider in {"115", "quark"}: + merged["cloud_provider"] = cloud_provider + client_type = self._clean_text(body.get("client_type") or body.get("client")) + if client_type: + merged["client_type"] = P115TransferService.normalize_qrcode_client_type(client_type) + if "is_gambler" in body: + merged["is_gambler"] = "true" if self._parse_bool_value(body.get("is_gambler"), False) else "false" + action = self._clean_text(body.get("action")) + if action: + merged["action"] = action + result_filter = self._clean_text(body.get("result_filter") or body.get("filter")).lower() + if result_filter in {"latest_episode", "latest", "latest_episodes"}: + merged["result_filter"] = "latest_episode" + plan_id = self._clean_text(body.get("plan_id") or body.get("plan")) + if plan_id: + merged["plan_id"] = plan_id + download_control = self._clean_text(body.get("download_control") or body.get("control") or body.get("operation")) + if download_control: + merged["download_control"] = download_control + subscribe_control = self._clean_text(body.get("subscribe_control") or body.get("control") or body.get("operation")) + if subscribe_control: + merged["subscribe_control"] = subscribe_control + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index > 0: + merged["sample_index"] = str(sample_index) + if "remove_if_resolved" in body: + merged["remove_if_resolved"] = "true" if self._parse_bool_value(body.get("remove_if_resolved"), True) else "false" + return merged + + @staticmethod + def _parse_assistant_text(text: str) -> Dict[str, str]: + raw = str(text or "").strip() + compact = re.sub(r"\s+", "", raw).lower() + share_url = AgentResourceOfficer._extract_first_url(raw) + remain = raw.replace(share_url, " ").strip() if share_url else raw + mode, query = AgentResourceOfficer._normalize_search_prefix(remain) + plan_match = re.search(r"\bplan-[a-zA-Z0-9]+\b", raw) + options: Dict[str, str] = { + "text": raw, + "url": share_url, + "access_code": "", + "path": "", + "mode": mode, + "keyword": query or remain, + "result_filter": "", + "source_order_text": "", + "cloud_provider": "", + "type": "", + "year": "", + "action": "", + "client_type": "", + "status": "", + "hash": "", + "plan_id": plan_match.group(0) if plan_match else "", + "decision_intent": "", + "sample_index": "", + "remove_if_resolved": "true", + } + if options.get("mode") in {"smart", "smart_decision"} and options.get("keyword"): + cleaned_keyword, decision_intent = AgentResourceOfficer._extract_smart_decision_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if decision_intent: + options["decision_intent"] = decision_intent + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + if raw.startswith("云盘搜索") or raw.startswith("云盘搜"): + options["source_order_text"] = "pansou,hdhive" + transfer_provider_prefixes = [ + ("夸克转存资源", "quark"), + ("夸克转存", "quark"), + ("115转存资源", "115"), + ("115转存", "115"), + ] + for prefix, provider in transfer_provider_prefixes: + if raw == prefix: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = "" + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + replay_match = re.match(r"^\s*(重放样本|重识别样本|重跑样本)\s*(\d+)?(?:\s+(.*))?$", raw) + if replay_match: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if replay_match.group(2): + options["sample_index"] = replay_match.group(2) + remain_text = AgentResourceOfficer._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if options.get("plan_id") and compact.startswith(("执行plan-", "确认plan-", "executeplan-")): + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif options.get("plan_id") and compact.startswith(("取消plan-", "清理plan-", "删除plan-", "clearplan-", "cancelplan-")): + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "帮助", + "使用帮助", + "命令帮助", + "help", + "agenthelp", + "arohelp", + "插件帮助", + }: + options["action"] = "assistant_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "执行计划", + "执行最新计划", + "确认计划", + "确认执行计划", + "执行plan", + "执行最新plan", + "executeplan", + "executelatestplan", + }: + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "计划列表", + "查看计划", + "待执行计划", + "保存计划", + "plans", + "listplans", + }: + options["action"] = "plans_list" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "偏好", + "片源偏好", + "查看偏好", + "偏好设置", + "智能体偏好", + "preferences", + "getpreferences", + }: + options["action"] = "preferences_get" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重置偏好", + "清除偏好", + "重设偏好", + "恢复默认偏好", + "resetpreferences", + }: + options["action"] = "preferences_reset" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "评分策略", + "评分规则", + "自动化规则", + "scoringpolicy", + "scoringrules", + }: + options["action"] = "scoring_policy" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消计划", + "清理计划", + "删除计划", + "cancelplan", + "clearplan", + "deleteplan", + }: + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录", + "115扫码", + "扫码115", + "登录115", + "115login", + "115qrcode", + "p115login", + "p115qrcode", + }: + options["action"] = "p115_qrcode_start" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "检查115登录", + "检查115扫码", + "检查扫码", + "115check", + "check115login", + "p115check", + }: + options["action"] = "p115_qrcode_check" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录状态", + "115状态", + "查看115状态", + "115健康", + "115status", + "p115status", + }: + options["action"] = "p115_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115帮助", + "115命令", + "115使用", + "115help", + "p115help", + }: + options["action"] = "p115_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115任务", + "待处理115", + "待继续115", + "115pending", + "p115pending", + }: + options["action"] = "p115_pending" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空夸克默认目录", + "清空夸克默认转存目录", + "清空夸克转存目录", + "清空夸克目录", + }: + options["action"] = "quark_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空115默认目录", + "清空115默认转存目录", + "清空115转存目录", + "清空115目录", + "清空115网盘转存目录", + "清空115网盘目录", + }: + options["action"] = "p115_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续115任务", + "重试115任务", + "继续115转存", + "重试115转存", + "continue115", + "resume115", + }: + options["action"] = "p115_resume" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消115任务", + "取消115转存", + "清除115任务", + "cancel115", + "clear115", + }: + options["action"] = "p115_cancel" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到", + "签到", + "hdhivecheckin", + "hdhivesign", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到日志", + "签到日志", + "影巢日志", + "hdhivecheckinhistory", + "hdhivesignhistory", + }: + options["action"] = "hdhive_checkin_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢普通签到", + "普通签到", + "普通", + "hdhivenormalcheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "false" + elif compact in { + "影巢赌狗签到", + "赌狗签到", + "赌狗", + "hdhivegamblercheckin", + "gamblercheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "true" + elif compact in { + "资源决策", + "智能决策", + "decision", + "smartdecision", + }: + options["mode"] = "smart_decision" + options["keyword"] = "" + elif compact in { + "后续", + "执行后追踪", + "继续追踪", + "followup", + "postexecute", + }: + options["action"] = "execution_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "跟进", + "继续跟进", + "查看进展", + "进展", + "smartfollowup", + }: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续决策", + "继续资源决策", + "decisioncontinue", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_continue" + elif compact in { + "换影巢", + "切换影巢", + "走影巢", + "用影巢", + "decisionhdhive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_hdhive" + elif compact in { + "换盘搜", + "切换盘搜", + "走盘搜", + "用盘搜", + "decisionpansou", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_pansou" + elif compact in { + "换pt", + "换原生", + "换mp", + "切换pt", + "切换原生", + "切换mp", + "decisionmppt", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_mp_pt" + elif compact in { + "保守一点", + "更保守", + "保守模式", + "decisionconservative", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_conservative" + elif compact in { + "激进一点", + "更激进", + "激进模式", + "decisionaggressive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_aggressive" + elif compact in { + "下载任务", + "下载状态", + "正在下载", + "下载列表", + "查看下载", + "下载进度", + "downloadtasks", + "downloadstatus", + }: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载最佳", + "下载推荐", + "下载最好", + "下载最佳片源", + "下载推荐片源", + "downloadbest", + }: + options["action"] = "mp_download_best" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载历史", + "下载记录", + "记录", + "历史下载", + "downloadhistory", + }: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近下载", + "recentdownloads", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["download_only"] = "true" + elif compact in { + "最近", + "最近动态", + "动态", + "recentactivity", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "追踪", + "资源追踪", + "下载追踪", + "媒体状态", + "落库状态", + "状态", + "进度", + "lifecyclestatus", + }: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库状态", + "本地入库", + "入库", + "ingeststatus", + }: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "识别", + "媒体识别", + "媒体详情", + "mp识别", + "mp媒体识别", + "mpmediadetail", + }: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载器", + "下载器状态", + "下载器列表", + "查看下载器", + "downloaders", + }: + options["action"] = "mp_downloaders" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "站点", + "站点状态", + "站点列表", + "pt站点", + "pt站点状态", + "sites", + }: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "订阅列表", + "订阅状态", + "查看订阅", + "mp订阅", + "subscribes", + }: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库历史", + "整理历史", + "转移历史", + "入库记录", + "最近整理", + "transferhistory", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近入库", + "recentingest", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["transfer_only"] = "true" + elif compact in { + "入库失败", + "整理失败", + "失败入库", + "失败整理", + "transferfailed", + }: + options["action"] = "mp_ingest_failures" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "failed" + elif compact in { + "入库成功", + "整理成功", + "成功入库", + "成功整理", + "transfersuccess", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "success" + elif compact in { + "本地诊断", + "诊断", + "失败原因", + "localdiagnose", + }: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "失败样本", + "识别样本", + "ai失败样本", + "aifailedsamples", + }: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "工作清单", + "识别工作清单", + "样本清单", + "sampleworklist", + }: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "样本洞察", + "识别洞察", + "失败洞察", + "sampleinsights", + }: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重放样本", + "重识别样本", + "重跑样本", + "aireplay", + "replaysample", + }: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + else: + for prefix, action in [ + ("执行计划", "execute_plan"), + ("执行", "execute_plan"), + ("确认计划", "execute_plan"), + ("确认", "execute_plan"), + ("查看计划", "plans_list"), + ("计划列表", "plans_list"), + ("取消计划", "plans_clear"), + ("清理计划", "plans_clear"), + ("删除计划", "plans_clear"), + ("保存偏好", "preferences_save"), + ("设置偏好", "preferences_save"), + ("更新偏好", "preferences_save"), + ("偏好设置", "preferences_save"), + ("偏好", "preferences_save"), + ("查看偏好", "preferences_get"), + ("片源偏好", "preferences_get"), + ("重置偏好", "preferences_reset"), + ("清除偏好", "preferences_reset"), + ("评分策略", "scoring_policy"), + ("评分规则", "scoring_policy"), + ("自动化规则", "scoring_policy"), + ]: + if raw.startswith(prefix + " ") or raw.startswith(prefix + ":") or raw.startswith(prefix + ":"): + remain_text = raw[len(prefix):].lstrip(" ::").strip() + if action in {"plans_list", "preferences_get", "preferences_reset"} or options.get("plan_id") or remain_text: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + if remain_text and not options.get("plan_id"): + match = re.search(r"\bplan-[a-zA-Z0-9]+\b", remain_text) + if match: + options["plan_id"] = match.group(0) + break + for prefix, control in [ + ("暂停下载", "pause"), + ("停止下载", "pause"), + ("恢复下载", "resume"), + ("继续下载", "resume"), + ("开始下载", "resume"), + ("删除下载", "delete"), + ("移除下载", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_download_control" + options["mode"] = "" + options["keyword"] = target_text + options["download_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载任务", "下载状态", "下载列表", "查看下载", "下载进度"]) + if prefix_match: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源决策", "智能决策"]) + if prefix_match: + options["mode"] = "smart_decision" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["继续跟进", "查看进展", "跟进", "进展"]) + if prefix_match: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载历史", "下载记录", "记录", "最近下载", "历史下载"]) + if prefix_match: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源追踪", "下载追踪", "媒体状态", "落库状态", "状态", "进度", "追踪"]) + if prefix_match: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["入库状态", "本地入库"]) + if prefix_match: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["MP识别", "mp识别", "媒体识别", "媒体详情", "详情媒体", "识别"]) + if prefix_match: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["站点状态", "站点列表", "PT站点", "pt站点", "站点"]) + if prefix_match: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, control in [ + ("搜索订阅", "search"), + ("刷新订阅", "search"), + ("暂停订阅", "pause"), + ("恢复订阅", "resume"), + ("删除订阅", "delete"), + ("移除订阅", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_subscribe_control" + options["mode"] = "" + options["keyword"] = target_text + options["subscribe_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["订阅列表", "订阅状态", "查看订阅", "MP订阅", "mp订阅"]) + if prefix_match: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, status_name in [ + ("入库失败", "failed"), + ("整理失败", "failed"), + ("失败入库", "failed"), + ("失败整理", "failed"), + ("入库成功", "success"), + ("整理成功", "success"), + ("成功入库", "success"), + ("成功整理", "success"), + ("入库历史", "all"), + ("入库记录", "all"), + ("整理历史", "all"), + ("转移历史", "all"), + ("最近入库", "all"), + ("最近整理", "all"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + options["action"] = "mp_ingest_failures" if status_name == "failed" else "mp_transfer_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + options["status"] = status_name + break + if not options.get("action") and raw.startswith("入库"): + blocked_prefixes = ("入库失败", "入库成功", "入库历史", "入库记录", "入库状态") + if not any(raw.startswith(prefix) for prefix in blocked_prefixes): + remain_text = raw[len("入库"):].lstrip(" ::").strip() + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = remain_text + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["为什么没入库", "本地诊断", "诊断", "失败原因"]) + if prefix_match: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["失败样本", "识别样本", "AI失败样本"]) + if prefix_match: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["工作清单", "识别工作清单", "样本清单"]) + if prefix_match: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["样本洞察", "识别洞察", "失败洞察"]) + if prefix_match: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["重放样本", "重识别样本", "重跑样本"]) + if prefix_match: + remain_text = prefix_match[1] + match = re.search(r"\d+", remain_text) + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if match: + options["sample_index"] = match.group(0) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if not options.get("action"): + for prefix, action in [ + ("转存资源", "cloud_transfer"), + ("转存", "cloud_transfer"), + ("下载资源", "mp_download"), + ("下载", "mp_download"), + ("订阅并搜索", "mp_subscribe_search"), + ("订阅搜索", "mp_subscribe_search"), + ("订阅媒体", "mp_subscribe"), + ("订阅", "mp_subscribe"), + ("热门推荐", "mp_recommendations"), + ("推荐", "mp_recommendations"), + ("智能发现", "mp_recommendations"), + ("热门发现", "mp_recommendations"), + ]: + if raw == prefix: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + if action == "cloud_transfer": + if remain_text: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if download_match: + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + else: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + else: + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if raw.startswith(prefix): + remain_text = raw[len(prefix):].strip() + if not remain_text: + continue + if action == "cloud_transfer": + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if not download_match: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + break + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + break + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if not options.get("action") and any( + marker in compact + for marker in [ + "热门影视", + "热门电影", + "热门电视剧", + "热门剧集", + "最近热门", + "有什么热门", + "看看热门", + "影视推荐", + "电影推荐", + "剧集推荐", + "电视剧推荐", + "豆瓣热门", + "豆瓣top250", + "正在热映", + "今日番剧", + "每日放送", + "bangumi", + "tmdb热门", + ] + ): + options["action"] = "mp_recommendations" + options["mode"] = "" + options["keyword"] = raw + 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"] = AgentResourceOfficer._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 key in {"client_type", "client", "客户端"} and value: + options["client_type"] = P115TransferService.normalize_qrcode_client_type(value) + continue + if key in {"hash", "download_hash", "任务hash"} and value: + options["hash"] = value.strip() + continue + if item.startswith("/") and not options["path"]: + options["path"] = AgentResourceOfficer._resolve_pan_path_value(item) + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + return options + + 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 = [] + base_urls = [] + configured_base = self._clean_text(self._pansou_base_url).rstrip("/") + if configured_base: + base_urls.append(configured_base) + for fallback_base in ("http://host.docker.internal:805", "http://127.0.0.1:805"): + if fallback_base not in base_urls: + base_urls.append(fallback_base) + for query in queries: + for base_url in base_urls: + urls.append(f"{base_url}/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=self._pansou_timeout) 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 _normalize_pansou_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_pansou_channel_items( + self, + merged: Dict[str, Any], + channel_name: str, + limit: int = 6, + ) -> 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) + normalized = { + "channel": self._normalize_pansou_channel_name(channel_name), + "url": url, + "password": password, + "note": note, + "source": source, + "datetime": dt, + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + normalized[extra_key] = value + results.append(normalized) + if len(results) >= limit: + break + return results + + @staticmethod + def _extract_size_text(text: str) -> str: + match = re.search(r"(\d+(?:\.\d+)?)\s*(gb|g|mb|m)\b", str(text or ""), flags=re.IGNORECASE) + if not match: + return "" + value = match.group(1) + unit = match.group(2).upper() + if unit == "G": + unit = "GB" + if unit == "M": + unit = "MB" + return f"{value}{unit}" + + def _pansou_item_brief_summary(self, item: Dict[str, Any]) -> str: + text = self._score_text_blob(item) + parts: List[str] = [] + if "2160" in text or "4k" in text or "uhd" in text: + parts.append("4K") + elif "1080" in text: + parts.append("1080P") + elif "720" in text: + parts.append("720P") + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + parts.append("杜比视界") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + parts.append("HDR") + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "双语", "官中", "内封简繁"]): + parts.append("中字") + elif self._score_has_any(text, ["无中字", "无字幕"]): + parts.append("无中字") + progress = self._extract_series_progress(text) + if progress.get("episode_count", 0) >= max(3, progress.get("max_episode", 0)): + parts.append(f"E01-E{progress['max_episode']:02d}") + elif progress.get("max_episode", 0) > 0: + parts.append(f"更新到E{progress['max_episode']:02d}") + size_text = self._extract_size_text(text) or self._extract_size_text(self._list_text(item.get("share_size") or item.get("size"))) + if size_text: + parts.append(size_text) + return " | ".join(parts[:5]) + + @staticmethod + def _format_markdown_link(title: Any, url: Any) -> str: + clean_title = str(title or "").strip() + clean_url = str(url or "").strip() + if not clean_title: + return clean_url + if not clean_url: + return clean_title + safe_title = clean_title.replace("[", "[").replace("]", "]") + return f"[{safe_title}]({clean_url})" + + @staticmethod + def _parse_simple_cjk_number(text: str) -> Optional[int]: + raw = str(text or "").strip() + if not raw: + return None + if raw.isdigit(): + return int(raw) + mapping = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + if raw == "十": + return 10 + if "十" in raw: + left, right = raw.split("十", 1) + left_value = 1 if left == "" else mapping.get(left) + if left_value is None: + return None + right_value = 0 if right == "" else mapping.get(right) + if right_value is None: + return None + return left_value * 10 + right_value + total = 0 + for ch in raw: + if ch not in mapping: + return None + total = total * 10 + mapping[ch] + return total if total >= 0 else None + + @classmethod + def _normalize_simple_cjk_numbers(cls, text: str) -> str: + raw = str(text or "") + if not raw: + return "" + pattern = re.compile(r"[零〇一二两三四五六七八九十]{1,3}") + def repl(match: re.Match[str]) -> str: + value = cls._parse_simple_cjk_number(match.group(0)) + return str(value) if value is not None else match.group(0) + return pattern.sub(repl, raw) + + @classmethod + def _build_keyword_variants(cls, keyword: str) -> List[str]: + raw = cls._clean_text(keyword) + if not raw: + return [] + variants: List[str] = [] + + def add(value: Any) -> None: + text = cls._clean_text(value) + if not text: + return + if text not in variants: + variants.append(text) + + add(raw) + trimmed = re.split(r"[::((【\[\-_/|]", raw, 1)[0].strip() + add(trimmed) + if "的" in raw: + add(raw.split("的", 1)[0].strip()) + base_candidates = list(variants) + for base in base_candidates: + numeric = cls._normalize_simple_cjk_numbers(base) + add(numeric) + add(numeric.replace("个月", "ヶ月").replace("個月", "ヶ月")) + add(numeric.replace("个月", "个月").replace("個月", "个月")) + add(base.replace("三个月", "3个月").replace("三個月", "3个月")) + add(base.replace("三个月", "3ヶ月").replace("三個月", "3ヶ月")) + add(re.sub(r"\s+", "", base)) + return variants + + def _pansou_payload_item_count(self, payload: Dict[str, Any]) -> int: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + total = 0 + for channel in ("115", "quark"): + items = merged.get(channel) or [] + if isinstance(items, list): + total += len(items) + return total + + def _call_pansou_search_with_variants(self, keyword: str) -> Tuple[bool, Dict[str, Any], str, str]: + variants = self._build_keyword_variants(keyword) + last_error = "" + last_payload: Dict[str, Any] = {} + for variant in variants: + ok, payload, message = self._call_pansou_search(variant) + if ok and self._pansou_payload_item_count(payload) > 0: + return True, payload, message, variant + last_error = message + last_payload = payload if isinstance(payload, dict) else {} + return False, last_payload, last_error or "盘搜暂无结果", self._clean_text(keyword) + + def _assistant_pansou_entry_summary(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(items) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "pansou_result", + "label": "先看条目再选编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "pansou_result", + "label": "盘搜列表已返回", + "decision_hint": "默认直接回编号即可转存;想先确认可回复“选择 编号 详情”。只有明确要生成计划时才发“计划选择 编号”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _best_series_progress_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return {} + candidates.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("max_episode")), 0), + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + ), + reverse=True, + ) + return candidates[0] + + def _latest_series_progress_items(self, items: List[Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return [] + best_episode = max(self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) for item in candidates) + if best_episode <= 0: + return [] + matches = [ + item for item in candidates + if self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) == best_episode + ] + matches.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + self._safe_int(value.get("updated_at"), 0), + self._clean_text(value.get("datetime")), + ), + reverse=True, + ) + return matches[: max(1, limit)] + + def _latest_episode_mp_items(self, items: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: + candidates: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + if max_episode <= 0: + continue + enriched = dict(item) + enriched["_series_progress"] = progress + candidates.append(enriched) + if not candidates: + return [], 0 + latest_episode = max(self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) for item in candidates) + if latest_episode <= 0: + return [], 0 + latest_items = [ + item for item in candidates + if self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) == latest_episode + ] + return latest_items, latest_episode + + def _episode_filter_mp_items(self, items: List[Dict[str, Any]], episode: int) -> List[Dict[str, Any]]: + target = self._safe_int(episode, 0) + if target <= 0: + return [] + matches: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + episode_count = self._safe_int(progress.get("episode_count"), 0) + if max_episode == target: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + continue + if episode_count > 0 and max_episode >= target: + start_episode = max(1, max_episode - episode_count + 1) + if start_episode <= target <= max_episode: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + return matches + + def _latest_resource_date_text(self, items: List[Dict[str, Any]]) -> str: + latest_text = "" + latest_unix = 0 + for item in items or []: + if not isinstance(item, dict): + continue + text = self._clean_text(item.get("datetime") or item.get("updated_at_text")) + if text and text > latest_text: + latest_text = text + unix_value = self._safe_int(item.get("updated_at"), 0) + if unix_value > latest_unix: + latest_unix = unix_value + if latest_text: + return latest_text + if latest_unix > 0: + return self._format_unix_time(latest_unix) + return "" + + def _format_update_resource_choice(self, item: Dict[str, Any], source_type: str) -> str: + current = dict(item or {}) + index = self._safe_int(current.get("_index") or current.get("index"), 0) + provider = self._clean_text(current.get("channel") or current.get("pan_type")).upper() + if provider == "QUARK": + provider = "夸克" + elif provider == "115": + provider = "115" + provider_emoji = "🗄" if provider == "夸克" else "📺" if provider == "115" else "🔗" + label = f"{index}. {provider_emoji}" if index > 0 else f"?. {provider_emoji}" + if provider: + label = f"{label} {provider}" + if source_type == "pansou": + brief = self._pansou_item_brief_summary(current) + date_text = self._format_pansou_display_datetime(current.get("datetime")) + title_text = self._clean_text(current.get("note") or current.get("title") or "盘搜资源") + parts = [label] + if date_text: + parts.append(date_text) + if brief: + parts.append(brief) + if title_text and title_text != brief: + parts.append(self._truncate_text(title_text, 80)) + return " · ".join(parts) + progress = self._format_update_progress_label(current.get("_series_progress")) + subtitle = self._resource_subtitle_text(current) + resolution = "/".join(current.get("video_resolution") or []) or "" + date_text = self._clean_text(current.get("updated_at_text")) + parts = [label] + if date_text: + parts.append(self._format_pansou_display_datetime(date_text)) + if resolution: + parts.append(f"✨{resolution}" if "4" in resolution or "2160" in resolution else resolution) + if subtitle: + parts.append(f"字幕:{subtitle}") + if progress and progress != "未识别到集数": + parts.append(f"📌 {progress}") + detail = self._clean_text(current.get("title") or current.get("remark") or current.get("description")) + if detail: + parts.append(self._truncate_text(detail, 70)) + return " · ".join(parts) + + def _format_update_progress_label(self, progress: Optional[Dict[str, Any]] = None) -> str: + current = dict(progress or {}) + max_episode = self._safe_int(current.get("max_episode"), 0) + episode_count = self._safe_int(current.get("episode_count"), 0) + if max_episode <= 0: + return "未识别到集数" + if episode_count >= max_episode >= 2: + return f"E01-E{max_episode:02d}" + return f"更新到 E{max_episode:02d}" + + async def _assistant_update_check( + self, + *, + keyword: str, + session: str, + cache_key: str, + year: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + if not clean_keyword: + return { + "success": False, + "message": "用法:更新检查 片名", + "data": self._assistant_response_data(session=session, data={"action": "update_check", "ok": False, "error_code": "missing_keyword"}), + } + official = self._tmdb_latest_episode_progress(clean_keyword, year) + official_episode = self._safe_int(official.get("episode"), 0) + + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + + pansou_best: Dict[str, Any] = {} + pansou_latest_items: List[Dict[str, Any]] = [] + pansou_recent_date = "" + pansou_total = 0 + search_ok, payload, _search_message = self._call_pansou_search(clean_keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + pansou_total = self._safe_int(data.get("total"), 0) + raw_items = self._collect_pansou_channel_items(merged, "115", 20) + self._collect_pansou_channel_items(merged, "quark", 20) + scored_items = self._attach_cloud_scores(raw_items, preferences=preferences, source_type="pansou", target_path=self._hdhive_default_path) + pansou_best = self._best_series_progress_item(scored_items) + pansou_latest_items = self._latest_series_progress_items(scored_items, limit=5) + pansou_recent_date = self._latest_resource_date_text(scored_items) + + hdhive_best: Dict[str, Any] = {} + hdhive_latest_items: List[Dict[str, Any]] = [] + hdhive_recent_date = "" + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + candidate_ok, candidate_result, _candidate_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type="tv", + year=self._clean_text(year), + candidate_limit=max(10, self._hdhive_candidate_page_size), + ) + if candidate_ok: + candidates = candidate_result.get("candidates") or [] + chosen = next((item for item in candidates if self._clean_text(item.get("media_type")).lower() in {"tv", "series"}), None) + if chosen is None and candidates: + chosen = candidates[0] + if isinstance(chosen, dict): + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen.get("media_type") or "tv", + tmdb_id=str(chosen.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=self._hdhive_default_path, + ) + hdhive_best = self._best_series_progress_item(preview) + hdhive_latest_items = self._latest_series_progress_items(preview, limit=5) + hdhive_recent_date = self._latest_resource_date_text(preview) + + pansou_progress = dict(pansou_best.get("_series_progress") or {}) + hdhive_progress = dict(hdhive_best.get("_series_progress") or {}) + lines = [f"更新检查:{clean_keyword}"] + if official_episode > 0: + official_title = self._clean_text(official.get("title")) or clean_keyword + lines.append(f"📺 TMDB 进度:{official_title} S{self._safe_int(official.get('season'), 1):02d}E{official_episode:02d}") + else: + lines.append("📺 TMDB 进度:未稳定识别到最新集数") + if pansou_best: + lines.append( + f"\n🟨 盘搜结果:{self._format_update_progress_label(pansou_progress)}" + f" · 最佳 #{self._safe_int(pansou_best.get('_index'), 0)}" + ) + if pansou_latest_items: + for item in pansou_latest_items: + lines.append(self._format_update_resource_choice(item, "pansou")) + elif pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟨 盘搜结果:暂无可识别更新结果") + if pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + if hdhive_best: + lines.append( + f"\n🟦 影巢结果:{self._format_update_progress_label(hdhive_progress)}" + f" · 最佳 #{self._safe_int(hdhive_best.get('_index'), 0)}" + ) + if hdhive_latest_items: + for item in hdhive_latest_items: + lines.append(self._format_update_resource_choice(item, "hdhive")) + elif hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟦 影巢结果:暂无可识别更新结果") + if hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + + latest_seen = max( + official_episode, + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + downloadable_latest = max( + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + if latest_seen > 0: + lines.append(f"\n🔎 当前观察到最高更新:E{latest_seen:02d}") + if downloadable_latest > 0: + lines.append(f"✅ 已可下载最新集:E{downloadable_latest:02d}") + else: + lines.append("⚠️ 已可下载最新集:暂未稳定识别") + if official_episode > 0: + pansou_ok = self._safe_int(pansou_progress.get("max_episode"), 0) >= official_episode + hdhive_ok = self._safe_int(hdhive_progress.get("max_episode"), 0) >= official_episode + if pansou_ok and hdhive_ok: + lines.append("✅ 盘搜和影巢都已跟上 TMDB 进度。") + else: + lines.append(f"{'✅' if pansou_ok else '⏳'} 盘搜:{'已跟上' if pansou_ok else '还没稳定跟上'}TMDB 进度。") + lines.append(f"{'✅' if hdhive_ok else '⏳'} 影巢:{'已跟上' if hdhive_ok else '还没稳定跟上'}TMDB 进度。") + pt_search_needed = latest_seen <= 0 and official_episode <= 0 + if pt_search_needed: + lines.append(f"\n下一步:官方和云盘侧都还没看到明确新集;如果要继续扩搜,可以回复:PT搜索 {clean_keyword}。") + else: + lines.append("\n下一步:直接回编号可继续处理;想先确认可发“选择 编号 详情”。") + lines.append(f"也可以发“云盘搜索 {clean_keyword}”查看更多资源,或“影巢搜索 {clean_keyword}”只看影巢。") + update_items: List[Dict[str, Any]] = [] + for item in pansou_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "pansou"}) + for item in hdhive_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "hdhive"}) + self._save_session(cache_key, { + "kind": "assistant_update_check", + "stage": "result", + "keyword": clean_keyword, + "year": self._clean_text(year), + "items": update_items, + "pansou_items": pansou_latest_items, + "hdhive_items": hdhive_latest_items, + "target_path": self._hdhive_default_path, + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check", + "ok": True, + "keyword": clean_keyword, + "official_progress": official, + "pansou_best": pansou_best, + "pansou_latest_items": pansou_latest_items, + "pansou_recent_date": pansou_recent_date, + "hdhive_best": hdhive_best, + "hdhive_latest_items": hdhive_latest_items, + "hdhive_recent_date": hdhive_recent_date, + "latest_seen_episode": latest_seen, + "downloadable_latest_episode": downloadable_latest, + "decision_summary": { + "stage": "update_check", + "label": "更新检查已完成", + "preferred_command": f"PT搜索 {clean_keyword}" if pt_search_needed else f"云盘搜索 {clean_keyword}", + "fallback_command": f"云盘搜索 {clean_keyword}" if pt_search_needed else f"影巢搜索 {clean_keyword}", + "compact_commands": [f"PT搜索 {clean_keyword}", f"云盘搜索 {clean_keyword}"] if pt_search_needed else [f"云盘搜索 {clean_keyword}", f"影巢搜索 {clean_keyword}"], + "recommended_agent_behavior": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + }, + }), + } + + @classmethod + def _read_tmdb_api_key(cls) -> str: + for value in [ + os.environ.get("TMDB_API_KEY", ""), + os.environ.get("TMDB_KEY", ""), + cls._clean_text(getattr(settings, "TMDB_API_KEY", "") if settings is not None else ""), + ]: + if cls._clean_text(value): + return cls._clean_text(value) + 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_text(value.strip().strip("'\"")) + if key: + return key + return "" + + @classmethod + def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: + clean_tmdb_id = cls._clean_text(tmdb_id) + clean_media_type = cls._clean_text(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 [] + endpoint = "movie" if clean_media_type == "movie" else "tv" + url = ( + f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?" + f"{urlencode({'api_key': tmdb_api_key, 'language': 'zh-CN', 'append_to_response': 'credits'})}" + ) + 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_text((member or {}).get("name")) + department = cls._clean_text((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 + + @classmethod + def _tmdb_latest_episode_progress(cls, keyword: str, year: str = "") -> Dict[str, Any]: + title = cls._clean_text(keyword) + if not title: + return {} + tmdb_api_key = cls._read_tmdb_api_key() + if not tmdb_api_key: + return {} + params = { + "api_key": tmdb_api_key, + "language": "zh-CN", + "query": title, + } + try: + search_url = "https://api.themoviedb.org/3/search/tv?" + urlencode(params) + request = UrlRequest(url=search_url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8", "ignore")) + results = payload.get("results") or [] + if not isinstance(results, list) or not results: + return {} + target_year = cls._clean_text(year)[:4] + picked = None + for item in results: + first_air = cls._clean_text((item or {}).get("first_air_date"))[:4] + if target_year and first_air and first_air == target_year: + picked = item + break + if picked is None: + picked = results[0] + tv_id = cls._clean_text((picked or {}).get("id")) + if not tv_id: + return {} + detail_url = ( + f"https://api.themoviedb.org/3/tv/{tv_id}?" + + urlencode({"api_key": tmdb_api_key, "language": "zh-CN"}) + ) + detail_request = UrlRequest(url=detail_url, headers={"Accept": "application/json"}) + with urlopen(detail_request, timeout=20) as response: + detail = json.loads(response.read().decode("utf-8", "ignore")) + last_episode = (detail.get("last_episode_to_air") or {}) if isinstance(detail, dict) else {} + season_number = cls._safe_int(last_episode.get("season_number"), 0) + episode_number = cls._safe_int(last_episode.get("episode_number"), 0) + return { + "tmdb_id": tv_id, + "title": cls._clean_text(detail.get("name") or picked.get("name") or title), + "year": cls._clean_text(detail.get("first_air_date") or picked.get("first_air_date"))[:4], + "season": season_number, + "episode": episode_number, + "status": cls._clean_text(detail.get("status")), + } + except Exception: + return {} + + def _maybe_enrich_hdhive_candidate_with_actors(self, candidate: Dict[str, Any]) -> Dict[str, Any]: + enriched = dict(candidate or {}) + if enriched.get("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]]) -> List[Dict[str, Any]]: + indexed = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] + pending = [(idx, item) for idx, item in indexed if not (item.get("actors") or [])] + enriched_map: Dict[int, Dict[str, Any]] = {idx: item for idx, item in indexed} + if pending: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as executor: + future_map = { + executor.submit(self._maybe_enrich_hdhive_candidate_with_actors, item): idx + for idx, item 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[idx][1]) + return [enriched_map[idx] for idx, _ in indexed] + + @staticmethod + def _format_candidate_label(item: Dict[str, Any]) -> str: + title = str(item.get("title") or "未命名") + year = str(item.get("year") or "?") + raw_media_type = str(item.get("media_type") or item.get("type") or "?") + media_type = { + "movie": "电影", + "tv": "剧集", + "series": "剧集", + }.get(raw_media_type.lower(), raw_media_type) + actors = item.get("actors") or [] + parts = [year, media_type] + actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) + if actor_text: + parts.append(f"主演:{actor_text}") + return f"{title} ({' | '.join([part for part in parts if part])})" + + @staticmethod + def _format_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "候选影片:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"候选影片:{len(candidates)} 个,请先选择影片:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") + lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @staticmethod + def _format_mp_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "MP 搜索候选:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"MP 搜索候选:{len(candidates)} 个,请先选择正确的电影/剧集:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复编号或“选择 编号”,选定后再搜索 PT 资源。") + lines.append("如需补充当前候选页主演,可回复:详情 或 审查。") + lines.append("如果已经知道年份,也可以直接发:MP搜索 片名 年份。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _keyword_has_explicit_year(cls, keyword: Any, year: Any = "") -> bool: + return bool(cls._clean_text(year) or re.search(r"(?:19|20)\d{2}", cls._clean_text(keyword))) + + @staticmethod + def _list_text(value: Any, separator: str = "/") -> str: + if value is None: + return "" + if isinstance(value, (list, tuple, set)): + parts = [str(item).strip() for item in value if str(item).strip()] + return separator.join(parts) + return str(value).strip() + + @staticmethod + def _truncate_text(value: Any, limit: int = 140) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 1)]}…" + + @staticmethod + def _resource_points_text(item: Dict[str, Any]) -> str: + points = item.get("unlock_points") + if points is None: + points = item.get("cost") + if points is None: + points = item.get("points") + if points in (0, "0"): + return "免费" + if points in (None, "", "未知"): + return "免费" if AgentResourceOfficer._resource_has_free_marker(item) else "未标积分" + return f"{points}分" + + @staticmethod + def _resource_subtitle_text(item: Dict[str, Any]) -> str: + language = AgentResourceOfficer._list_text( + item.get("subtitle_language") + or item.get("subtitle_languages") + or item.get("subtitles") + or item.get("subtitle") + ) + subtitle_type = AgentResourceOfficer._list_text(item.get("subtitle_type") or item.get("subtitle_types")) + if language and subtitle_type: + return f"{language} · {subtitle_type}" + return language or subtitle_type + + @staticmethod + def _resource_episode_text(item: Dict[str, Any]) -> str: + explicit_keys = [ + "episode_range", + "episodes_range", + "episode_info", + "episodes", + "episode", + "update_status", + "update_info", + "season_episode", + ] + for key in explicit_keys: + text = AgentResourceOfficer._list_text(item.get(key)) + if text: + return AgentResourceOfficer._truncate_text(text, 40) + + source_text = " ".join( + str(item.get(key) or "") + for key in ["title", "remark", "description", "desc", "detail", "note"] + ) + patterns = [ + r"(全\s*\d+\s*集)", + r"(全集)", + r"(更新至\s*第?\s*\d+\s*集)", + r"(更\s*\d+\s*集)", + r"(第?\s*\d+\s*[-~到至]\s*\d+\s*集)", + r"(\d+\s*-\s*\d+\s*集)", + r"(S\d{1,2}E\d{1,3}(?:\s*[-~]\s*E?\d{1,3})?)", + r"(EP?\s*\d{1,3}(?:\s*[-~]\s*EP?\s*\d{1,3})?)", + ] + for pattern in patterns: + match = re.search(pattern, source_text, flags=re.IGNORECASE) + if match: + return re.sub(r"\s+", "", match.group(1)) + return "" + + @staticmethod + def _resource_remark_text(item: Dict[str, Any]) -> str: + for key in ["remark", "description", "desc", "detail", "details", "summary", "note"]: + text = AgentResourceOfficer._truncate_text(item.get(key), 160) + if text: + return text + return "" + + def _format_resource_lines( + self, + resources: List[Dict[str, Any]], + candidate: Optional[Dict[str, Any]] = None, + *, + page: int = 1, + page_size: int = 20, + total_resources: Optional[int] = None, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(resources), page=page, page_size=page_size) + page_items = resources[start:end] + total_count = max(0, self._safe_int(total_resources if total_resources is not None else len(resources), len(resources))) + lines = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {total_count} 条") + if total_pages > 1 and page_items: + first_visible = self._safe_int((page_items[0] or {}).get("pick_index"), start + 1) + last_visible = self._safe_int((page_items[-1] or {}).get("pick_index"), min(len(resources), end)) + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {first_visible}-{last_visible} / 共 {total_count} 条:") + current_provider = "" + for local_idx, item in enumerate(page_items, start=1): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + global_index = self._safe_int(item.get("pick_index"), start + local_idx) + lines.append(self._format_hdhive_resource_summary_line(item, global_index)) + lines.append("") + summary = self._score_summary(page_items, limit=5) + best_index = self._safe_int((((summary or {}).get("best") or {}).get("index")), 0) + detail_hint = f"选择 {best_index} 详情" if best_index > 0 else "选择 1 详情" + suggestion_lines = self._format_hdhive_selection_suggestion(page_items) + if suggestion_lines: + lines.append("") + lines.extend(suggestion_lines) + lines.append(f"下一步:直接回编号即可转存;想先确认可发“{detail_hint}”。") + lines.append("如需保留计划确认链,可再发“计划选择 编号”。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(line for line in lines if line is not None) + + def _assistant_hdhive_resource_entry_summary(self, resources: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(resources) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "hdhive_resource", + "label": "先查看条目再选择编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "hdhive_resource", + "label": "影巢资源列表已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + @classmethod + def _normalize_hdhive_resource_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_hdhive" or cls._clean_text(current_state.get("stage")) != "resource": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + return {} + + @staticmethod + def _format_route_result(result: Dict[str, Any]) -> str: + lines = ["已执行资源路由"] + route = result.get("route") or {} + provider = str(route.get("provider") or route.get("pan_type") or "") + if provider: + lines.append(f"网盘:{provider}") + if route.get("message"): + lines.append(f"结果:{route.get('message')}") + if route.get("target_path"): + lines.append(f"目录:{route.get('target_path')}") + return "\n".join(lines) + + async def _unlock_and_route( + self, + slug: str, + target_path: str = "", + resource: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Dict[str, Any], str]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return False, disabled.get("data") or {}, disabled.get("message") or "影巢资源入口已关闭" + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(resource) + if not points_ok: + return False, {"resource_guard": points_data, "resource": resource or {}}, points_message + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return False, result, unlock_message + + unlock_data = result.get("data") or {} + share_url = self._clean_text(unlock_data.get("full_url") or unlock_data.get("url")) + access_code = self._clean_text(unlock_data.get("access_code")) + pan_type = self._clean_text(unlock_data.get("pan_type")).lower() + + route_result: Dict[str, Any] = { + "unlock": result, + "route": { + "pan_type": pan_type or "unknown", + "share_url": share_url, + "access_code": access_code, + "executed": False, + "message": "", + }, + } + + if share_url and (pan_type == "quark" or self._is_quark_url(share_url)): + quark_service = self._ensure_quark_service() + transfer_ok, transfer_result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "quark", + "target_path": target_path or self._quark_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, ( + "影巢解锁成功,但" + + self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ) + ) + return True, route_result, "success" + + if share_url and (pan_type == "115" or self._is_115_url(share_url)): + p115_service = self._ensure_p115_service() + transfer_ok, transfer_result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "115", + "target_path": target_path or self._p115_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + title="影巢解锁成功,但 115 转存失败", + ) + return True, route_result, "success" + + route_result["route"]["message"] = "当前解锁结果未识别到可自动路由的 115 / 夸克链接" + return True, route_result, "success" + + @staticmethod + def _is_quark_url(value: str) -> bool: + return QuarkTransferService.is_quark_share_url(value) + + @staticmethod + def _is_115_url(value: str) -> bool: + host = urlparse(value or "").netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + @staticmethod + def _run_coroutine_sync(coro): + try: + return asyncio.run(coro) + except RuntimeError: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def feishu_assistant_route(self, text: str, session: str) -> Dict[str, Any]: + return self._run_coroutine_sync( + self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "feishu", + "compact": False, + }, + ) + ) + ) + + def feishu_assistant_pick(self, arg: str, session: str) -> Dict[str, Any]: + index, target_path, action, mode = self._parse_feishu_pick_arg(arg) + return self._run_coroutine_sync( + self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "feishu", + "index": index, + "action": action, + "mode": mode, + "path": target_path, + "compact": False, + }, + ) + ) + ) + + @classmethod + def _parse_feishu_pick_arg(cls, arg: str) -> Tuple[int, str, str, str]: + return cls._parse_pick_text(arg) + + async def tool_hdhive_search_session( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + target_path: str = "", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=self._clean_text(keyword), + media_type=self._clean_text(media_type or "auto").lower(), + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return f"影巢搜索失败:{search_message}" + + candidates = result.get("candidates") or [] + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type or "auto").lower(), + "year": self._clean_text(year), + "target_path": self._clean_text(target_path), + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return ( + f"{self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size)}\n" + f"session_id: {session_id}\n" + "下一步:调用 agent_resource_officer_hdhive_pick,并传入 session_id 与 choice" + ) + + async def tool_hdhive_pick_session(self, session_id: str, index: int, target_path: str = "", action: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + session = self._load_session(self._clean_text(session_id)) + if not session: + return "会话不存在或已过期" + + stage = session.get("stage") + service = self._ensure_hdhive_service() + action = self._normalize_pick_action(action) + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(self._clean_text(session_id), {**session, "candidates": enriched}) + return self._format_candidate_lines(enriched, page=current_page, page_size=page_size) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_candidate_lines(candidates, page=next_page, page_size=page_size) + if index <= 0 or index > len(candidates): + return "候选编号超出范围" + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return f"影巢资源查询失败:{resource_message}" + preferences = self._normalize_assistant_preferences( + (self._assistant_preferences or {}).get(self._normalize_preference_key(session=self._clean_text(session.get("keyword")) or "default")) + ) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path or session.get("target_path") or "", + ) + self._save_session( + self._clean_text(session_id), + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": self._clean_text(target_path) or session.get("target_path") or "", + }, + ) + return self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)) + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)) + if index <= 0 or index > len(resources): + return "资源编号超出范围" + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=self._clean_text(target_path) or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return f"资源处理失败:{route_message}" + return self._format_route_result(route_result) + + return f"当前会话阶段不支持继续选择:{stage}" + + async def tool_route_share(self, share_url: str, access_code: str = "", target_path: str = "") -> str: + share_url = self._clean_text(share_url) + if not share_url: + return "缺少分享链接" + + if self._is_quark_url(share_url): + service = self._ensure_quark_service() + ok, result, message = service.transfer_share( + share_url, + access_code=self._clean_text(access_code), + target_path=self._clean_text(target_path) or self._quark_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_quark_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._quark_default_path, + ) + return f"夸克转存成功\n目录:{result.get('target_path') or self._quark_default_path}" + + if self._is_115_url(share_url): + ok, result, message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(access_code), + path=self._clean_text(target_path) or self._p115_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_p115_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._p115_default_path, + ) + return f"115 转存成功\n目录:{result.get('path') or self._p115_default_path}" + + return "当前链接不是可识别的 115 / 夸克分享链接" + + async def tool_assistant_route( + self, + text: str = "", + session: str = "default", + session_id: str = "", + target_path: str = "", + mode: str = "", + keyword: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + action: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "path": self._clean_text(target_path), + "mode": self._clean_text(mode), + "keyword": self._clean_text(keyword), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "action": self._clean_text(action), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "处理完成") + + async def tool_assistant_pick( + self, + session: str = "default", + session_id: str = "", + index: int = 0, + action: str = "", + mode: str = "", + target_path: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": index, + "action": self._clean_text(action), + "mode": self._clean_text(mode), + "path": self._clean_text(target_path), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "继续处理完成") + + async def tool_assistant_help(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + return self._format_assistant_help_text(session=session_name) + + async def tool_assistant_capabilities(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_capabilities_compact_data(self._assistant_capabilities_public_data()) + return ( + f"Agent影视助手:{data.get('version')};" + f"工作流 {len(data.get('workflows') or [])} 个;" + f"Tool {len(data.get('agent_tools') or [])} 个" + ) + return self._format_assistant_capabilities_text() + + async def tool_assistant_readiness(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_readiness_compact_data(self._assistant_readiness_public_data()) + services = data.get("services") or {} + return ( + f"就绪:{'是' if data.get('can_start') else '否'};" + f"115:{'可用' if services.get('p115_ready') else '不可用'};" + f"影巢:{'已配' if services.get('hdhive_configured') else '未配'};" + f"夸克:{'已配' if services.get('quark_configured') else '未配'};" + f"待计划:{data.get('saved_plans_pending') or 0}" + ) + return self._format_assistant_readiness_text() + + async def tool_feishu_health(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + channel = self._ensure_feishu_channel() + data = { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + } + if not compact: + return json.dumps(data, ensure_ascii=False, indent=2) + return ( + f"飞书入口:{'已开启' if data.get('enabled') else '未开启'};" + f"长连接:{'运行中' if data.get('running') else '未运行'};" + f"SDK:{'可用' if data.get('sdk_available') else '缺失'};" + f"AppID:{'已填' if data.get('app_id_configured') else '未填'};" + f"AppSecret:{'已填' if data.get('app_secret_configured') else '未填'};" + f"白名单:chat {data.get('allowed_chat_count') or 0} / user {data.get('allowed_user_count') or 0};" + f"其他飞书入口:{'检测到运行中' if data.get('legacy_bridge_running') else '未检测到'}" + ) + + async def tool_assistant_pulse(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_pulse_text() + + async def tool_assistant_startup(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_startup_text() + + async def tool_assistant_maintain(self, execute: bool = False, limit: int = 100) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + return self._format_assistant_maintain_text(data) + + async def tool_assistant_toolbox(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_toolbox_text() + + async def tool_assistant_request_templates( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return self._format_assistant_request_templates_text(data) + + async def tool_assistant_selfcheck(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_selfcheck_text() + + async def tool_assistant_history( + self, + session: str = "", + session_id: str = "", + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_history_compact_data( + self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + ) + failed = sum(1 for item in (data.get("items") or []) if not item.get("success")) + return f"最近执行历史:{len(data.get('items') or [])} 条;失败 {failed} 条" + return self._format_assistant_history_text(session=session, session_id=session_id, limit=limit) + + async def tool_assistant_execute_action( + self, + name: str, + session: str = "default", + session_id: str = "", + choice: Optional[int] = None, + target_path: str = "", + keyword: str = "", + media_type: str = "", + year: str = "", + share_url: str = "", + access_code: str = "", + client_type: str = "", + source: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = "", + prefer_unexecuted: bool = True, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_action( + _JsonRequestShim( + _RequestContextShim(), + { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": choice, + "path": self._clean_text(target_path), + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + "plan_id": self._clean_text(plan_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "动作执行完成") + + async def tool_assistant_execute_actions( + self, + actions: List[Dict[str, Any]], + session: str = "default", + session_id: str = "", + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_actions( + _JsonRequestShim( + _RequestContextShim(), + { + "actions": actions or [], + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "批量动作执行完成") + + async def tool_assistant_workflow( + self, + name: str, + session: str = "default", + session_id: str = "", + keyword: str = "", + choice: Optional[int] = None, + candidate_choice: Optional[int] = None, + resource_choice: Optional[int] = None, + target_path: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + source: str = "", + limit: int = 20, + dry_run: Optional[bool] = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + payload = { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "keyword": self._clean_text(keyword), + "choice": choice, + "candidate_choice": candidate_choice, + "resource_choice": resource_choice, + "path": self._clean_text(target_path), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "limit": self._safe_int(limit, 20), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + } + if dry_run is not None: + payload["dry_run"] = bool(dry_run) + result = await self.api_assistant_workflow( + _JsonRequestShim(_RequestContextShim(), payload) + ) + return str(result.get("message") or "工作流执行完成") + + async def tool_assistant_preferences( + self, + session: str = "default", + session_id: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + reset: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + method = "DELETE" if reset else "POST" if preferences else "GET" + result = await self.api_assistant_preferences( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "user_key": self._clean_text(user_key), + "preferences": preferences or {}, + "compact": bool(compact), + }, + method=method, + ) + ) + return str(result.get("message") or "偏好画像处理完成") + + async def tool_assistant_execute_plan( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_plan_execute( + _JsonRequestShim( + _RequestContextShim(), + { + "plan_id": self._clean_text(plan_id), + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "计划执行完成") + + async def tool_assistant_plans( + self, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_plans_compact_data( + self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=False, + limit=limit, + ) + ) + pending = sum(1 for item in (data.get("items") or []) if not item.get("executed")) + return f"已保存计划:{len(data.get('items') or [])} 条;待执行 {pending} 条" + return self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + + async def tool_assistant_plans_clear( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = self._clear_workflow_plans( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + return str(result.get("message") or "计划清理完成") + + async def tool_assistant_recover( + self, + session: str = "", + session_id: str = "", + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_recover( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "execute": bool(execute), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + "limit": self._safe_int(limit, 20), + }, + ) + ) + return str(result.get("message") or "恢复检查完成") + + async def tool_assistant_session_state(self, session: str = "default", session_id: str = "", compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if compact: + state = self._assistant_session_compact_data(self._assistant_session_public_data(session=session_name)) + recovery = state.get("recovery") or {} + parts = [ + f"会话:{state.get('session')}", + f"阶段:{state.get('kind') or '-'} / {state.get('stage') or '-'}", + f"恢复:{recovery.get('mode') or '-'}", + ] + if recovery.get("recommended_action"): + parts.append(f"推荐动作:{recovery.get('recommended_action')}") + return "\n".join(parts) + return self._format_assistant_session_summary(session=session_name) + + async def tool_assistant_sessions( + self, + kind: str = "", + has_pending_p115: Optional[bool] = None, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_sessions_compact_data( + self._assistant_sessions_public_data(kind=kind, has_pending_p115=has_pending_p115, limit=limit) + ) + return f"活跃会话:{data.get('total') or 0} 个;展示 {len(data.get('items') or [])} 个" + return self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + + async def tool_assistant_session_clear(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + existing = self._load_session(cache_key) + if not existing: + return "当前没有需要清理的会话。" + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return f"已清理会话:{session_name}" + + async def tool_assistant_sessions_clear( + self, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_sessions_clear( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + }, + ) + ) + return str(result.get("message") or "会话清理完成") + + async def tool_p115_qrcode_start(self, client_type: str = "alipaymini") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + final_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=final_client_type) + if not qr_ok: + return f"115 扫码二维码生成失败:{qr_message}" + return ( + "115 扫码二维码已生成\n" + f"client_type: {data.get('client_type')}\n" + f"uid: {data.get('uid')}\n" + f"time: {data.get('time')}\n" + f"sign: {data.get('sign')}\n" + f"qrcode: {data.get('qrcode')}\n" + "下一步:调用 agent_resource_officer_p115_qrcode_check,并传入 uid、time、sign 和 client_type" + ) + + async def tool_p115_qrcode_check( + self, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(uid), + time_value=self._clean_text(time_value), + sign=self._clean_text(sign), + client_type=P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type), + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": self._p115_client_type, + }) + data["cookie_saved"] = True + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"status: {status or 'unknown'}", + f"message: {qr_message}", + ] + if data.get("cookie_saved"): + lines.append("cookie_saved: true") + lines.append(self._format_p115_status_summary(title="115 登录完成")) + if data.get("cookie_keys"): + lines.append(f"cookie_keys: {', '.join(data.get('cookie_keys') or [])}") + return "\n".join(lines) + + async def tool_p115_status(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_p115_status_summary() + + async def tool_p115_pending(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + return summary or "当前没有待继续的 115 任务。" + + async def tool_p115_resume(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return "当前没有待继续的 115 任务。" + if not self._p115_status_snapshot().get("ready"): + return f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。" + resume_ok, resume_message, _ = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 Agent Tool 手动继续 115 任务", + ) + lines = ["已手动继续 115 任务", resume_message] + if not resume_ok: + lines.append("任务仍未成功,保留待继续状态。") + return "\n".join(line for line in lines if line) + + async def tool_p115_cancel(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + if not summary: + return "当前没有待取消的 115 任务。" + self._clear_pending_p115_share(session_id) + return f"{summary}\n已取消并清除这次待继续的 115 任务。" + + async def api_p115_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_p115_service() + health_ok, result, health_message = service.health() + cookie_state = result.get("cookie_state") or {} + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "p115_ready": health_ok, + "p115_direct_ready": bool(result.get("direct_ready")), + "p115_direct_source": result.get("direct_source") or "", + "p115_helper_ready": bool(result.get("helper_ready")), + "default_target_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie_configured": bool(cookie_state.get("configured")), + "p115_cookie_valid": bool(cookie_state.get("valid")), + "p115_cookie_mode": cookie_state.get("mode") or "none", + "p115_cookie_keys": cookie_state.get("cookie_keys") or [], + "message": "" if health_ok else health_message, + "raw": result, + }, + } + + async def api_p115_qrcode(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": qr_message} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_qrcode_check(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + uid = self._clean_text(request.query_params.get("uid")) + time_value = self._clean_text(request.query_params.get("time")) + sign = self._clean_text(request.query_params.get("sign")) + if not uid or not time_value or not sign: + return {"success": False, "message": "缺少 uid/time/sign,无法检查扫码状态"} + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=uid, + time_value=time_value, + sign=sign, + client_type=client_type, + ) + if qr_ok and (data.get("status") == "success"): + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + data["status_summary"] = self._format_p115_status_summary(title="115 登录完成") + if not qr_ok: + return {"success": False, "message": qr_message, "data": data} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + url = self._clean_text(body.get("url") or body.get("share_url")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_p115_service() + transfer_ok, result, transfer_message = service.transfer_share( + url=url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_p115_pending(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + data = self._pending_p115_public_data(state) + data["session_id"] = session_id + return { + "success": True, + "message": summary or "当前没有待继续的 115 任务。", + "data": data, + } + + async def api_p115_pending_resume(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return { + "success": False, + "message": "当前没有待继续的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。", + "data": {"session_id": session_id, **self._pending_p115_public_data(state)}, + } + resume_ok, resume_message, resume_data = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 API 手动继续 115 任务", + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": { + "session_id": session_id, + "result": resume_data, + "pending": self._pending_p115_public_data(self._load_session(session_id) or {}), + }, + } + + async def api_p115_pending_cancel(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + if not summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + pending_data = self._pending_p115_public_data(state) + self._clear_pending_p115_share(session_id) + return { + "success": True, + "message": f"{summary}\n已取消并清除这次待继续的 115 任务。", + "data": {"session_id": session_id, "cancelled": True, "pending": pending_data}, + } + + async def api_hdhive_unlock_and_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + route_ok, route_result, route_message = await self._unlock_and_route(slug, target_path=target_path, resource=body) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return {"success": True, "message": route_message, "data": route_result} + + async def api_share_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_url = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 自动路由") + + if self._is_quark_url(share_url): + quark_service = self._ensure_quark_service() + transfer_ok, result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": { + "provider": "quark", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "quark", + "result": result, + }, + } + + if self._is_115_url(share_url): + p115_service = self._ensure_p115_service() + transfer_ok, result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": { + "provider": "115", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "115", + "result": result, + }, + } + + return { + "success": False, + "message": "当前链接不是可识别的 115 / 夸克分享链接", + "data": {"provider": "unknown", "url": share_url}, + } + + async def api_assistant_preferences(self, request: Request): + body: Dict[str, Any] = {} + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session") or "default") + user_key = self._clean_text(body.get("user_key") or request.query_params.get("user_key")) + if request.method.upper() == "DELETE" or self._parse_bool_value(body.get("reset"), False): + preferences_data = self._reset_assistant_preferences(session=session, user_key=user_key) + return { + "success": True, + "message": "智能体片源偏好已重置,下一次启动会重新进入偏好询问。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_reset", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + if request.method.upper() == "POST": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else { + key: value + for key, value in body.items() + if key not in {"apikey", "session", "session_id", "user_key", "compact", "reset"} + } + preferences_data = self._save_assistant_preferences(session=session, user_key=user_key, preferences=preferences) + return { + "success": True, + "message": "智能体片源偏好已保存。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + + preferences_data = self._assistant_preferences_public_data(session=session, user_key=user_key) + message_text = "智能体片源偏好未初始化,请先询问用户偏好。" if preferences_data.get("needs_onboarding") else "智能体片源偏好已初始化。" + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "preferences", + "ok": True, + "preferences": preferences_data, + }), + } + + async def api_assistant_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + text = self._clean_text(body.get("text") or body.get("query") or body.get("message") or "") + state = self._load_session(cache_key) or {} + compact = self._parse_bool_value(body.get("compact"), False) + saved_plan = self._session_workflow_plan_public_data(session=session, session_id=cache_key) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + pending_plan_group = self._clean_text(state.get("pending_plan_group")) + numeric_confirmation = self._parse_pending_plan_numeric_confirmation(text) + pending_multi_plan = None + if pending_plan_group and (numeric_confirmation > 0 or self._is_pending_plan_confirmation_text(text)): + pending_multi_plan = self._find_pending_multi_plan( + session=session, + session_id=cache_key, + rank=numeric_confirmation if numeric_confirmation > 0 else 1, + group_id=pending_plan_group, + ) + pending_plan = pending_multi_plan or self._find_workflow_plan(session=session, session_id=cache_key, executed=False) + if ( + pending_plan + and ( + self._is_pending_plan_confirmation_text(text) + or self._is_pending_plan_numeric_confirmation(text, pending_plan) + ) + and not self._clean_text(body.get("plan_id")) + ): + if isinstance(state.get("recommend_handoff"), dict) and state.get("recommend_handoff"): + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(pending_plan.get("plan_id")) if pending_multi_plan else "", + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_direct_intent = self._normalize_mp_recommend_direct_intent(text) + if recommend_direct_intent: + source_name, inferred_media_type = self._normalize_mp_recommend_request( + recommend_direct_intent.get("keyword") or "tmdb_trending" + ) + recommend_result = await self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + ) + if not recommend_result.get("success"): + return finish(recommend_result) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_direct_intent.get("index") or 1, + "action": recommend_direct_intent.get("action"), + "mode": recommend_direct_intent.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_short_direct = self._normalize_mp_recommend_short_action(text, state=state) + if recommend_short_direct: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_short_direct.get("index"), + "action": recommend_short_direct.get("action"), + "mode": recommend_short_direct.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_source_compound = self._normalize_recommend_source_compound_action(text, state=state) + if recommend_source_compound: + return finish(await self._assistant_run_recommend_source_compound( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(recommend_source_compound.get("mode")), + followup_action=self._clean_text(recommend_source_compound.get("followup_action")), + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + + hdhive_resource_short = self._normalize_hdhive_resource_short_action(text, state=state) + if hdhive_resource_short: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": hdhive_resource_short.get("index"), + "action": hdhive_resource_short.get("action"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + parsed = self._merge_assistant_structured_input(body, self._parse_assistant_text(text)) + target_path = parsed.get("path") or "" + + recommend_followup = self._normalize_mp_recommend_followup(text, state=state) + has_recommend_followup = bool(recommend_followup) + if self._clean_text((recommend_followup or {}).get("action")) == "mp_recommendations": + return finish(await self._assistant_mp_recommendations( + source=self._clean_text(recommend_followup.get("keyword") or state.get("requested_source") or state.get("source") or "tmdb_trending"), + media_type=self._clean_text(recommend_followup.get("type") or state.get("media_type") or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if recommend_followup: + for key, value in recommend_followup.items(): + if value not in {None, ""}: + parsed[key] = str(value) + preparsed_action = self._clean_text(parsed.get("action")) + recommend_handoff_short = self._normalize_recommend_handoff_short_action(text, state=state) + recommend_short = self._normalize_mp_recommend_short_action(text, state=state) + if preparsed_action not in {"ai_replay_failed_sample"} and recommend_short: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": recommend_short.get("index"), + "action": recommend_short.get("action"), + "mode": recommend_short.get("mode"), + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + pick_index, pick_path, pick_action, pick_mode = self._parse_pick_text(text) + handoff_route_action = self._clean_text(recommend_handoff_short.get("route_action")) + if not pick_index and handoff_route_action: + pick_action = handoff_route_action + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"} and (pick_index > 0 or pick_action): + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": pick_index, + "action": pick_action, + "mode": pick_mode, + "path": target_path or pick_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"}: + route_action = self._normalize_pick_action(text) + smart_route_action = self._normalize_smart_search_short_action(text, state_kind=state.get("kind")) + if smart_route_action: + route_action = smart_route_action + if handoff_route_action: + route_action = handoff_route_action + if route_action: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": 0, + "action": route_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not text and not any(parsed.get(key) for key in ["mode", "keyword", "url", "action"]): + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + + assistant_action = preparsed_action + ai_short_action = self._normalize_ai_reingest_short_action(text, state=state) + if not assistant_action and ai_short_action: + for key, value in ai_short_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + recommend_handoff_action = self._normalize_recommend_handoff_action(text, state=state) + if not assistant_action and recommend_handoff_action: + for key, value in recommend_handoff_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + if not assistant_action and self._clean_text(recommend_handoff_short.get("action")): + for key, value in recommend_handoff_short.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + keyword = self._clean_text(parsed.get("keyword")) + if assistant_action == "assistant_help": + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + if assistant_action == "execute_plan": + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(parsed.get("plan_id")), + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + if assistant_action == "plans_list": + include_actions = self._parse_bool_value(body.get("include_actions"), False) + executed = self._parse_optional_bool(body.get("executed")) + limit = self._safe_int(body.get("limit"), 10) + plans_data = self._assistant_plans_public_data( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + return finish({ + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_list", + "ok": True, + **plans_data, + }), + }) + if assistant_action == "plans_clear": + plan_id = self._clean_text(parsed.get("plan_id")) + if not plan_id: + return finish({ + "success": False, + "message": "取消或清理计划需要指定 plan_id,例如:取消计划 plan-xxxx。", + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": False, + "error_code": "missing_plan_id", + }), + }) + clear_result = self._clear_workflow_plans(plan_id=plan_id, limit=1) + return finish({ + "success": bool(clear_result.get("ok")), + "message": str(clear_result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": bool(clear_result.get("ok")), + **clear_result, + }), + }) + if assistant_action in {"preferences_get", "preferences_save", "preferences_reset"}: + if assistant_action == "preferences_save": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else self._parse_assistant_preferences_text(text) + if not preferences: + return finish({ + "success": False, + "message": ( + "保存偏好缺少可识别内容。示例:保存偏好 4K 杜比 HDR 中字 全集 " + "做种>=3 影巢积分20 不自动入库" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": False, + "error_code": "missing_preferences", + }), + }) + payload = { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "preferences": preferences, + "compact": compact, + "apikey": self._extract_apikey(request, body), + } + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, payload, method="POST"))) + if assistant_action == "preferences_reset": + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="DELETE"))) + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="GET"))) + if assistant_action == "scoring_policy": + return finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if assistant_action == "hdhive_checkin": + is_gambler = self._parse_bool_value(parsed.get("is_gambler"), self._hdhive_checkin_gambler_mode) + result = self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 智能入口") + data = result.get("data") if isinstance(result.get("data"), dict) else {} + status = data.get("status") or ("签到成功" if result.get("success") else "签到失败") + mode_text = "赌狗签到" if is_gambler else "普通签到" + summary = f"影巢{mode_text}:{status}\n{result.get('message') or ''}".strip() + return finish({ + "success": bool(result.get("success")), + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin", + "ok": bool(result.get("success")), + "is_gambler": is_gambler, + "status_summary": summary, + "result": data, + }), + }) + if assistant_action == "hdhive_checkin_history": + summary = self._format_hdhive_checkin_history_text(limit=10) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin_history", + "ok": True, + "status_summary": summary, + "history": self._hdhive_checkin_history_public_data(limit=10), + }), + }) + if assistant_action == "mp_media_detail": + if not keyword: + return finish({ + "success": False, + "message": "媒体识别失败:缺少片名。用法:识别 蜘蛛侠", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_mp_media_detail( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "auto"), + year=self._clean_text(body.get("year") or parsed.get("year")), + )) + if assistant_action == "mp_downloaders": + return finish(await self._assistant_mp_downloaders(session=session, cache_key=cache_key)) + if assistant_action == "mp_sites": + return finish(await self._assistant_mp_sites( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "active"), + name=keyword, + limit=self._safe_int(body.get("limit"), 30), + )) + if assistant_action == "mp_subscribes": + return finish(await self._assistant_mp_subscribes( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "all"), + name=keyword, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "mp_transfer_history": + return finish(await self._assistant_mp_transfer_history( + session=session, + cache_key=cache_key, + title=keyword, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_ingest_failures": + return finish(await self._assistant_mp_ingest_failures( + session=session, + cache_key=cache_key, + title=keyword, + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "ai_failed_samples": + return finish(await self._assistant_ai_failed_samples( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_worklist": + return finish(await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_insights": + return finish(await self._assistant_ai_sample_insights( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if assistant_action == "ai_replay_failed_sample": + sample_index = self._safe_int(parsed.get("sample_index") or body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(parsed.get("remove_if_resolved") or body.get("remove_if_resolved"), True) + return finish(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if assistant_action == "mp_subscribe_control": + control = self._clean_text(parsed.get("subscribe_control") or body.get("subscribe_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "搜索": "search", + "刷新": "search", + "search": "search", + "run": "search", + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + target = self._clean_text(keyword or body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if control not in {"search", "pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“订阅列表”,再发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”或“删除订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + }) + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id): + return finish({ + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return finish(await self._assistant_mp_subscribe_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if assistant_action == "mp_download_tasks": + return finish(await self._assistant_mp_download_tasks( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "downloading"), + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "mp_download_history": + return finish(await self._assistant_mp_download_history( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_lifecycle_status": + return finish(await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_ingest_status": + return finish(await self._assistant_mp_ingest_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_recent_activity": + return finish(await self._assistant_mp_recent_activity( + session=session, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only") or parsed.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only") or parsed.get("transfer_only"), False), + )) + if assistant_action == "smart_followup": + return finish(await self._assistant_smart_followup( + request, + session=session, + session_id=cache_key, + keyword=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "smart_decision_adjust": + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=self._clean_text(parsed.get("decision_adjust") or body.get("decision_adjust") or "decision_continue"), + )) + if assistant_action == "execution_followup": + return finish(await self._assistant_execution_followup( + request, + session=session, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id") or parsed.get("plan_id")), + )) + if assistant_action == "mp_local_diagnose": + return finish(await self._assistant_mp_local_diagnose( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_download_control": + control = self._clean_text(parsed.get("download_control") or body.get("download_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "开始": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + target = self._clean_text(keyword or body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if control not in {"pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“下载任务”,再发“暂停下载 1”“恢复下载 1”或“删除下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + }) + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return finish({ + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_task_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ))) + return finish(await self._assistant_mp_download_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + )) + if assistant_action == "mp_download_best": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + return finish(await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action == "mp_download": + choice = self._safe_int(parsed.get("keyword") or body.get("choice") or body.get("index"), 0) + if choice <= 0: + return finish({ + "success": False, + "message": "用法:下载资源 1", + "data": self._assistant_response_data(session=session, data={"action": "mp_download", "ok": False}), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ))) + return finish(await self._assistant_mp_download( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action in {"mp_subscribe", "mp_subscribe_search"}: + if not keyword: + return finish({ + "success": False, + "message": "用法:订阅媒体 片名 或 订阅并搜索 片名", + "data": self._assistant_response_data(session=session, data={"action": assistant_action, "ok": False}), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session, + cache_key=cache_key, + immediate_search=assistant_action == "mp_subscribe_search", + ))) + return finish(await self._assistant_mp_subscribe( + keyword=keyword, + session=session, + immediate_search=assistant_action == "mp_subscribe_search", + )) + if assistant_action == "mp_recommendations": + source, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or parsed.get("keyword") or text or "tmdb_trending" + ) + return finish(await self._assistant_mp_recommendations( + source=source, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if assistant_action == "return_to_recommend": + return finish(await self._assistant_restore_mp_recommendation_handoff( + session=session, + cache_key=cache_key, + state=state, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "switch_recommend_handoff_source": + return finish(await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(parsed.get("mode")), + )) + if assistant_action == "return_to_smart_decision": + return finish(await self._assistant_route_recommend_handoff_to_smart_decision( + request, + session=session, + cache_key=cache_key, + state=state, + )) + if assistant_action == "confirm_recommend_handoff": + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=target_path, + )) + if assistant_action == "p115_help": + summary = self._format_p115_help_text() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_help", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + } + if assistant_action == "p115_status": + summary = self._format_p115_status_summary() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_status", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }) + if assistant_action == "p115_pending": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + return { + "success": True, + "message": pending_summary, + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + if assistant_action == "quark_clear_default_dir": + bridge_result = self._assistant_quarkdisk_clear_directory(self._quark_default_path) + if bridge_result is None: + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + else: + clear_ok, clear_result, clear_message = bridge_result + if not clear_ok and isinstance(clear_result, dict) and clear_result.get("fallback_to_direct"): + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + target_path = self._clean_text((clear_result or {}).get("target_path")) or self._quark_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + failed_items = (clear_result or {}).get("failed_items") or [] + lines = [ + f"清空夸克默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if removed_count: + lines.append(f"已删除:{removed_count} 项") + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + if isinstance(failed_items, list) and failed_items: + names = [ + self._clean_text(item.get("name") if isinstance(item, dict) else item) + for item in failed_items[:5] + ] + names = [name for name in names if name] + if names: + lines.append("失败项:" + "、".join(names)) + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"夸克默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"夸克默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_clear_default_dir": + p115_service = self._ensure_p115_service() + clear_ok, clear_result, clear_message = p115_service.clear_directory(self._p115_default_path) + target_path = self._clean_text((clear_result or {}).get("path")) or self._p115_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + lines = [ + f"清空115默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"115 默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"115 默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_resume": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + summary = self._format_p115_status_summary() + return { + "success": False, + "message": f"当前没有待继续的 115 任务。\n{summary}", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{pending_summary}\n当前 115 还不可用,请先回复:115登录", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": resume_ok, "result": resume_data}), + } + if assistant_action == "p115_cancel": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + self._clear_pending_p115_share(cache_key) + return { + "success": True, + "message": f"{pending_summary}\n已取消并清除这次待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + if assistant_action == "p115_qrcode_start": + previous_state = state + client_type = P115TransferService.normalize_qrcode_client_type( + parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": f"115 扫码二维码生成失败:{qr_message}"} + self._save_session( + cache_key, + { + **previous_state, + "kind": "assistant_p115_login", + "stage": "qrcode", + "client_type": client_type, + "uid": self._clean_text(data.get("uid")), + "time": self._clean_text(data.get("time")), + "sign": self._clean_text(data.get("sign")), + }, + ) + pending_text = "" + if (previous_state.get("pending_p115") or {}).get("share_url"): + pending_text = "\n检测到有待继续的 115 任务,登录成功后我会自动继续执行。" + return { + "success": True, + "message": ( + "115 扫码二维码已生成\n" + f"客户端:{client_type}\n" + "请使用 115 App 扫码确认后,再回复:检查115登录" + f"{pending_text}" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_start", + "ok": True, + "qrcode": data.get("qrcode"), + "uid": data.get("uid"), + "time": data.get("time"), + "sign": data.get("sign"), + "client_type": client_type, + }), + } + if assistant_action == "p115_qrcode_check": + if not state or str(state.get("kind") or "").strip() != "assistant_p115_login": + pending_summary = self._pending_p115_summary(state) + if pending_summary and self._p115_status_snapshot().get("ready"): + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "没有待检查的扫码会话,但检测到待继续的 115 任务。" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,继续保留待处理状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": resume_ok, "result": resume_data}), + } + summary = self._format_p115_status_summary() + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": ( + "没有待检查的 115 登录会话。\n" + f"{summary}\n" + "如需重新扫码登录,请回复:115登录" + ), + "data": { + **self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_check", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }, + } + client_type = P115TransferService.normalize_qrcode_client_type( + state.get("client_type") or parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(state.get("uid")), + time_value=self._clean_text(state.get("time")), + sign=self._clean_text(state.get("sign")), + client_type=client_type, + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + self._save_session(cache_key, {**state, "stage": "success", "client_type": client_type}) + if not qr_ok: + return { + "success": False, + "message": f"115 扫码状态:{qr_message}", + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": False, **data}), + } + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"状态:{status or 'unknown'}", + f"结果:{qr_message}", + ] + if data.get("cookie_saved"): + lines.append(self._format_p115_status_summary(title="115 登录完成")) + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + if resume_message: + lines.append("已自动继续刚才未完成的 115 任务") + lines.append(resume_message) + data["resume_ok"] = resume_ok + data["resume_result"] = resume_data + if not resume_ok: + lines.append("任务仍未成功,已继续保留待处理状态。") + elif status in {"waiting", "scanned"}: + lines.append("如果还没确认登录,请在 115 App 里点确认后再次回复:检查115登录") + message_text = "\n".join(line for line in lines if line).strip() + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": True, **data}), + } + + if parsed.get("url"): + provider = "quark" if self._is_quark_url(parsed["url"]) else "115" if self._is_115_url(parsed["url"]) else "unknown" + result = await self.api_share_route( + _JsonRequestShim(request, { + "url": parsed["url"], + "access_code": parsed.get("access_code") or "", + "path": target_path, + "trigger": "Agent影视助手 智能入口", + "apikey": self._extract_apikey(request, body), + }) + ) + if provider == "115": + if result.get("success"): + self._clear_pending_p115_share(cache_key) + else: + self._save_pending_p115_share( + cache_key, + share_url=parsed["url"], + access_code=parsed.get("access_code") or "", + target_path=target_path or self._p115_default_path, + source="assistant_link", + last_error=str(result.get("message") or ""), + ) + return finish({ + "success": bool(result.get("success")), + "message": ( + f"{'夸克' if provider == 'quark' else '115' if provider == '115' else '分享'}转存已完成\n目录:" + f"{((result.get('data') or {}).get('result') or {}).get('target_path') or ((result.get('data') or {}).get('result') or {}).get('path') or target_path or '-'}" + if result.get("success") + else ( + f"{str(result.get('message') or '处理失败')}\n{self._format_p115_resume_hint()}" + if provider == "115" + else str(result.get("message") or "处理失败") + ) + ), + "data": self._assistant_response_data(session=session, data={ + "action": "share_route", + "ok": bool(result.get("success")), + "provider": provider, + "result": result.get("data") or {}, + }), + }) + + mode = parsed.get("mode") or "hdhive" + media_type = self._clean_text(parsed.get("type") or "auto").lower() or "auto" + year = self._clean_text(parsed.get("year")) + result_filter = self._clean_text(parsed.get("result_filter")).lower() + decision_intent = self._clean_text(parsed.get("decision_intent")).lower() + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else None + if not source_order: + parsed_source_order_text = self._clean_text(parsed.get("source_order_text")) + if parsed_source_order_text: + source_order = [ + self._clean_text(item).lower() + for item in parsed_source_order_text.split(",") + if self._clean_text(item) + ] + origin = self._clean_text(body.get("origin")) + recommend_handoff = body.get("recommend_handoff") if isinstance(body.get("recommend_handoff"), dict) else {} + apikey = self._extract_apikey(request, body) + session_preference_overrides: Dict[str, Any] = {} + cloud_provider = self._clean_text(parsed.get("cloud_provider") or body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider == "quark": + session_preference_overrides.update({ + "has_quark": True, + "has_115": False, + "prefer_cloud_provider": "quark", + }) + elif cloud_provider == "115": + session_preference_overrides.update({ + "has_quark": False, + "has_115": True, + "prefer_cloud_provider": "115", + }) + + if mode == "update": + return finish(await self._assistant_update_check( + keyword=keyword, + session=session, + cache_key=cache_key, + year=year, + )) + + if mode == "mp_download_title": + if not keyword: + return finish({ + "success": False, + "message": "用法:下载 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_title", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "mp_download_title", + "label": "生成待确认下载计划", + "result_filter": result_filter, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + + if mode == "smart": + if not keyword: + return finish({ + "success": False, + "message": "用法:智能搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_search", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + )) + + if mode == "cloud_transfer_execute": + if not keyword: + return finish({ + "success": False, + "message": "用法:转存 片名;也支持:夸克转存 片名 / 115转存 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_transfer_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + pending_label = "云盘转存" + if cloud_provider == "quark": + pending_label = "夸克转存" + elif cloud_provider == "115": + pending_label = "115转存" + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "cloud_transfer_execute", + "cloud_provider": cloud_provider, + "label": pending_label, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + return finish(await self._assistant_cloud_transfer_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin or cloud_provider or "cloud_transfer", + )) + + if mode == "smart_decision": + if not keyword: + return finish({ + "success": False, + "message": "用法:资源决策 片名;也支持:智能决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + origin=origin, + )) + + if mode == "smart_plan": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能计划 片名;也可以先做“智能搜索 片名”,再回复“计划最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "smart_execute": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能执行 片名;也可以先做“智能搜索 片名”,再回复“执行最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "mp": + if not keyword: + return finish({ + "success": False, + "message": "用法:MP搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "media_search", + "ok": False, + }), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={"mode": "mp", "result_filter": result_filter} if result_filter else None, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + if candidate_year and candidate_year not in candidate_title: + keyword = f"{candidate_title} {candidate_year}" + else: + keyword = candidate_title + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + mp_items = (result.get("data") or {}).get("items") or [] + if (not result.get("success") or not mp_items) and keyword: + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="mp_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("MP/PT 当前暂无可用结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + search_ok, hdhive_result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if search_ok: + candidates = hdhive_result.get("candidates") or [] + if candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="MP/PT 当前暂无可用结果,已自动补查影巢。", + )) + if result.get("success") and recommend_handoff: + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, {**current_state, "recommend_handoff": dict(recommend_handoff)}) + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(self._load_session(cache_key) or {})) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(self._load_session(cache_key) or {}) + result["data"] = self._assistant_response_data(session=session, data=result_data) + return finish(result) + + if mode == "pansou" or mode == "cloud": + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if not search_ok: + if mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜搜索失败:{keyword}\n错误:{search_message}"} + search_ok = False + payload = {} + search_message = search_message + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + if mode == "cloud" and not items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = local_index + hdhive_candidate = dict(chosen_candidate) + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=[], + pansou_total=int(data.get("total") or 0), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if not items and mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜暂无结果:{keyword}"} + if items and mode == "cloud": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_cloud_result_page_size) + + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = len(items) + local_index + hdhive_candidate = dict(chosen_candidate) + + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=items, + pansou_total=int(data.get("total") or len(items)), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=items, + total=int(data.get("total") or len(items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search" if mode == "pansou" else "cloud_search", + search_scope="pansou" if mode == "pansou" else "pansou_then_hdhive", + recommend_handoff=recommend_handoff, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + if mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return {"success": False, "message": f"影巢搜索失败:{search_message}", "data": result} + candidates = result.get("candidates") or [] + if not candidates and mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + )) + + async def api_assistant_action(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + name = self._clean_text(body.get("name") or body.get("action_name")) + if not name: + return {"success": False, "message": "缺少动作名 name"} + compact = self._parse_bool_value(body.get("compact"), False) + + async def finish(awaitable): + result = await awaitable + return self._assistant_single_action_compact_response(name, result) if compact else result + + async def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + route_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + pick_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + + if name == "start_pansou_search": + route_payload.update({ + "mode": "pansou", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_search": + route_payload.update({ + "mode": "smart", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_decision": + route_payload.update({ + "mode": "smart_decision", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + "decision_profile": body.get("decision_profile"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_plan": + route_payload.update({ + "mode": "smart_plan", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_execute": + route_payload.update({ + "mode": "smart_execute", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_hdhive_search": + route_payload.update({ + "mode": "hdhive", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_mp_media_search": + route_payload.update({ + "mode": "mp", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "query_mp_media_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_media_detail( + keyword=self._clean_text(body.get("keyword") or body.get("title")), + session=session_name, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or "auto"), + year=self._clean_text(body.get("year")), + )) + if name == "query_mp_search_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_result_detail( + choice=self._safe_int(body.get("choice") or body.get("index"), 0), + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_best_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_result_detail( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_best_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_download_plan( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + choice = self._safe_int(body.get("choice") or body.get("index"), 0) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + ))) + return await finish(self._assistant_mp_download( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_download_tasks": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_tasks( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "downloading"), + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_mp_download_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_lifecycle_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_ingest_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_downloaders": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_downloaders(session=session_name, cache_key=cache_key)) + if name == "query_mp_sites": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_sites( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "active"), + name=self._clean_text(body.get("site_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 30), + )) + if name == "query_mp_subscribes": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_subscribes( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or "all"), + name=self._clean_text(body.get("subscribe_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 20), + )) + if name == "query_mp_ingest_failures": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_failures( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_ai_failed_samples": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_failed_samples( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_worklist": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_worklist( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_insights": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_insights( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if name == "replay_ai_failed_sample": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(body.get("remove_if_resolved"), True) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + ))) + return await finish(self._assistant_ai_replay_failed_sample( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if name == "query_mp_transfer_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_transfer_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + status=self._clean_text(body.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_recent_activity": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only"), False), + )) + if name == "query_mp_local_diagnose": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_local_diagnose( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_execution_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id")), + )) + if name == "query_smart_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_smart_followup( + request, + session=session_name, + session_id=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "mp_subscribe_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return await finish(self._assistant_mp_subscribe_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if name == "mp_download_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + downloader = self._clean_text(body.get("downloader")) + delete_files = self._parse_bool_value(body.get("delete_files"), False) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + downloader=downloader, + delete_files=delete_files, + ))) + return await finish(self._assistant_mp_download_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + downloader=downloader, + delete_files=delete_files, + )) + if name in {"start_mp_subscribe", "start_mp_subscribe_search"}: + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if not keyword: + state = self._load_session(cache_key) or {} + keyword = self._clean_text(state.get("keyword")) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session_name, + cache_key=cache_key, + immediate_search=name == "start_mp_subscribe_search", + ))) + return await finish(self._assistant_mp_subscribe( + keyword=keyword, + session=session_name, + immediate_search=name == "start_mp_subscribe_search", + )) + if name == "start_mp_recommendations": + recommend_session, recommend_cache_key = self._normalize_assistant_session_ref( + session=self._clean_text(body.get("session")) or "default", + session_id=body.get("session_id"), + ) + source_name, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or "tmdb_trending" + ) + return await finish(self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type) or "all", + limit=self._safe_int(body.get("limit"), 20), + session=recommend_session, + cache_key=recommend_cache_key, + )) + if name == "pick_recommend_search": + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "mode": body.get("mode") or body.get("search_mode") or "mp", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"preferences_get", "preferences_save", "preferences_reset"}: + method = "GET" if name == "preferences_get" else "DELETE" if name == "preferences_reset" else "POST" + payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "user_key": body.get("user_key"), + "preferences": body.get("preferences") or {}, + "compact": body.get("compact", True), + "apikey": self._extract_apikey(request, body), + } + return await finish(self.api_assistant_preferences(_JsonRequestShim(request, payload, method=method))) + if name in {"scoring_policy", "query_scoring_policy"}: + session_name = self._clean_text(body.get("session")) or "default" + return await finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if name == "start_115_login": + route_payload.update({ + "action": "p115_qrcode_start", + "client_type": body.get("client_type"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "route_share": + route_payload.update({ + "url": body.get("url") or body.get("share_url"), + "access_code": body.get("access_code"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "unlock_hdhive_resource": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + resource = body.get("resource") if isinstance(body.get("resource"), dict) else {} + slug = self._clean_text(body.get("slug") or resource.get("slug")) + final_path = self._resolve_pan_path_value( + self._clean_text(body.get("path") or body.get("target_path")) + ) or self._hdhive_default_path + if not slug: + return await finish(immediate({ + "success": False, + "message": "影巢解锁动作缺少 slug", + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "error_code": "missing_slug", + }), + })) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_plan", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return await finish(immediate({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "selected_resource": resource, + "result": route_result, + }), + })) + return await finish(immediate({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + })) + if name == "inspect_session_state": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"execute_latest_plan", "execute_plan", "execute_session_latest_plan"}: + return await finish(self.api_assistant_plan_execute(_JsonRequestShim(request, { + "plan_id": body.get("plan_id"), + "session": body.get("session"), + "session_id": body.get("session_id"), + "prefer_unexecuted": body.get("prefer_unexecuted", True), + "stop_on_error": body.get("stop_on_error", True), + "include_raw_results": body.get("include_raw_results", False), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"pick_pansou_result", "pick_hdhive_candidate", "pick_hdhive_resource"}: + pick_payload.update({"choice": body.get("choice") or body.get("index")}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"plan_pansou_result", "plan_hdhive_resource", "plan_pick_result"}: + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "action": "plan", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_detail": + pick_payload.update({"action": "detail"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_next_page": + pick_payload.update({"action": "next_page"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "check_115_login": + route_payload.update({"action": "p115_qrcode_check"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "show_115_status": + route_payload.update({"action": "p115_status"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "resume_pending_115": + route_payload.update({"action": "p115_resume"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "cancel_pending_115": + route_payload.update({"action": "p115_cancel"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "clear_current_session": + return await finish(self.api_assistant_session_clear(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "inspect_session": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_session_by_id": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_stale_sessions": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "stale_only": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_executed_plans": + return await finish(self.api_assistant_plans_clear(_JsonRequestShim(request, { + "executed": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + + return {"success": False, "message": f"不支持的动作模板:{name}"} + + @staticmethod + def _assistant_result_message_head(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text.splitlines()[0][:200] + + def _assistant_action_result_summary( + self, + *, + index: int, + name: str, + result: Dict[str, Any], + ) -> Dict[str, Any]: + data = dict((result or {}).get("data") or {}) + session_state = dict(data.get("session_state") or {}) + if not session_state and ( + "has_session" in data + or "kind" in data + or "stage" in data + or "suggested_actions" in data + ): + session_state = dict(data) + summary = { + "index": index, + "name": self._clean_text(name), + "success": bool((result or {}).get("success")), + "action": self._clean_text(data.get("action")) or self._clean_text(name), + "ok": bool(data.get("ok")) if "ok" in data else bool((result or {}).get("success")), + "message_head": self._assistant_result_message_head((result or {}).get("message")), + "session": self._clean_text(data.get("session") or session_state.get("session")), + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "has_pending_p115": bool(((session_state.get("pending_p115") or {}).get("has_pending"))), + } + if isinstance(data.get("score_summary"), dict): + summary["score_summary"] = data.get("score_summary") + if isinstance(data.get("diagnosis_summary"), dict): + summary["diagnosis_summary"] = data.get("diagnosis_summary") + return summary + + async def api_assistant_actions(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + actions = body.get("actions") or [] + if not isinstance(actions, list) or not actions: + return {"success": False, "message": "缺少 actions 数组"} + + apikey = self._extract_apikey(request, body) + requested_count = min(len(actions), 20) + stop_on_error = self._parse_bool_value(body.get("stop_on_error"), True) + include_raw_results = self._parse_bool_value(body.get("include_raw_results"), False) + compact = self._parse_bool_value(body.get("compact"), False) + batch_session = self._clean_text(body.get("session")) or "default" + batch_session_id = self._clean_text(body.get("session_id")) + + summaries: List[Dict[str, Any]] = [] + raw_results: List[Dict[str, Any]] = [] + halted = False + halted_at = 0 + + for idx, item in enumerate(actions[:requested_count], 1): + payload = dict(item or {}) if isinstance(item, dict) else {"name": self._clean_text(item)} + if not payload.get("session") and batch_session: + payload["session"] = batch_session + if not payload.get("session_id") and batch_session_id: + payload["session_id"] = batch_session_id + if "execute" not in payload and "execute" in body: + payload["execute"] = body.get("execute") + if apikey and not payload.get("apikey") and not payload.get("api_key"): + payload["apikey"] = apikey + action_name = self._clean_text(payload.get("name") or payload.get("action_name")) + result = await self.api_assistant_action(_JsonRequestShim(request, payload)) + summaries.append(self._assistant_action_result_summary(index=idx, name=action_name, result=result)) + if include_raw_results: + raw_results.append(result) + if not result.get("success") and stop_on_error: + halted = True + halted_at = idx + break + + final_session = batch_session + final_session_id = batch_session_id + if summaries: + final_session = self._clean_text(summaries[-1].get("session")) or final_session + final_session_id = self._clean_text(summaries[-1].get("session_id")) or final_session_id + session_name, _ = self._normalize_assistant_session_ref(session=final_session, session_id=final_session_id) + + success = bool(summaries) and all(item.get("success") for item in summaries) + if halted: + success = False + + message_lines = [ + f"批量动作执行完成:{len(summaries)}/{requested_count} 步", + f"成功:{len([item for item in summaries if item.get('success')])} 步", + ] + if halted: + message_lines.append(f"已在第 {halted_at} 步停止") + else: + message_lines.append("已按顺序执行完毕") + if summaries: + last_head = self._clean_text(summaries[-1].get("message_head")) + if last_head: + message_lines.append(f"最后结果:{last_head}") + + data = { + "action": "execute_actions", + "ok": success, + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + } + if include_raw_results: + data["raw_results"] = raw_results + self._record_assistant_execution( + action=self._clean_text(body.get("workflow")) or "execute_actions", + session=session_name, + success=success, + message="\n".join(message_lines), + summary={ + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + }, + ) + full_data = self._assistant_response_data(session=session_name, data=data) + return { + "success": success, + "message": "\n".join(message_lines), + "data": self._assistant_actions_compact_data(full_data) if compact else full_data, + } + + @staticmethod + def _assistant_workflow_catalog() -> Dict[str, Any]: + return { + "workflows": [ + { + "name": "pansou_search", + "description": "按关键词执行盘搜,只返回候选结果并保留会话", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "smart_resource_search", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只读返回推荐结果", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_decision", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并返回查看详情、生成计划或直接执行的统一建议。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "decision_profile", "path", "compact"], + }, + { + "name": "smart_resource_plan", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并为当前首选生成待确认 plan_id", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_execute", + "description": "按偏好自动搜索当前首选,并立即执行对应写入动作;仅适用于用户已明确要求直接执行的场景。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "pansou_transfer", + "description": "按关键词盘搜并直接选择指定编号转存,choice 默认 1", + "fields": ["session", "keyword", "choice", "path", "compact"], + }, + { + "name": "hdhive_candidates", + "description": "按关键词搜索影巢候选影片,等待下一步选片", + "fields": ["session", "keyword", "media_type", "year", "path", "compact"], + }, + { + "name": "hdhive_unlock", + "description": "按关键词搜索影巢,选择候选影片,再选择资源解锁落盘", + "fields": ["session", "keyword", "candidate_choice", "resource_choice", "media_type", "year", "path", "compact"], + }, + { + "name": "mp_search", + "description": "执行 MP 原生搜索,返回 PT 候选结果和评分摘要", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_media_detail", + "description": "使用 MoviePilot 原生识别确认媒体详情、年份、类型和 TMDB/Douban/IMDB ID", + "fields": ["session", "keyword", "media_type", "year", "compact"], + }, + { + "name": "mp_search_detail", + "description": "执行 MP 原生搜索并按编号查看 PT 详情与评分理由,只读", + "fields": ["session", "keyword", "choice", "compact"], + }, + { + "name": "mp_search_best", + "description": "执行 MP 原生搜索并查看当前评分最高的 PT 候选详情,只读", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_search_download", + "description": "执行 MP 原生搜索并按编号下载,默认先生成 plan_id", + "fields": ["session", "keyword", "choice", "compact", "dry_run"], + }, + { + "name": "mp_download_tasks", + "description": "查询 MP 下载任务状态,可按 status/title/hash/downloader 过滤", + "fields": ["session", "status", "title", "hash", "downloader", "limit", "compact"], + }, + { + "name": "mp_download_history", + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态,只读", + "fields": ["session", "keyword", "hash", "limit", "page", "compact"], + }, + { + "name": "mp_lifecycle_status", + "description": "聚合查询 MP 下载任务、下载历史和整理/入库历史,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_ingest_status", + "description": "按片名或 hash 判断当前处于下载中、已下载、整理中、已入库还是失败阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_downloaders", + "description": "查询 MP 下载器配置摘要,不返回敏感字段", + "fields": ["session", "compact"], + }, + { + "name": "mp_sites", + "description": "查询 MP 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文", + "fields": ["session", "status", "keyword", "limit", "compact"], + }, + { + "name": "mp_download_control", + "description": "暂停、恢复或删除 MP 下载任务,默认先生成 plan_id", + "fields": ["session", "control", "target", "downloader", "delete_files", "compact", "dry_run"], + }, + { + "name": "mp_subscribe", + "description": "按关键词创建 MP 订阅,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribe_and_search", + "description": "创建订阅并立即触发搜索,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribes", + "description": "查询 MP 订阅列表,可按 status/media_type/keyword 过滤", + "fields": ["session", "status", "media_type", "keyword", "limit", "compact"], + }, + { + "name": "mp_subscribe_control", + "description": "触发订阅搜索、暂停、恢复或删除订阅,默认先生成 plan_id", + "fields": ["session", "control", "target", "compact", "dry_run"], + }, + { + "name": "mp_transfer_history", + "description": "查询 MP 最近整理/入库历史,可按标题和成功/失败状态过滤,只读", + "fields": ["session", "keyword", "status", "limit", "page", "compact"], + }, + { + "name": "mp_ingest_failures", + "description": "聚合查看最近整理/入库失败记录,只读", + "fields": ["session", "keyword", "limit", "page", "compact"], + }, + { + "name": "ai_failed_samples", + "description": "读取 AI 识别增强插件保存的失败样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_worklist", + "description": "读取 AI 失败样本工作清单,适合先挑需要复查的样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_insights", + "description": "读取 AI 失败样本洞察,查看主要失败原因与优先处理项,只读", + "fields": ["session", "keyword", "limit", "top", "compact"], + }, + { + "name": "ai_replay_failed_sample", + "description": "对指定 AI 失败样本执行二次识别重放;默认只生成 plan_id,确认后执行", + "fields": ["session", "sample_index", "remove_if_resolved", "compact", "confirmed"], + }, + { + "name": "mp_recent_activity", + "description": "查看最近下载和最近整理/入库活动,只读", + "fields": ["session", "limit", "download_only", "transfer_only", "compact"], + }, + { + "name": "mp_local_diagnose", + "description": "一站式诊断为什么还没入库或当前卡在什么阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "execution_followup", + "description": "按最近已执行计划自动查询对应的下载、订阅或入库后续状态,只读", + "fields": ["session", "session_id", "plan_id", "compact"], + }, + { + "name": "smart_followup", + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动,只读", + "fields": ["session", "session_id", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_recommend", + "description": "读取 MP 原生推荐,例如 TMDB、豆瓣、Bangumi", + "fields": ["session", "source", "media_type", "limit", "compact"], + }, + { + "name": "mp_recommend_search", + "description": "读取 MP 推荐并按编号继续搜索;mode 可选 mp / hdhive / pansou,传 keyword 时直接 MP 搜索", + "fields": ["session", "source", "keyword", "choice", "mode", "media_type", "limit", "compact"], + }, + { + "name": "share_transfer", + "description": "识别 115 或夸克分享链接并直接转存", + "fields": ["session", "url", "access_code", "path", "compact"], + }, + { + "name": "p115_login_start", + "description": "发起 115 扫码登录", + "fields": ["session", "client_type", "compact"], + }, + { + "name": "p115_status", + "description": "查看 115 当前可用状态", + "fields": ["session", "compact"], + }, + ] + } + + def _assistant_workflow_actions(self, name: str, body: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]: + workflow_name = self._clean_text(name).lower() + session = self._clean_text(body.get("session")) or "default" + session_id = self._clean_text(body.get("session_id")) + path = self._clean_text(body.get("path") or body.get("target_path")) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or "auto") + year = self._clean_text(body.get("year")) + source = self._clean_text(body.get("source")) or "tmdb_trending" + limit = self._safe_int(body.get("limit"), 20) + + def base(payload: Dict[str, Any]) -> Dict[str, Any]: + current = dict(payload) + current.setdefault("session", session) + if session_id: + current.setdefault("session_id", session_id) + if path and "path" not in current: + current["path"] = path + return current + + if workflow_name == "pansou_search": + if not keyword: + return [], "pansou_search 缺少 keyword" + return [base({"name": "start_pansou_search", "keyword": keyword})], "" + + if workflow_name == "smart_resource_search": + if not keyword: + return [], "smart_resource_search 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_decision": + if not keyword: + return [], "smart_resource_decision 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_decision", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + "decision_profile": body.get("decision_profile"), + })], "" + + if workflow_name == "smart_resource_plan": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_plan", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_execute": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_execute", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "pansou_transfer": + if not keyword: + return [], "pansou_transfer 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_pansou_search", "keyword": keyword}), + base({"name": "pick_pansou_result", "choice": max(1, choice)}), + ], "" + + if workflow_name == "hdhive_candidates": + if not keyword: + return [], "hdhive_candidates 缺少 keyword" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }) + ], "" + + if workflow_name == "hdhive_unlock": + if not keyword: + return [], "hdhive_unlock 缺少 keyword" + candidate_choice = self._safe_int(body.get("candidate_choice") or body.get("choice"), 0) + resource_choice = self._safe_int(body.get("resource_choice"), 0) + if candidate_choice <= 0 or resource_choice <= 0: + return [], "hdhive_unlock 需要 candidate_choice 和 resource_choice" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }), + base({"name": "pick_hdhive_candidate", "choice": candidate_choice}), + base({"name": "pick_hdhive_resource", "choice": resource_choice}), + ], "" + + if workflow_name == "mp_search": + if not keyword: + return [], "mp_search 缺少 keyword" + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + + if workflow_name == "mp_media_detail": + if not keyword: + return [], "mp_media_detail 缺少 keyword" + return [base({ + "name": "query_mp_media_detail", + "keyword": keyword, + "media_type": media_type, + "year": year, + })], "" + + if workflow_name == "mp_search_detail": + if not keyword: + return [], "mp_search_detail 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_search_result_detail", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_search_best": + if not keyword: + return [], "mp_search_best 缺少 keyword" + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_best_result_detail"}), + ], "" + + if workflow_name == "mp_search_download": + if not keyword: + return [], "mp_search_download 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "pick_mp_download", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_download_tasks": + return [base({ + "name": "query_mp_download_tasks", + "status": self._clean_text(body.get("status")) or "downloading", + "title": self._clean_text(body.get("title") or body.get("keyword")), + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "downloader": self._clean_text(body.get("downloader")), + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "mp_download_history": + return [base({ + "name": "query_mp_download_history", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_lifecycle_status": + return [base({ + "name": "query_mp_lifecycle_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_ingest_status": + return [base({ + "name": "query_mp_ingest_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_downloaders": + return [base({"name": "query_mp_downloaders"})], "" + + if workflow_name == "mp_sites": + return [base({ + "name": "query_mp_sites", + "status": self._clean_text(body.get("status")) or "active", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 30), + })], "" + + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_download_control 需要 control 和 target" + return [base({ + "name": "mp_download_control", + "control": control, + "target": target, + "downloader": self._clean_text(body.get("downloader")), + "delete_files": self._parse_bool_value(body.get("delete_files"), False), + })], "" + + if workflow_name == "mp_subscribe": + if not keyword: + return [], "mp_subscribe 缺少 keyword" + return [base({"name": "start_mp_subscribe", "keyword": keyword})], "" + + if workflow_name == "mp_subscribe_and_search": + if not keyword: + return [], "mp_subscribe_and_search 缺少 keyword" + return [base({"name": "start_mp_subscribe_search", "keyword": keyword})], "" + + if workflow_name == "mp_subscribes": + return [base({ + "name": "query_mp_subscribes", + "status": self._clean_text(body.get("status")) or "all", + "media_type": self._clean_text(body.get("media_type") or body.get("type")) or "all", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + })], "" + + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_subscribe_control 需要 control 和 target" + return [base({ + "name": "mp_subscribe_control", + "control": control, + "target": target, + })], "" + + if workflow_name == "mp_transfer_history": + return [base({ + "name": "query_mp_transfer_history", + "keyword": keyword, + "status": self._clean_text(body.get("status")) or "all", + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_ingest_failures": + return [base({ + "name": "query_mp_ingest_failures", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "ai_failed_samples": + return [base({ + "name": "query_ai_failed_samples", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_worklist": + return [base({ + "name": "query_ai_sample_worklist", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_insights": + return [base({ + "name": "query_ai_sample_insights", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + "top": self._safe_int(body.get("top"), 5), + })], "" + + if workflow_name == "ai_replay_failed_sample": + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index <= 0: + return [], "ai_replay_failed_sample 需要 sample_index" + return [base({ + "name": "replay_ai_failed_sample", + "sample_index": sample_index, + "remove_if_resolved": self._parse_bool_value(body.get("remove_if_resolved"), True), + })], "" + + if workflow_name == "mp_recent_activity": + return [base({ + "name": "query_mp_recent_activity", + "limit": self._safe_int(body.get("limit"), 10), + "download_only": self._parse_bool_value(body.get("download_only"), False), + "transfer_only": self._parse_bool_value(body.get("transfer_only"), False), + })], "" + + if workflow_name == "mp_local_diagnose": + return [base({ + "name": "query_mp_local_diagnose", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "execution_followup": + return [base({ + "name": "query_execution_followup", + "plan_id": self._clean_text(body.get("plan_id")), + })], "" + + if workflow_name == "smart_followup": + return [base({ + "name": "query_smart_followup", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_recommend": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "smart_discovery": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "mp_recommend_search": + if keyword: + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + actions = [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ] + choice = self._safe_int(body.get("choice"), 0) + if choice > 0: + actions.append(base({ + "name": "pick_recommend_search", + "choice": choice, + "mode": self._clean_text(body.get("mode")) or "mp", + })) + return actions, "" + + if workflow_name == "share_transfer": + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if not share_url: + return [], "share_transfer 缺少 url" + return [ + base({ + "name": "route_share", + "url": share_url, + "access_code": self._clean_text(body.get("access_code")), + }) + ], "" + + if workflow_name == "p115_login_start": + return [ + base({ + "name": "start_115_login", + "client_type": self._clean_text(body.get("client_type")), + }) + ], "" + + if workflow_name == "p115_status": + return [base({"name": "show_115_status"})], "" + + return [], f"不支持的工作流:{name}" + + async def api_assistant_workflow(self, request: Request): + if request.method.upper() == "GET": + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + return { + "success": True, + "message": "Agent影视助手 预设工作流目录", + "data": self._assistant_response_data(session="default", data={ + "action": "workflow_catalog", + "ok": True, + **self._assistant_workflow_catalog(), + }), + } + + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + workflow_name = self._clean_text(body.get("name") or body.get("workflow")) + compact = self._parse_bool_value(body.get("compact"), False) + if not workflow_name: + return {"success": False, "message": "缺少工作流名 name"} + actions, build_error = self._assistant_workflow_actions(workflow_name, body) + if build_error: + return {"success": False, "message": build_error} + + session = self._clean_text(body.get("session")) or "default" + write_workflows = { + "pansou_transfer", + "hdhive_unlock", + "share_transfer", + "ai_replay_failed_sample", + "mp_search_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_and_search", + } + dry_run = self._parse_bool_value( + body.get("dry_run"), + self._clean_text(workflow_name).lower() in write_workflows, + ) + if dry_run: + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + result = self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ) + if not result.get("success"): + return result + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_id = self._parse_bool_value(body.get("allow_raw_id"), False) + result = self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + allow_raw_id=allow_raw_id, + ) + if not result.get("success"): + return result + if workflow_name == "ai_replay_failed_sample": + result = self._assistant_ai_replay_sample_plan_response( + sample_index=self._safe_int(body.get("sample_index") or body.get("index"), 0), + session=session, + cache_key=self._clean_text(body.get("session_id")), + remove_if_resolved=self._parse_bool_value(body.get("remove_if_resolved"), True), + ) + if not result.get("success"): + return result + return self._assistant_workflow_plan_response_compact(result) if compact else result + execute_body = { + **{key: value for key, value in body.items() if key not in {"apikey", "dry_run"}}, + "dry_run": False, + } + plan = self._save_workflow_plan( + workflow=workflow_name, + session=session, + session_id=self._clean_text(body.get("session_id")), + actions=actions, + execute_body=execute_body, + ) + full_data = self._assistant_response_data(session=session, data={ + "action": "workflow_plan", + "ok": True, + "plan_id": plan.get("plan_id"), + "workflow": workflow_name, + "dry_run": True, + "workflow_actions": actions, + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan.get("plan_id")}, + "execute_body": execute_body, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + }) + return { + "success": True, + "message": f"工作流 {workflow_name} 计划已生成:{plan.get('plan_id')},共 {len(actions)} 步,未实际执行。", + "data": self._assistant_workflow_plan_compact_data(full_data) if compact else full_data, + } + + result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": self._clean_text(body.get("session_id")), + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + data = dict(result.get("data") or {}) + data["workflow"] = workflow_name + if not compact: + data["workflow_actions"] = actions + return { + "success": bool(result.get("success")), + "message": f"工作流 {workflow_name} 执行完成\n{result.get('message') or ''}".strip(), + "data": data, + } + + async def api_assistant_plan_execute(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + plan_id = self._clean_text(body.get("plan_id")) + session = self._clean_text(body.get("session")) + session_id = self._clean_text(body.get("session_id")) + prefer_unexecuted = self._parse_bool_value(body.get("prefer_unexecuted"), True) + compact = self._parse_bool_value(body.get("compact"), False) + plan = self._find_workflow_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=False if prefer_unexecuted and not plan_id else None, + ) + if not plan and not plan_id and (session or session_id) and prefer_unexecuted: + plan = self._find_workflow_plan( + session=session, + session_id=session_id, + executed=None, + ) + if not plan: + result = { + "success": False, + "message": f"计划不存在或已过期:{plan_id}" if plan_id else "没有匹配到可执行计划,请先生成 dry_run 计划或改传 plan_id。", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_not_found", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + plan_id = self._clean_text(plan.get("plan_id")) + + actions = plan.get("actions") or [] + if not isinstance(actions, list) or not actions: + result = { + "success": False, + "message": f"计划没有可执行动作:{plan_id}", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_has_no_actions", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + workflow_name = self._clean_text(plan.get("workflow")) or "saved_plan" + session = self._clean_text(plan.get("session")) or "default" + session_id = self._clean_text(plan.get("session_id")) + action_result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": session_id, + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": False, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + + executed_at = int(time.time()) + plan.update({ + "executed": True, + "executed_at": executed_at, + "executed_at_text": self._format_unix_time(executed_at), + "last_success": bool(action_result.get("success")), + "last_message": self._assistant_result_message_head(action_result.get("message")), + }) + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + + data = dict(action_result.get("data") or {}) + data.update({ + "action": "execute_plan", + "plan_id": plan_id, + "workflow": workflow_name, + "plan_auto_selected": not bool(self._clean_text(body.get("plan_id"))), + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "plan_executed_at": executed_at, + "plan_executed_at_text": plan.get("executed_at_text"), + }) + followup_state = dict(data.get("session_state") or {}) if isinstance(data.get("session_state"), dict) else {} + if isinstance(plan.get("execute_body"), dict): + followup_state["plan_execute_body"] = dict(plan.get("execute_body") or {}) + followup = self._assistant_plan_execute_followup( + workflow=workflow_name, + session=session, + session_id=session_id, + session_state=followup_state, + ok=bool(action_result.get("success")), + plan_id=plan_id, + ) + data["recommended_action"] = self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")) + data["follow_up_hint"] = self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")) + data["followup_summary"] = followup.get("followup_summary") or {} + data["next_actions"] = self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or [], + ) + data["action_templates"] = self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ) + if not compact: + data["workflow_actions"] = actions + message_lines = [ + f"计划 {plan_id} 执行完成", + self._clean_text(action_result.get("message")), + ] + if data.get("recommended_action"): + message_lines.append(f"推荐动作:{data.get('recommended_action')}") + if data.get("follow_up_hint"): + message_lines.append(f"下一步:{data.get('follow_up_hint')}") + message_lines.extend(self._format_followup_summary_lines(data.get("followup_summary"))) + result = { + "success": bool(action_result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": data, + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + async def api_assistant_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + raw_action_text = self._clean_text(body.get("action") or body.get("pick_action")) + action = self._normalize_pick_action(raw_action_text) + target_path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + compact = self._parse_bool_value(body.get("compact"), False) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + state = self._load_session(cache_key) + if not state: + return {"success": False, "message": "没有可继续的缓存,请先发起搜索或发送分享链接。"} + if index <= 0 and not action: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + + kind = str(state.get("kind") or "").strip() + smart_short_action = self._normalize_smart_search_short_action(raw_action_text, state_kind=kind) + if smart_short_action: + action = smart_short_action + if kind == "assistant_cloud": + pansou_items = state.get("pansou_items") or [] + hdhive_resources = state.get("hdhive_resources") or [] + hdhive_candidate = dict(state.get("hdhive_candidate") or {}) + hdhive_candidates = state.get("hdhive_candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + pansou_count = len(pansou_items) + total_count = pansou_count + len(hdhive_resources) + if action == "next_page": + total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_cloud_text( + keyword=self._clean_text(state.get("keyword")), + pansou_items=pansou_items, + pansou_total=self._safe_int(state.get("pansou_total"), pansou_count), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best" and pansou_items: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + "total": self._safe_int(state.get("pansou_total"), len(pansou_items)), + "page": max(1, self._safe_int(state.get("page"), 1)), + "page_size": page_size, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + if index <= 0: + if action: + return {"success": False, "message": "云盘搜索结果需要编号,例如:选择 1 或 选择 12 详情。"} + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > total_count: + return {"success": False, "message": f"序号超出范围,请输入 1 到 {total_count} 之间的数字。"} + if index <= pansou_count: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + local_index = index - pansou_count + delegate_state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "selected_candidate": hdhive_candidate, + "resources": [ + {**dict(item or {}), "index": idx} + for idx, item in enumerate(hdhive_resources, start=1) + ], + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": local_index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + + if kind == "assistant_update_check": + items = [dict(item or {}) for item in (state.get("items") or []) if isinstance(item, dict)] + if action == "next_page": + return {"success": False, "message": "更新检查结果当前不分页,可以直接回复编号或“选择 编号 详情”。"} + if action in {"best", "best_execute", "best_plan"}: + best = self._best_scored_source_item(items) + if not best: + return {"success": False, "message": "当前更新检查结果没有可评分条目,请直接回复编号。"} + index = self._safe_int(best.get("_index") or best.get("index"), 0) + action = "plan" if action == "best_plan" else "" if action == "best_execute" else "detail" + if index <= 0: + return {"success": False, "message": "更新检查结果需要编号,例如:选择 25 详情。"} + matched_items = [ + item for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) == index + ] + if not matched_items and 1 <= index <= len(items): + matched_items = [items[index - 1]] + if not matched_items: + available = "、".join( + f"#{self._safe_int(item.get('_index') or item.get('index'), 0)}" + for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) > 0 + ) + return { + "success": False, + "message": f"序号不在当前更新检查结果里。可选编号:{available or '暂无'}", + } + selected = dict(matched_items[0]) + source_type = self._clean_text(selected.get("_update_source")).lower() + choice_index = self._safe_int(selected.get("_index") or selected.get("index"), index) + selected["index"] = choice_index + selected["_index"] = choice_index + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if action == "detail": + title = "盘搜资源详情" if source_type == "pansou" else "影巢资源详情" + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title=title), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_resource_detail", + "ok": True, + "choice": choice_index, + "source_type": source_type, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + }) + if source_type == "pansou": + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法继续处理;可先发“选择 编号 详情”查看。"} + access_code = self._clean_text(selected.get("password")) + route_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_pansou_transfer", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": route_path, + }], + execute_body={ + "workflow": "update_check_pansou_transfer", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": route_path, + }, + message="更新检查盘搜资源转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": route_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "target_path": route_path, + "apikey": self._extract_apikey(request, body), + }) + ) + return finish(route_result) + if source_type == "hdhive": + slug = self._clean_text(selected.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法继续处理;可先发“选择 编号 详情”查看。"} + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_hdhive_unlock", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": selected, + }], + execute_body={ + "workflow": "update_check_hdhive_unlock", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": final_path, + }, + message="更新检查影巢资源解锁/转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": final_path, + "selected_resource": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=selected, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_update_check_hdhive", + title=selected.get("title") or selected.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(selected.get('title') or selected.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_hdhive_unlock", + "ok": True, + "selected_resource": selected, + "result": route_result, + }), + }) + return {"success": False, "message": "当前更新检查条目缺少来源信息,请重新执行更新检查。"} + if kind == "assistant_pansou": + items = state.get("items") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total_items = len(items) + if action == "next_page": + total_pages = max(1, (total_items + page_size - 1) // page_size) if total_items else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_pansou_text( + self._clean_text(state.get("keyword")), + items, + self._safe_int(state.get("total"), total_items), + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法执行最佳。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + plan_result = self._save_assistant_pick_plan_response( + workflow="pansou_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }], + execute_body={ + "workflow": "pansou_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择盘搜最佳候选并执行:#{index}", + extra_data={"choice": index, "source_type": "pansou"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + result = self._save_assistant_pick_plan_response( + workflow="pansou_best_plan", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail": + if index <= 0: + return {"success": False, "message": "盘搜详情需要编号,例如:选择 1 详情。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "盘搜计划需要编号,例如:计划选择 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="pansou_transfer_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_transfer_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜转存计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "盘搜结果当前只支持:选择 编号、计划选择 编号、选择 编号 详情、最佳片源、计划最佳、执行最佳。"} + if index <= 0: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "path": final_path, + "trigger": "Agent影视助手 智能入口盘搜选择", + "apikey": self._extract_apikey(request, body), + }) + ) + if not route_result.get("success"): + if self._is_115_url(share_url): + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=access_code, + target_path=final_path, + source="assistant_pansou_pick", + title=selected.get("note") or "", + last_error=str(route_result.get("message") or ""), + ) + return finish({ + "success": False, + "message": ( + f"{str(route_result.get('message') or '转存失败')}\n" + f"{self._format_p115_resume_hint(selected.get('note') or '')}" + ), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_quark_url(share_url) and self._is_quark_share_restricted_message( + str(route_result.get("message") or "") + ): + recovered = await self._assistant_retry_pansou_quark_candidates( + request, + items=items, + selected_index=index, + selected_url=share_url, + session=session, + target_path=final_path, + apikey=self._extract_apikey(request, body), + ) + if recovered: + return finish(recovered) + return finish({ + "success": False, + "message": str(route_result.get('message') or '转存失败'), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_115_url(share_url): + self._clear_pending_p115_share(cache_key) + provider = ((route_result.get("data") or {}).get("provider") or "").lower() + result_payload = (route_result.get("data") or {}).get("result") or {} + directory = (result_payload.get("result") or {}).get("target_path") or (result_payload.get("result") or {}).get("path") or final_path + text_message = "\n".join([ + "盘搜结果已执行转存", + f"资源:{selected.get('note') or '未命名资源'}", + f"类型:{provider or selected.get('channel') or '-'}", + f"目录:{directory or '-'}", + ]) + return finish({ + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={"action": "share_route", "ok": True}), + }) + + if kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + pick_action = self._normalize_pick_action(body.get("action")) + next_mode = self._clean_text( + body.get("mode") + or body.get("search_mode") + or body.get("target") + or "" + ).lower() + if index <= 0: + index = selected_index + if index <= 0 and ( + pick_action in {"detail", "best", "plan", "best_execute"} + or next_mode + ): + index = 1 + if index <= 0: + return {"success": False, "message": "推荐结果需要先指定编号,例如:选择 1 决策,或先发:详情 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + title = self._clean_text(selected.get("title")) + if not title: + return {"success": False, "message": "选中的推荐条目缺少标题,无法继续搜索。"} + if pick_action == "detail": + self._save_session(cache_key, { + **state, + "selected_index": index, + "selected_item": selected, + }) + return finish({ + "success": True, + "message": self._format_mp_recommend_item_detail_text(selected), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendation_detail", + "ok": True, + "choice": index, + "selected_index": index, + "item": selected, + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + }), + }) + mode_aliases = { + "原生": "mp", + "mp搜索": "mp", + "影巢": "hdhive", + "yc": "hdhive", + "盘搜": "pansou", + "ps": "pansou", + "计划": "smart_plan", + "smart_plan": "smart_plan", + "确认": "smart_execute", + "执行": "smart_execute", + "直接执行": "smart_execute", + "smart_execute": "smart_execute", + "决策": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart": "smart_decision", + "smart_decision": "smart_decision", + } + if not next_mode: + if pick_action == "plan": + next_mode = "smart_plan" + elif pick_action == "best_execute": + next_mode = "smart_execute" + elif pick_action in {"best", "detail"}: + next_mode = "smart_decision" + next_mode = mode_aliases.get(next_mode, next_mode) + if not next_mode: + next_mode = "mp" + if next_mode not in {"mp", "hdhive", "pansou", "smart_decision", "smart_plan", "smart_execute"}: + return {"success": False, "message": "推荐选择只支持 mode=smart_decision、mode=smart_plan、mode=smart_execute、mode=mp、mode=hdhive 或 mode=pansou。"} + selected_media_type = self._clean_text(selected.get("type") or state.get("media_type") or "auto").lower() + media_type_aliases = { + "电影": "movie", + "movie": "movie", + "movies": "movie", + "电视剧": "tv", + "剧集": "tv", + "番剧": "tv", + "tv": "tv", + "series": "tv", + "all": "auto", + "全部": "auto", + } + selected_media_type = media_type_aliases.get(selected_media_type, "auto") + next_keyword = title + if next_mode == "smart_decision" and pick_action in {"best", "detail"}: + next_keyword = f"{title} 详情" + routed_body = { + "session": session, + "session_id": cache_key, + "mode": next_mode, + "keyword": next_keyword, + "media_type": selected_media_type, + "path": target_path, + "apikey": self._extract_apikey(request, body), + } + if next_mode in {"smart_decision", "smart_plan", "smart_execute"}: + routed_body["origin"] = "mp_recommend" + else: + routed_body["recommend_handoff"] = self._assistant_recommend_handoff_state( + source=self._clean_text(state.get("source")), + requested_source=self._clean_text(state.get("requested_source") or state.get("source")), + media_type=selected_media_type, + selected_index=index, + selected_item=selected, + ) + return finish(await self.api_assistant_route( + _JsonRequestShim(request, routed_body) + )) + + if kind == "assistant_smart_search": + if action in { + "decision_continue", + "decision_hdhive", + "decision_pansou", + "decision_mp_pt", + "decision_conservative", + "decision_aggressive", + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=action, + )) + if action == "best_execute": + return finish(await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best_plan": + return finish(self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best": + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + active_source = self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + try: + result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + finally: + self._save_session(cache_key, state) + return finish(result) + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + requested_mode = self._clean_text(body.get("mode") or body.get("search_mode")).lower() + mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp_pt", + "mp": "mp_pt", + "pt": "mp_pt", + } + requested_mode = mode_aliases.get(requested_mode, requested_mode) + active_source = requested_mode or self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + if kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action in {"best", "best_execute", "best_plan", "plan"}: + return {"success": False, "message": "MP 候选阶段还没有 PT 资源评分,请先回复编号选定电影/剧集。"} + if action: + return {"success": False, "message": "MP 候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效候选编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1] or {}) + candidate_title = self._clean_text(candidate.get("title")) or self._clean_text(state.get("keyword")) + candidate_year = self._clean_text(candidate.get("year")) + search_keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + pending_action = dict(state.get("pending_action") or {}) if isinstance(state.get("pending_action"), dict) else {} + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if pending_mode == "cloud_transfer_execute": + cloud_provider = self._clean_text(pending_action.get("cloud_provider")).lower() + overrides: Dict[str, Any] = {} + if cloud_provider == "quark": + overrides = {"has_quark": True, "has_115": False, "prefer_cloud_provider": "quark"} + elif cloud_provider == "115": + overrides = {"has_quark": False, "has_115": True, "prefer_cloud_provider": "115"} + result = await self._assistant_cloud_transfer_execute( + request, + keyword=search_keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(candidate.get("media_type") or state.get("media_type") or "auto").lower() or "auto", + year=candidate_year or self._clean_text(state.get("year")), + source_order=["pansou", "hdhive"], + target_path=self._clean_text(state.get("target_path")), + session_preference_overrides=overrides, + origin=cloud_provider or "cloud_transfer", + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(result) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + return finish(result) + + if kind == "assistant_mp": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total = self._safe_int(state.get("total"), 0) + if action == "next_page": + total_pages = max(1, (max(0, total) + page_size - 1) // page_size) if total else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号查看详情或下载。"} + next_page = current_page + 1 + all_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if all_items: + preview = self._slice_mp_preview_items(all_items, page=next_page, page_size=page_size) + else: + preview = self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=next_page, + page_size=page_size, + ) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + updated_state = { + **state, + "items": preview, + "all_items": all_items, + "page": next_page, + "page_size": page_size, + "total": total, + } + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_mp_search_text( + self._clean_text(state.get("keyword")), + f"{self._clean_text(state.get('keyword'))} — MP搜索", + preview, + total=total, + page=next_page, + page_size=page_size, + result_filter=self._clean_text(state.get("result_filter")), + latest_episode=self._safe_int(state.get("latest_episode"), 0), + episode_filter=self._safe_int(state.get("episode_filter"), 0), + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search_next_page", + "ok": True, + "keyword": self._clean_text(state.get("keyword")), + "items": preview, + "page": next_page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }), + }) + if action == "best": + result = await self._assistant_mp_best_result_detail( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + if action == "best_execute": + plan_result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix="已自动选择当前评分最高的 PT 候选并执行计划", + extra_data={"source_type": "mp_pt"}, + )) + if action == "best_plan": + result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail" and index <= 0: + return {"success": False, "message": "MP 搜索结果详情需要编号,例如:选择 1。"} + if action == "plan" and index <= 0: + return {"success": False, "message": "生成 PT 下载计划需要编号,例如:下载1。"} + if action == "plan" or (not action and index > 0): + result = self._assistant_mp_download_plan_response( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download", + message="PT 下载计划已生成", + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + result = await self._assistant_mp_result_detail( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + + if kind == "assistant_hdhive": + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_pick", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + stage = str(state.get("stage") or "").strip() + service = self._ensure_hdhive_service() + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if stage == "candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action == "best": + return {"success": False, "message": "影巢候选影片阶段没有评分,不能用“最佳片源”;请先回复编号选择影片,进入资源列表后再用“最佳片源”。"} + if action == "best_execute": + return {"success": False, "message": "影巢候选影片阶段还不能直接执行最佳;请先进入资源列表,或直接发送:智能执行 片名。"} + if action == "best_plan": + return {"success": False, "message": "影巢候选影片阶段还不能直接生成最佳计划;请先进入资源列表,或直接发送:智能计划 片名。"} + if action == "plan": + return {"success": False, "message": "影巢候选影片阶段不能生成资源计划;请先回复编号选择影片,进入资源列表后再发:计划选择 1。"} + if action: + return {"success": False, "message": "影巢候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效影片编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or state.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": f"影巢资源查询失败:{resource_message}", "data": resource_result} + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=final_path, + ) + self._save_session( + cache_key, + { + **state, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": final_path, + }, + ) + return finish({ + "success": True, + "message": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_search", + "ok": True, + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "total_pages": max(1, (len(preview) + self._assistant_result_page_size - 1) // self._assistant_result_page_size) if preview else 1, + "score_summary": self._score_summary(preview, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(preview), + }), + }) + resources = state.get("resources") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return finish({ + "success": False, + "message": "已经是最后一页了,可以直接回复编号继续选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": False, + "error_code": "already_last_page", + "page": current_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_resource_lines(resources, dict(state.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": True, + "selected_candidate": dict(state.get("selected_candidate") or {}), + "resources": resources, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + "score_summary": self._score_summary(resources, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(resources), + }), + }) + if action == "best": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="影巢当前最佳资源"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法执行最佳。"} + plan_result = self._save_assistant_pick_plan_response( + workflow="hdhive_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择影巢最佳资源并执行:#{index}", + extra_data={"choice": index, "source_type": "hdhive"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法生成计划。"} + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_best_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + )) + if action == "detail": + if index <= 0: + return {"success": False, "message": "影巢资源详情需要编号,例如:选择 1 详情。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(resource, title="影巢资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resource_detail", + "ok": True, + "choice": index, + "item": resource, + "score_summary": self._score_summary([resource], limit=1), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "影巢资源计划需要编号,例如:计划选择 1。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法生成计划。"} + actions = [{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": resource, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_unlock_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "hdhive_unlock_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢解锁/转存计划已生成", + score_items=[resource], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": resource, + }, + )) + if action: + return {"success": False, "message": "影巢资源阶段只支持:直接回编号、计划选择 编号、选择 编号 详情;也支持短命令“详情 3”“计划 3”。"} + if index <= 0: + return {"success": False, "message": "请选择有效资源编号,例如:选择 1"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_unlock", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(resource.get('title') or resource.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + }) + + return {"success": False, "message": f"当前会话阶段不支持继续选择:{kind or 'unknown'}"} + + async def api_assistant_capabilities(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_capabilities_public_data() + return { + "success": True, + "message": self._format_assistant_capabilities_text(), + "data": self._assistant_capabilities_compact_data(data) if compact else self._assistant_response_data( + session="default", + data=data, + ), + } + + async def api_assistant_readiness(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_readiness_public_data() + response_data = { + "action": "readiness", + "ok": bool(data.get("can_start")), + **data, + } + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_readiness_text(), + "data": self._assistant_readiness_compact_data(response_data) if compact else self._assistant_response_data( + session="default", + data=response_data, + ), + } + + async def api_assistant_pulse(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_pulse_public_data() + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_pulse_text(), + "data": data, + } + + async def api_assistant_startup(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_startup_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_startup_text(), + "data": data, + } + + async def api_assistant_maintain(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + requested_execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + execute = requested_execute and request.method.upper() != "GET" + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + if requested_execute and request.method.upper() == "GET": + data["execute_ignored"] = True + data["warning"] = "GET 请求只返回 dry-run 维护建议;如需执行维护请使用 POST execute=true。" + if execute: + executed_actions = data.get("executed_actions") or [] + self._record_assistant_execution( + action="maintain", + session=self._clean_text(body.get("session")) or "default", + session_id=self._clean_text(body.get("session_id")), + success=bool(data.get("ok")), + message=self._format_assistant_maintain_text(data), + summary={ + "executed": bool(data.get("executed")), + "executed_actions": [ + { + "name": self._clean_text(item.get("name")), + "removed": self._safe_int(item.get("removed"), 0), + } + for item in executed_actions + if isinstance(item, dict) + ], + "after": { + "stale_sessions": (data.get("after") or {}).get("stale_sessions"), + "saved_plans_executed": (data.get("after") or {}).get("saved_plans_executed"), + "saved_plans_pending": (data.get("after") or {}).get("saved_plans_pending"), + }, + }, + ) + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_maintain_text(data), + "data": data, + } + + async def api_assistant_toolbox(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_toolbox_public_data() + return { + "success": True, + "message": self._format_assistant_toolbox_text(), + "data": data, + } + + async def api_assistant_request_templates(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + names = ( + body.get("names") + or body.get("name") + or body.get("template") + or request.query_params.get("names") + or request.query_params.get("name") + or request.query_params.get("template") + ) + recipe = ( + body.get("recipe") + or body.get("recommended_recipe") + or request.query_params.get("recipe") + or request.query_params.get("recommended_recipe") + ) + include_templates = self._parse_bool_value( + body.get("include_templates") if "include_templates" in body else request.query_params.get("include_templates"), + True, + ) + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return { + "success": True, + "message": self._format_assistant_request_templates_text(data), + "data": data, + } + + async def api_assistant_selfcheck(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_selfcheck_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_selfcheck_text(), + "data": data, + } + + async def api_assistant_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + response_data = self._assistant_history_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "history", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_history_text(session=session, session_id=session_id, limit=limit), + "data": response_data, + } + + async def api_assistant_plans(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + executed = self._parse_optional_bool(request.query_params.get("executed")) + include_actions = bool(self._parse_optional_bool(request.query_params.get("include_actions")) or False) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + response_data = self._assistant_plans_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "plans", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": response_data, + } + + async def api_assistant_plans_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_workflow_plans( + plan_id=body.get("plan_id"), + session=body.get("session"), + session_id=body.get("session_id"), + executed=self._parse_optional_bool(body.get("executed")), + all_plans=self._parse_bool_value(body.get("all_plans"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + if not result.get("ok"): + return { + "success": False, + "message": str(result.get("message") or "计划清理参数无效"), + "data": result, + } + return { + "success": True, + "message": str(result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=body.get("session") or "default", data={ + "action": "plans_clear", + "ok": True, + **result, + }), + } + + async def api_assistant_recover(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session")) + session_id = self._clean_text(body.get("session_id") or request.query_params.get("session_id")) + execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + prefer_unexecuted = self._parse_bool_value( + body.get("prefer_unexecuted") if "prefer_unexecuted" in body else None, + True, + ) + stop_on_error = self._parse_bool_value( + body.get("stop_on_error") if "stop_on_error" in body else None, + True, + ) + include_raw_results = self._parse_bool_value( + body.get("include_raw_results") if "include_raw_results" in body else None, + False, + ) + compact = bool( + self._parse_optional_bool(body.get("compact")) + if "compact" in body + else self._parse_optional_bool(request.query_params.get("compact")) + ) + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 20) + data = self._assistant_recover_public_data( + session=session, + session_id=session_id, + limit=limit, + ) + data.update({ + "action": "recover", + "ok": True, + "execute_requested": execute, + "executed": False, + }) + + if not execute: + return { + "success": True, + "message": self._format_assistant_recover_text(data), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + recovery = dict(data.get("recovery") or {}) + if not recovery.get("can_resume"): + data["ok"] = False + data["execute_error"] = recovery.get("reason") or "当前没有可直接恢复的动作" + return { + "success": False, + "message": str(data["execute_error"]), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + template = dict(recovery.get("action_template") or {}) + action_body = dict(template.get("action_body") or {}) + if not action_body and template.get("name"): + action_body = {"name": template.get("name"), **dict(template.get("body") or {})} + if not self._clean_text(action_body.get("name")): + data["ok"] = False + data["execute_error"] = "恢复模板缺少可执行动作名" + return { + "success": False, + "message": data["execute_error"], + "data": self._assistant_recover_response_data(data, compact=compact), + } + + action_body.setdefault("session", data.get("session") or "default") + if data.get("session_id"): + action_body.setdefault("session_id", data.get("session_id")) + action_body.setdefault("prefer_unexecuted", prefer_unexecuted) + action_body.setdefault("stop_on_error", stop_on_error) + action_body.setdefault("include_raw_results", include_raw_results) + action_body["apikey"] = self._extract_apikey(request, body) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + result_data = dict(result.get("data") or {}) + data.update({ + "ok": bool(result.get("success")), + "executed": True, + "execute_success": bool(result.get("success")), + "execute_action": action_body.get("name"), + "execute_message": result.get("message") or "", + "execute_result": result if include_raw_results else { + "success": bool(result.get("success")), + "message": result.get("message") or "", + "data": { + "action": result_data.get("action"), + "ok": result_data.get("ok"), + "session": result_data.get("session"), + "session_id": result_data.get("session_id"), + "plan_id": result_data.get("plan_id"), + "workflow": result_data.get("workflow"), + }, + }, + }) + return { + "success": bool(result.get("success")), + "message": f"恢复动作 {action_body.get('name')} 执行完成\n{result.get('message') or ''}".strip(), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + async def api_assistant_session_state(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, _ = self._normalize_assistant_session_ref( + session=( + (body or {}).get("session") + or request.query_params.get("session") + or request.query_params.get("chat_id") + or request.query_params.get("user_id") + or "default" + ), + session_id=(body or {}).get("session_id") or request.query_params.get("session_id"), + ) + compact = bool( + self._parse_optional_bool((body or {}).get("compact")) + if "compact" in (body or {}) + else self._parse_optional_bool(request.query_params.get("compact")) + ) + summary = self._format_assistant_session_summary(session=session) + session_state = self._assistant_session_public_data(session=session) + if compact: + return { + "success": True, + "message": summary, + "data": self._assistant_session_compact_data(session_state), + } + data = self._assistant_response_data(session=session, data={ + "action": "session_state", + "ok": True, + "session_snapshot": session_state, + **session_state, + }) + return {"success": True, "message": summary, "data": data} + + async def api_assistant_session_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + existing = self._load_session(cache_key) + if not existing: + return { + "success": True, + "message": "当前没有需要清理的会话。", + "data": self._assistant_response_data(session=session, data={"cleared": False}), + } + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return { + "success": True, + "message": f"已清理会话:{session}", + "data": self._assistant_response_data(session=session, data={"cleared": True}), + } + + async def api_assistant_sessions(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + kind = self._clean_text(request.query_params.get("kind")) + has_pending_p115_raw = request.query_params.get("has_pending_p115") + has_pending_p115: Optional[bool] = None + if has_pending_p115_raw is not None: + has_pending_p115 = str(has_pending_p115_raw).strip().lower() in {"1", "true", "yes", "y"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact"))) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + if compact: + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_sessions_compact_data(data), + } + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_response_data(session="default", data={ + "action": "sessions", + "ok": True, + **data, + }), + } + + async def api_assistant_sessions_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_assistant_sessions( + session=body.get("session"), + session_id=body.get("session_id"), + kind=body.get("kind"), + has_pending_p115=body.get("has_pending_p115"), + stale_only=self._parse_bool_value(body.get("stale_only"), False), + all_sessions=self._parse_bool_value(body.get("all_sessions"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + cleared_count = self._safe_int(result.get("cleared_count"), 0) + if cleared_count <= 0: + return { + "success": True, + "message": "没有匹配到需要清理的 assistant 会话。", + "data": result, + } + return { + "success": True, + "message": f"已清理 {cleared_count} 个 assistant 会话。", + "data": result, + } + + async def api_session_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": result.get("candidates") or [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(result.get("candidates") or [], page=1, page_size=self._hdhive_candidate_page_size), + "session_id": session_id, + "stage": "candidate", + "keyword": keyword, + "candidates": result.get("candidates") or [], + "candidate_count": len(result.get("candidates") or []), + "meta": result.get("meta") or {}, + }, + } + + async def api_session_hdhive_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + session_id = self._clean_text(body.get("session_id")) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + action = self._normalize_pick_action(body.get("action") or body.get("pick_action")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + if not session_id or (index <= 0 and not action): + return {"success": False, "message": "session_id 和选择编号必填;详情/翻页动作可传 action"} + + session = self._load_session(session_id) + if not session: + return {"success": False, "message": "会话不存在或已过期"} + + stage = session.get("stage") + service = self._ensure_hdhive_service() + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(session_id, {**session, "candidates": enriched, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": current_page, + "candidates": enriched, + }, + } + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(candidates): + return {"success": False, "message": "候选编号超出范围"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": resource_message, "data": resource_result} + preview = self._group_resource_preview(resource_result.get("data") or [], per_group=None) + self._save_session( + session_id, + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": target_path or session.get("target_path") or "", + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "session_id": session_id, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "meta": { + "total": len(preview), + "count_115": len([x for x in preview if str(x.get("pan_type") or "").lower() == "115"]), + "count_quark": len([x for x in preview if str(x.get("pan_type") or "").lower() == "quark"]), + }, + }, + } + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "page_size": page_size, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "session_id": session_id, + "stage": "resource", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(resources): + return {"success": False, "message": "资源编号超出范围"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=target_path or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return { + "success": True, + "message": route_message, + "data": { + "text": self._format_route_result(route_result), + "session_id": session_id, + "selected_resource": resource, + "result": route_result, + }, + } + + return {"success": False, "message": f"当前会话阶段不支持继续选择: {stage}"} diff --git a/AgentResourceOfficer/agenttool.py b/AgentResourceOfficer/agenttool.py new file mode 100644 index 0000000..4af8355 --- /dev/null +++ b/AgentResourceOfficer/agenttool.py @@ -0,0 +1,870 @@ +from typing import Optional, Type + +from pydantic import BaseModel + +from app.agent.tools.base import MoviePilotTool +from app.core.plugin import PluginManager + +from .schemas import ( + AssistantCapabilitiesToolInput, + AssistantExecuteActionToolInput, + AssistantExecuteActionsToolInput, + AssistantExecutePlanToolInput, + AssistantHistoryToolInput, + AssistantHelpToolInput, + AssistantMaintainToolInput, + AssistantPickToolInput, + AssistantPreferencesToolInput, + AssistantPlansClearToolInput, + AssistantPlansToolInput, + AssistantPulseToolInput, + AssistantReadinessToolInput, + AssistantRecoverToolInput, + AssistantRequestTemplatesToolInput, + AssistantRouteToolInput, + AssistantSessionClearToolInput, + AssistantSessionsClearToolInput, + AssistantSessionsToolInput, + AssistantSessionStateToolInput, + AssistantSelfcheckToolInput, + AssistantStartupToolInput, + AssistantToolboxToolInput, + AssistantWorkflowToolInput, + FeishuChannelHealthToolInput, + HDHiveSearchSessionToolInput, + HDHiveSessionPickToolInput, + P115CancelPendingToolInput, + P115PendingToolInput, + P115QRCodeCheckToolInput, + P115QRCodeStartToolInput, + P115ResumePendingToolInput, + P115StatusToolInput, + ShareRouteToolInput, +) + + +def _get_plugin(): + return PluginManager().running_plugins.get("AgentResourceOfficer") + + +class HDHiveSearchSessionTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_search" + description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step." + args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + keyword = kwargs.get("keyword", "") + return f"正在通过 Agent影视助手搜索影巢候选:{keyword}" + + async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_search_session( + keyword=keyword, + media_type=media_type, + year=year, + target_path=path, + ) + + +class HDHiveSessionPickTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_pick" + description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item." + args_schema: Type[BaseModel] = HDHiveSessionPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session_id = kwargs.get("session_id", "") + choice = kwargs.get("choice", "") + return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}" + + async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_pick_session( + session_id=session_id, + index=choice, + target_path=path, + action=action, + ) + + +class ShareRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_route_share" + description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path." + args_schema: Type[BaseModel] = ShareRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 路由分享链接" + + async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_route_share( + share_url=url, + access_code=access_code, + target_path=path, + ) + + +class AssistantRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_entry" + description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links." + args_schema: Type[BaseModel] = AssistantRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or "" + return f"正在通过 Agent影视助手 统一入口处理:{text}" + + async def run( + self, + text: str = None, + session: str = "default", + session_id: str = None, + path: str = None, + mode: str = None, + keyword: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + action: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_route( + text=text, + session=session, + session_id=session_id, + target_path=path, + mode=mode, + keyword=keyword, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + action=action, + compact=compact, + ) + + +class AssistantPickTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_pick" + description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page." + args_schema: Type[BaseModel] = AssistantPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + choice = kwargs.get("choice", 0) + action = kwargs.get("action", "") + tail = f"动作 {action}" if action else f"选择 {choice}" + return f"正在继续 Agent影视助手 统一会话:{session},{tail}" + + async def run( + self, + session: str = "default", + session_id: str = None, + choice: int = 0, + action: str = None, + mode: str = None, + path: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pick( + session=session, + session_id=session_id, + index=choice, + action=action, + mode=mode, + target_path=path, + compact=compact, + ) + + +class AssistantHelpTool(MoviePilotTool): + name: str = "agent_resource_officer_help" + description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance." + args_schema: Type[BaseModel] = AssistantHelpToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 使用帮助" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_help(session=session, session_id=session_id) + + +class AssistantCapabilitiesTool(MoviePilotTool): + name: str = "agent_resource_officer_capabilities" + description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents." + args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 能力说明" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_capabilities(compact=compact) + + +class AssistantReadinessTool(MoviePilotTool): + name: str = "agent_resource_officer_readiness" + description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings." + args_schema: Type[BaseModel] = AssistantReadinessToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 启动就绪状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_readiness(compact=compact) + + +class FeishuChannelHealthTool(MoviePilotTool): + name: str = "agent_resource_officer_feishu_health" + description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured." + args_schema: Type[BaseModel] = FeishuChannelHealthToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 内置飞书入口状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_feishu_health(compact=compact) + + +class AssistantPulseTool(MoviePilotTool): + name: str = "agent_resource_officer_pulse" + description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents." + args_schema: Type[BaseModel] = AssistantPulseToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 轻量启动状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pulse() + + +class AssistantStartupTool(MoviePilotTool): + name: str = "agent_resource_officer_startup" + description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint." + args_schema: Type[BaseModel] = AssistantStartupToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 启动聚合信息" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_startup() + + +class AssistantMaintainTool(MoviePilotTool): + name: str = "agent_resource_officer_maintain" + description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans." + args_schema: Type[BaseModel] = AssistantMaintainToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 维护建议" + + async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_maintain(execute=execute, limit=limit) + + +class AssistantToolboxTool(MoviePilotTool): + name: str = "agent_resource_officer_toolbox" + description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples." + args_schema: Type[BaseModel] = AssistantToolboxToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 轻量工具清单" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_toolbox() + + +class AssistantRequestTemplatesTool(MoviePilotTool): + name: str = "agent_resource_officer_request_templates" + description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies." + args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 请求模板" + + async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_request_templates( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + + +class AssistantSelfcheckTool(MoviePilotTool): + name: str = "agent_resource_officer_selfcheck" + description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health." + args_schema: Type[BaseModel] = AssistantSelfcheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在执行 Agent影视助手 协议自检" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_selfcheck() + + +class AssistantHistoryTool(MoviePilotTool): + name: str = "agent_resource_officer_history" + description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action." + args_schema: Type[BaseModel] = AssistantHistoryToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 最近执行历史" + + async def run( + self, + session: str = None, + session_id: str = None, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_history( + session=session, + session_id=session_id, + compact=compact, + limit=limit, + ) + + +class AssistantExecuteActionTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_action" + description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step." + args_schema: Type[BaseModel] = AssistantExecuteActionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + choice: int = None, + path: str = None, + keyword: str = None, + media_type: str = None, + year: str = None, + url: str = None, + access_code: str = None, + client_type: str = None, + source: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = None, + prefer_unexecuted: bool = True, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_action( + name=name, + session=session, + session_id=session_id, + choice=choice, + target_path=path, + keyword=keyword, + media_type=media_type, + year=year, + share_url=url, + access_code=access_code, + client_type=client_type, + source=source, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + plan_id=plan_id, + prefer_unexecuted=prefer_unexecuted, + compact=compact, + ) + + +class AssistantExecuteActionsTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_actions" + description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly." + args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + actions = kwargs.get("actions") or [] + return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步" + + async def run( + self, + actions: list, + session: str = "default", + session_id: str = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_actions( + actions=actions, + session=session, + session_id=session_id, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantWorkflowTool(MoviePilotTool): + name: str = "agent_resource_officer_run_workflow" + description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs." + args_schema: Type[BaseModel] = AssistantWorkflowToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + keyword: str = None, + choice: int = None, + candidate_choice: int = None, + resource_choice: int = None, + path: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + source: str = None, + limit: int = 20, + dry_run: bool = False, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_workflow( + name=name, + session=session, + session_id=session_id, + keyword=keyword, + choice=choice, + candidate_choice=candidate_choice, + resource_choice=resource_choice, + target_path=path, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + source=source, + limit=limit, + dry_run=dry_run, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPreferencesTool(MoviePilotTool): + name: str = "agent_resource_officer_preferences" + description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions." + args_schema: Type[BaseModel] = AssistantPreferencesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + if kwargs.get("reset"): + return "正在重置 Agent影视助手 智能体偏好画像" + if kwargs.get("preferences"): + return "正在保存 Agent影视助手 智能体偏好画像" + return "正在读取 Agent影视助手 智能体偏好画像" + + async def run( + self, + session: str = "default", + session_id: str = None, + user_key: str = None, + preferences: dict = None, + reset: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_preferences( + session=session, + session_id=session_id, + user_key=user_key, + preferences=preferences, + reset=reset, + compact=compact, + ) + + +class AssistantExecutePlanTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_plan" + description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id." + args_schema: Type[BaseModel] = AssistantExecutePlanToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPlansTool(MoviePilotTool): + name: str = "agent_resource_officer_plans" + description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id." + args_schema: Type[BaseModel] = AssistantPlansToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 已保存计划" + + async def run( + self, + session: str = None, + session_id: str = None, + executed: bool = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + compact=compact, + limit=limit, + ) + + +class AssistantPlansClearTool(MoviePilotTool): + name: str = "agent_resource_officer_plans_clear" + description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans." + args_schema: Type[BaseModel] = AssistantPlansClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 已保存计划" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + executed: bool = None, + all_plans: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans_clear( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + + +class AssistantRecoverTool(MoviePilotTool): + name: str = "agent_resource_officer_recover" + description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint." + args_schema: Type[BaseModel] = AssistantRecoverToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + target = kwargs.get("session_id") or kwargs.get("session") or "全局" + action = "并直接恢复" if kwargs.get("execute") else "恢复建议" + return f"正在查看 Agent影视助手 {target} 的{action}" + + async def run( + self, + session: str = None, + session_id: str = None, + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_recover( + session=session, + session_id=session_id, + execute=execute, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + limit=limit, + ) + + +class AssistantSessionStateTool(MoviePilotTool): + name: str = "agent_resource_officer_session_state" + description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task." + args_schema: Type[BaseModel] = AssistantSessionStateToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在查看 Agent影视助手 会话状态:{session}" + + async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact) + + +class AssistantSessionClearTool(MoviePilotTool): + name: str = "agent_resource_officer_session_clear" + description: str = "Clear the current Agent影视助手 assistant session cache." + args_schema: Type[BaseModel] = AssistantSessionClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在清理 Agent影视助手 会话:{session}" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_clear(session=session, session_id=session_id) + + +class AssistantSessionsTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions" + description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow." + args_schema: Type[BaseModel] = AssistantSessionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 活跃会话列表" + + async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions( + kind=kind, + has_pending_p115=has_pending_p115, + compact=compact, + limit=limit, + ) + + +class AssistantSessionsClearTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions_clear" + description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset." + args_schema: Type[BaseModel] = AssistantSessionsClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 活跃会话" + + async def run( + self, + session: str = None, + session_id: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions_clear( + session=session, + session_id=session_id, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + ) + + +class P115QRCodeStartTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_start" + description: str = "Generate a 115 login QR code using the p115client-compatible client session flow." + args_schema: Type[BaseModel] = P115QRCodeStartToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + client_type = kwargs.get("client_type", "alipaymini") + return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}" + + async def run(self, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_start(client_type=client_type) + + +class P115QRCodeCheckTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_check" + description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds." + args_schema: Type[BaseModel] = P115QRCodeCheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 检查 115 扫码状态" + + async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_check( + uid=uid, + time_value=time, + sign=sign, + client_type=client_type, + ) + + +class P115StatusTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_status" + description: str = "Show the current 115 transfer readiness, default target path, and current session source." + args_schema: Type[BaseModel] = P115StatusToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看 115 当前状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_status() + + +class P115PendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_pending" + description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error." + args_schema: Type[BaseModel] = P115PendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看待继续的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_pending(session=session) + + +class P115ResumePendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_resume_pending" + description: str = "Retry the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115ResumePendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 继续待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_resume(session=session) + + +class P115CancelPendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_cancel_pending" + description: str = "Cancel and clear the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115CancelPendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 取消待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_cancel(session=session) diff --git a/AgentResourceOfficer/feishu_channel.py b/AgentResourceOfficer/feishu_channel.py new file mode 100644 index 0000000..44a1c32 --- /dev/null +++ b/AgentResourceOfficer/feishu_channel.py @@ -0,0 +1,1885 @@ +import asyncio +import copy +import fcntl +import importlib +import json +import re +import sqlite3 +import threading +import time +import traceback +from base64 import b64decode +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import jieba +except Exception: + jieba = None + +try: + import lark_oapi as lark +except Exception: + lark = None + +_LARK_IMPORT_LOCK = threading.Lock() + +try: + 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.core.event import eventmanager + from app.core.metainfo import MetaInfo + from app.db.downloadhistory_oper import DownloadHistoryOper + from app.db.models.downloadhistory import DownloadHistory + from app.db.models.transferhistory import TransferHistory + from app.db.site_oper import SiteOper + from app.db.subscribe_oper import SubscribeOper + from app.db.systemconfig_oper import SystemConfigOper + from app.helper.subscribe import SubscribeHelper + from app.core.plugin import PluginManager + from app.log import logger + from app.scheduler import Scheduler + from app.schemas.types import EventType, SystemConfigKey, TorrentStatus, media_type_to_agent + from app.utils.http import RequestUtils + from app.utils.string import StringUtils +except Exception: + DownloadChain = None + DownloadHistoryOper = None + DownloadHistory = None + TransferHistory = None + MediaChain = None + SearchChain = None + SiteOper = None + SubscribeChain = None + SubscribeHelper = None + SubscribeOper = None + SystemConfigOper = None + eventmanager = None + MetaInfo = None + PluginManager = None + Scheduler = None + EventType = None + SystemConfigKey = None + TorrentStatus = None + media_type_to_agent = None + RequestUtils = None + StringUtils = None + + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() + + +_EVENT_CACHE_FILE = Path(__file__).resolve().parent / ".feishu_event_cache.json" + + +def ensure_lark_sdk(auto_install: bool = False) -> tuple[bool, str]: + global lark + + if lark is not None: + return True, "" + + with _LARK_IMPORT_LOCK: + if lark is not None: + return True, "" + + try: + import lark_oapi as runtime_lark + + lark = runtime_lark + return True, "" + except Exception as exc: + first_error = str(exc) + + return False, f"缺少依赖 lark-oapi:{first_error}。请通过插件 requirements.txt 安装依赖后重启 MoviePilot。" + + +class _FeishuLongConnectionRuntime: + def __init__(self) -> None: + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._fingerprint = "" + self._channel: Optional["FeishuChannel"] = None + + def start(self, channel: "FeishuChannel") -> None: + ok, message = ensure_lark_sdk(auto_install=False) + if not ok: + logger.error(f"[AgentResourceOfficer][Feishu] {message}") + return + + if not channel.enabled or not channel.app_id or not channel.app_secret: + return + + fingerprint = channel.connection_fingerprint() + with self._lock: + self._channel = channel + if self._thread and self._thread.is_alive(): + if fingerprint != self._fingerprint: + logger.warning("[AgentResourceOfficer][Feishu] 长连接已在运行,飞书凭证变更需重启 MoviePilot 后生效") + return + self._fingerprint = fingerprint + self._thread = threading.Thread( + target=self._run, + name="agent-resource-officer-feishu", + daemon=True, + ) + self._thread.start() + + def _run(self) -> None: + channel = self._channel + if channel is None or lark is None: + return + + def _on_message(data) -> None: + current = self._channel + if current is not None: + current.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( + channel.app_id, + channel.app_secret, + log_level=lark.LogLevel.DEBUG if channel.debug else lark.LogLevel.INFO, + event_handler=event_handler, + ) + logger.info("[AgentResourceOfficer][Feishu] 正在启动飞书长连接") + ws_client.start() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 长连接退出:{exc}\n{traceback.format_exc()}") + + def is_running(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + def stop(self) -> None: + with self._lock: + self._channel = None + + +class FeishuChannel: + _LEGACY_DEFAULT_COMMANDS = { + "/p115_manual_transfer", + "/p115_inc_sync", + "/p115_full_sync", + "/p115_strm", + "/quark_save", + "/media_search", + "/media_download", + "/media_subscribe", + "/media_subscribe_search", + } + _LEGACY_DEFAULT_ALIAS_KEYS = { + "刮削", + "搜索", + "MP搜索", + "原生搜索", + "下载", + "订阅", + "订阅搜索", + "生成STRM", + "全量STRM", + "指定路径STRM", + "夸克转存", + "夸克", + "搜索资源", + "下载资源", + "订阅媒体", + "订阅并搜索", + } + + def __init__(self, plugin: Any) -> None: + self.plugin = plugin + self.runtime = _FeishuLongConnectionRuntime() + self.enabled = False + self.allow_all = False + self.reply_enabled = True + self.reply_receive_id_type = "chat_id" + self.app_id = "" + self.app_secret = "" + self.verification_token = "" + self.allowed_chat_ids: List[str] = [] + self.allowed_user_ids: List[str] = [] + self.command_whitelist: List[str] = [] + self.command_aliases = "" + self.command_mode = "resource_officer" + self.debug = False + self._token_cache: Dict[str, Any] = {} + self._token_lock = threading.Lock() + self._event_cache: Dict[str, float] = {} + self._event_lock = threading.Lock() + self._search_cache: Dict[str, Dict[str, Any]] = {} + self._search_cache_lock = threading.Lock() + self._search_cache_limit = 200 + + @classmethod + def default_command_whitelist(cls) -> List[str]: + return [ + "/pansou_search", + "/smart_entry", + "/smart_pick", + "/media_search", + "/version", + ] + + @classmethod + def default_command_aliases(cls) -> str: + return ( + "搜索=/smart_entry\n" + "找=/smart_entry\n" + "云盘搜索=/smart_entry\n" + "MP搜索=/smart_entry\n" + "PT搜索=/smart_entry\n" + "原生搜索=/smart_entry\n" + "盘搜搜索=/pansou_search\n" + "盘搜=/pansou_search\n" + "ps=/pansou_search\n" + "1=/pansou_search\n" + "影巢搜索=/smart_entry\n" + "影巢=/smart_entry\n" + "yc=/smart_entry\n" + "2=/smart_entry\n" + "转存=/smart_entry\n" + "115转存=/smart_entry\n" + "夸克转存=/smart_entry\n" + "夸克=/smart_entry\n" + "下载=/smart_entry\n" + "订阅=/smart_entry\n" + "订阅搜索=/smart_entry\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_entry\n" + "影巢普通签到=/smart_entry\n" + "普通签到=/smart_entry\n" + "签到=/smart_entry\n" + "赌狗签到=/smart_entry\n" + "签到日志=/smart_entry\n" + "影巢签到日志=/smart_entry\n" + "选择=/smart_pick\n" + "详情=/smart_pick\n" + "审查=/smart_pick\n" + "选=/smart_pick\n" + "继续=/smart_pick\n" + "搜索资源=/smart_entry\n" + "下载资源=/smart_entry\n" + "订阅媒体=/smart_entry\n" + "订阅并搜索=/smart_entry\n" + "版本=/version" + ) + + @staticmethod + def clean(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() + + @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()] + + @classmethod + def parse_alias_text(cls, 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 + + @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(): + if key in cls._LEGACY_DEFAULT_ALIAS_KEYS and value in cls._LEGACY_DEFAULT_COMMANDS: + continue + merged[key] = value + return "\n".join(f"{key}={value}" for key, value in merged.items()) + + @classmethod + def merge_command_whitelist(cls, configured: List[str]) -> List[str]: + merged: List[str] = [] + seen = set() + for cmd in configured or []: + if cmd in cls._LEGACY_DEFAULT_COMMANDS: + continue + 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 + + def configure(self, config: Dict[str, Any]) -> None: + self.enabled = bool(config.get("feishu_enabled", False)) + self.allow_all = bool(config.get("feishu_allow_all", False)) + self.reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self.reply_receive_id_type = self.clean(config.get("feishu_reply_receive_id_type") or "chat_id") + self.app_id = self.clean(config.get("feishu_app_id")) + self.app_secret = self.clean(config.get("feishu_app_secret")) + self.verification_token = self.clean(config.get("feishu_verification_token")) + self.allowed_chat_ids = self.split_lines(config.get("feishu_allowed_chat_ids")) + self.allowed_user_ids = self.split_lines(config.get("feishu_allowed_user_ids")) + self.command_whitelist = self.merge_command_whitelist(self.split_commands(config.get("feishu_command_whitelist"))) + self.command_aliases = self.merge_command_aliases(self.clean(config.get("feishu_command_aliases"))) + self.command_mode = self.clean(config.get("feishu_command_mode") or "resource_officer") + self.debug = bool(config.get("debug", False)) + + def start(self) -> None: + if self.enabled: + self.runtime.start(self) + + def stop(self) -> None: + self.runtime.stop() + + def is_running(self) -> bool: + return self.runtime.is_running() + + @staticmethod + def is_legacy_bridge_running() -> bool: + if PluginManager is None: + return False + try: + running_plugins = PluginManager().running_plugins or {} + plugin = ( + running_plugins.get("FeishuCommandBridgeLong") + or running_plugins.get("feishucommandbridgelong") + ) + if not plugin: + return False + config_db = Path("/config/user.db") + if config_db.exists(): + try: + with sqlite3.connect(str(config_db)) as conn: + row = conn.execute( + "select value from systemconfig where key=?", + ("plugin.FeishuCommandBridgeLong",), + ).fetchone() + if row and row[0]: + config = json.loads(row[0]) + if not bool(config.get("enabled")): + return False + except Exception: + pass + # MoviePilot may keep disabled plugins in running_plugins after loading. + # Treat the legacy bridge as a conflict only when it is actually enabled. + if hasattr(plugin, "health"): + try: + health = plugin.health() + if isinstance(health, dict): + return bool(health.get("enabled") and health.get("running")) + except Exception: + pass + if hasattr(plugin, "_enabled"): + return bool(getattr(plugin, "_enabled", False)) + if hasattr(plugin, "get_state"): + try: + return bool(plugin.get_state()) + except Exception: + return False + return False + except Exception: + return False + + def connection_fingerprint(self) -> str: + return "|".join([self.app_id, self.app_secret, self.verification_token]) + + def health(self) -> Dict[str, Any]: + sdk_available, sdk_message = ensure_lark_sdk(auto_install=False) + legacy_bridge_running = self.is_legacy_bridge_running() + app_id_configured = bool(self.app_id) + app_secret_configured = bool(self.app_secret) + verification_token_configured = bool(self.verification_token) + missing_requirements = [] + if not sdk_available: + missing_requirements.append("lark-oapi") + if not app_id_configured: + missing_requirements.append("feishu_app_id") + if not app_secret_configured: + missing_requirements.append("feishu_app_secret") + conflict_warning = bool(self.enabled and legacy_bridge_running) + ready_to_start = bool(self.enabled and sdk_available and app_id_configured and app_secret_configured and not conflict_warning) + safe_to_enable = bool((not legacy_bridge_running) and sdk_available and app_id_configured and app_secret_configured) + if conflict_warning: + recommended_action = "disable_legacy_bridge_or_use_different_app" + migration_hint = "内置飞书入口和旧飞书桥接同时运行,建议关闭旧桥接或使用不同飞书 App。" + elif not self.enabled and legacy_bridge_running: + recommended_action = "keep_legacy_or_disable_it_before_migration" + migration_hint = "内置飞书入口关闭,旧飞书桥接运行中;迁移前先关闭旧桥接。" + elif not self.enabled: + recommended_action = "configure_and_enable_feishu_channel" + migration_hint = "内置飞书入口关闭;配置飞书凭证后可开启。" + elif missing_requirements: + recommended_action = "complete_feishu_requirements" + migration_hint = "内置飞书入口已启用,但依赖或飞书凭证不完整。" + elif not self.is_running(): + recommended_action = "restart_moviepilot_or_resave_config" + migration_hint = "内置飞书入口已启用但长连接未运行,建议保存配置或重启 MoviePilot。" + else: + recommended_action = "none" + migration_hint = "内置飞书入口运行正常。" + return { + "enabled": self.enabled, + "running": self.is_running(), + "sdk_available": sdk_available, + "app_id_configured": app_id_configured, + "app_secret_configured": app_secret_configured, + "verification_token_configured": verification_token_configured, + "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_mode": self.command_mode, + "command_whitelist": self.command_whitelist, + "alias_count": len(self.parse_alias_text(self.command_aliases)), + "legacy_bridge_running": legacy_bridge_running, + "conflict_warning": conflict_warning, + "ready_to_start": ready_to_start, + "safe_to_enable": safe_to_enable, + "missing_requirements": missing_requirements, + "sdk_message": sdk_message, + "recommended_action": recommended_action, + "migration_hint": migration_hint, + } + + def handle_long_connection_event(self, data: Any) -> None: + if not self.enabled: + return + event = getattr(data, "event", None) + header = getattr(data, "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 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 self.debug: + logger.info(f"[AgentResourceOfficer][Feishu] event_id={event_id} chat_id={chat_id}") + + if not self._is_allowed(chat_id=chat_id, user_open_id=sender_open_id): + self.reply_text(chat_id, sender_open_id, "该会话未在白名单中,命令已拒绝。") + return + if self._is_help_request(raw_text): + self.reply_text(chat_id, sender_open_id, self._build_help_text()) + return + if self._is_menu_request(raw_text): + self.reply_text(chat_id, sender_open_id, 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_text(chat_id, sender_open_id, f"命令 {cmd} 不在白名单中。\n\n{self._build_help_text()}") + return + if not self._handle_builtin_command(command_text, chat_id, sender_open_id): + self._submit_moviepilot_command(command_text, chat_id, sender_open_id) + + def _handle_builtin_command(self, command_text: str, chat_id: str, open_id: str) -> bool: + parts = command_text.split(maxsplit=1) + cmd = parts[0].strip() + arg = parts[1].strip() if len(parts) > 1 else "" + cache_key = self._cache_key(chat_id, open_id) + + if cmd == "/version": + self.reply_text(chat_id, open_id, f"Agent影视助手 {getattr(self.plugin, 'plugin_version', '')}\n飞书入口:{'运行中' if self.is_running() else '未运行'}") + return True + + if cmd == "/media_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:MP搜索 片名") + return True + self.reply_text(chat_id, open_id, f"正在使用 MP 原生搜索:{arg}") + self._run_thread("feishu-media-search", self._run_media_search, arg, chat_id, open_id) + return True + + if cmd == "/media_download": + if not arg or not arg.isdigit(): + self.reply_text(chat_id, open_id, "用法:下载资源 序号\n示例:下载资源 1") + return True + self.reply_text(chat_id, open_id, f"正在生成第 {arg} 条资源的下载计划,请稍候。") + self._run_thread("feishu-media-download", self._run_media_download, int(arg), chat_id, open_id) + return True + + if cmd in {"/media_subscribe", "/media_subscribe_search"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:订阅媒体 片名\n示例:订阅媒体 流浪地球2") + return True + immediate = cmd == "/media_subscribe_search" + self.reply_text(chat_id, open_id, f"正在{'订阅并搜索' if immediate else '订阅'}:{arg}") + self._run_thread("feishu-media-subscribe", self._run_media_subscribe, arg, immediate, chat_id, open_id) + return True + + if cmd == "/pansou_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:盘搜搜索 片名\n示例:盘搜搜索 流浪地球2") + return True + self.reply_text(chat_id, open_id, f"正在使用盘搜搜索:{arg}") + self._run_thread("feishu-pansou-search", self._run_assistant_route, f"盘搜搜索 {arg}", cache_key, chat_id, open_id) + return True + + if cmd in {"/smart_entry", "/quark_save"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:处理 片名 或 处理 分享链接") + return True + self.reply_text(chat_id, open_id, f"正在智能处理:{arg}") + self._run_thread("feishu-smart-entry", self._run_assistant_route, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/smart_pick": + if not arg: + self.reply_text(chat_id, open_id, "用法:选择 序号\n示例:选择 1\n也支持:详情、审查、n 下一页") + return True + self.reply_text(chat_id, open_id, f"正在继续执行:{arg}") + self._run_thread("feishu-smart-pick", self._run_assistant_pick, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/p115_manual_transfer": + if not arg: + paths = self._get_p115_manual_transfer_paths() + if not paths: + self.reply_text(chat_id, open_id, "未配置待整理目录。请先在 P115StrmHelper 中配置 pan_transfer_paths,或发送:刮削 /待整理/") + return True + self.reply_text(chat_id, open_id, f"已开始刮削 {len(paths)} 个目录:\n" + "\n".join(f"- {path}" for path in paths)) + self._run_thread("feishu-p115-manual-transfer-batch", self._run_p115_manual_transfer_batch, paths, chat_id, open_id) + return True + self.reply_text(chat_id, open_id, f"已开始刮削:{arg}") + self._run_thread("feishu-p115-manual-transfer", self._run_p115_manual_transfer, arg, chat_id, open_id) + return True + + if cmd in {"/p115_inc_sync", "/p115_full_sync", "/p115_strm"}: + final_command = "/p115_full_sync" if cmd == "/p115_strm" and not arg else command_text + self._submit_p115_command(final_command, chat_id, open_id) + return True + + return False + + @staticmethod + def _run_thread(name: str, target: Any, *args: Any) -> None: + threading.Thread(target=target, args=args, name=name, daemon=True).start() + + def _run_assistant_route(self, text: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route(text=text, session=session) + self._reply_result(chat_id, open_id, result) + + def _run_assistant_pick(self, arg: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_pick(arg=arg, session=session) + self._reply_result(chat_id, open_id, result) + + def _reply_result(self, chat_id: str, open_id: str, result: Dict[str, Any]) -> None: + message = str(result.get("message") or "处理完成").strip() + self.reply_text(chat_id, open_id, message) + qrcode = self._find_nested_value(result.get("data"), "qrcode") + if isinstance(qrcode, str): + self.reply_qrcode_data_url(chat_id, open_id, qrcode) + + @classmethod + def _find_nested_value(cls, payload: Any, key: str) -> Any: + if isinstance(payload, dict): + if key in payload: + return payload.get(key) + for value in payload.values(): + found = cls._find_nested_value(value, key) + if found: + return found + elif isinstance(payload, list): + for value in payload: + found = cls._find_nested_value(value, key) + if found: + return found + return None + + def _run_media_search(self, keyword: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_search(keyword, self._cache_key(chat_id, open_id))) + + def _run_media_download(self, index: int, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route( + text=f"下载资源 {index}", + session=self._cache_key(chat_id, open_id), + ) + self._reply_result(chat_id, open_id, result) + + def _run_media_subscribe(self, keyword: str, immediate: bool, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_subscribe(keyword, immediate)) + + def _execute_media_search(self, keyword: str, cache_key: str) -> str: + if not all([MetaInfo, MediaChain, SearchChain, StringUtils]): + return "MP 原生搜索失败:当前环境缺少 MoviePilot 搜索依赖。" + 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) + preview_limit = 20 + preview_results = results[:preview_limit] + lines = [ + f"已识别:{self._format_media_label(mediainfo, season)}", + f"共找到 {len(results)} 条资源,展示前 {len(preview_results)} 条:", + ] + for idx, context in enumerate(preview_results, 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"[AgentResourceOfficer][Feishu] 搜索资源失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"搜索资源失败:{keyword}\n错误:{exc}" + + def _query_media_detail(self, keyword: str, media_type: str = "", year: str = "") -> Dict[str, Any]: + if not all([MetaInfo, MediaChain]): + return {"success": False, "message": "媒体识别失败:当前环境缺少 MoviePilot 媒体识别依赖。", "item": {}} + title_text = str(keyword or "").strip() + if not title_text: + return {"success": False, "message": "媒体识别失败:缺少片名。", "item": {}} + try: + meta = MetaInfo(title_text) + if year: + try: + meta.year = str(year) + except Exception: + pass + mediainfo = MediaChain().recognize_media(meta=meta) + if not mediainfo: + return {"success": False, "message": f"未识别到媒体信息:{title_text}", "item": {"keyword": title_text}} + season = meta.begin_season if meta.begin_season else getattr(mediainfo, "season", None) + media_type_value = getattr(mediainfo, "type", None) + media_type_name = getattr(media_type_value, "name", "") or str(media_type_value or "") + item = { + "keyword": title_text, + "title": str(getattr(mediainfo, "title", "") or ""), + "original_title": str(getattr(mediainfo, "original_title", "") or ""), + "year": str(getattr(mediainfo, "year", "") or ""), + "type": media_type_name, + "tmdb_id": getattr(mediainfo, "tmdb_id", None), + "douban_id": getattr(mediainfo, "douban_id", None), + "imdb_id": str(getattr(mediainfo, "imdb_id", "") or ""), + "season": season, + "category": str(getattr(mediainfo, "category", "") or ""), + "overview": str(getattr(mediainfo, "overview", "") or "")[:300], + } + lines = [ + f"媒体识别:{title_text}", + f"结果:{item.get('title') or '-'} ({item.get('year') or '-'})", + f"类型:{item.get('type') or '-'} | TMDB:{item.get('tmdb_id') or '-'} | 豆瓣:{item.get('douban_id') or '-'}", + ] + if item.get("original_title") and item.get("original_title") != item.get("title"): + lines.append(f"原标题:{item.get('original_title')}") + if season: + lines.append(f"季:S{int(season):02d}" if isinstance(season, int) else f"季:{season}") + if item.get("overview"): + lines.append(f"简介:{item.get('overview')}") + lines.append("说明:这是 MoviePilot 原生识别结果,后续 MP 搜索、订阅和 PT 评分会以它为准。") + return {"success": True, "message": "\n".join(lines), "item": item} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 媒体识别失败:{title_text} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"媒体识别失败:{exc}", "item": {"keyword": title_text}} + + def _execute_media_download(self, index: int, cache_key: str) -> str: + if DownloadChain is None: + return "下载资源失败:当前环境缺少 MoviePilot 下载依赖。" + cache = self._get_search_cache(cache_key) + if not cache: + return "没有可用的搜索缓存,请先发送:MP搜索 片名" + 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: + save_path = "" + if self.plugin is not None: + save_path = str(getattr(self.plugin, "_mp_download_save_path", "") or "").strip() + download_id = DownloadChain().download_single( + context=context, + username="agentresourceofficer-feishu", + source="AgentResourceOfficer", + save_path=save_path or None, + ) + if not download_id: + return f"下载提交失败:{torrent.title}" + path_line = f"\n保存路径:{save_path}" if save_path else "" + return f"已提交下载:{torrent.title}\n站点:{torrent.site_name or '未知站点'}{path_line}\n任务ID:{download_id}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 下载资源失败:{torrent.title} {exc}\n{traceback.format_exc()}") + return f"下载资源失败:{torrent.title}\n错误:{exc}" + + def _query_download_tasks( + self, + *, + downloader: str = "", + status: str = "downloading", + title: str = "", + hash_value: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "查询下载任务失败:当前环境缺少 MoviePilot 下载依赖。", "items": []} + try: + chain = DownloadChain() + status_name = str(status or "downloading").strip().lower() + downloader_name = str(downloader or "").strip() or None + tasks: List[Any] = [] + if hash_value: + tasks = chain.list_torrents(downloader=downloader_name, hashs=[hash_value]) or [] + elif status_name == "downloading": + tasks = chain.downloading(name=downloader_name) or [] + else: + for torrent_status in [TorrentStatus.DOWNLOADING, TorrentStatus.TRANSFER] if TorrentStatus else []: + tasks.extend(chain.list_torrents(downloader=downloader_name, status=torrent_status) or []) + if status_name == "completed": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() in {"seeding", "completed"}] + elif status_name == "paused": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() == "paused"] + if title: + title_lower = title.lower() + tasks = [ + task for task in tasks + if title_lower in str(getattr(task, "title", "") or getattr(task, "name", "") or "").lower() + ] + items: List[Dict[str, Any]] = [] + for index, task in enumerate(tasks[:max(1, min(30, int(limit or 10)))], 1): + task_hash = str(getattr(task, "hash", "") or "") + history = DownloadHistoryOper().get_by_hash(task_hash) if DownloadHistoryOper and task_hash else None + title_text = str(getattr(task, "title", "") or getattr(task, "name", "") or "").strip() + if history and getattr(history, "title", None): + title_text = title_text or str(history.title) + size_value = getattr(task, "size", None) + size_text = StringUtils.str_filesize(size_value) if StringUtils and size_value else "" + progress = getattr(task, "progress", None) + try: + progress_text = f"{float(progress):.1f}%" if progress is not None else "" + except Exception: + progress_text = str(progress or "") + items.append({ + "index": index, + "hash": task_hash, + "hash_short": task_hash[:8], + "downloader": str(getattr(task, "downloader", "") or ""), + "title": title_text or "未命名任务", + "name": str(getattr(task, "name", "") or ""), + "size": size_text, + "progress": progress_text, + "state": str(getattr(task, "state", "") or ""), + "dlspeed": getattr(task, "dlspeed", None), + "upspeed": getattr(task, "upspeed", None), + "left_time": getattr(task, "left_time", None), + "tags": str(getattr(task, "tags", "") or ""), + "media_title": str(getattr(history, "title", "") or "") if history else "", + }) + status_label = { + "downloading": "下载中", + "completed": "已完成", + "paused": "已暂停", + "all": "全部", + }.get(status_name, status_name) + if not items: + return { + "success": True, + "message": f"未找到{status_label}下载任务。", + "items": [], + "total": len(tasks), + "status": status_name, + } + lines = [f"下载任务:{status_label},共 {len(tasks)} 条,展示前 {len(items)} 条:"] + for item in items: + details = [ + item.get("progress") or "进度未知", + item.get("size") or "大小未知", + item.get("state") or "状态未知", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('hash_short')}", + ] + lines.append(f"{item.get('index')}. {item.get('title')}") + lines.append(" " + " | ".join(details)) + lines.append("写入操作需确认:可发“暂停下载 1”“恢复下载 1”“删除下载 1”。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": len(tasks), + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载任务失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载任务失败:{exc}", "items": []} + + def _control_download_task( + self, + *, + action: str, + hash_value: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "操作下载任务失败:当前环境缺少 MoviePilot 下载依赖。"} + task_hash = str(hash_value or "").strip() + if len(task_hash) != 40 or not all(ch in "0123456789abcdefABCDEF" for ch in task_hash): + return {"success": False, "message": "操作下载任务失败:hash 格式无效,请先查询下载任务后按编号操作。"} + downloader_name = str(downloader or "").strip() or None + action_name = str(action or "").strip().lower() + try: + chain = DownloadChain() + if action_name in {"pause", "stop"}: + ok = chain.set_downloading(task_hash, "stop", name=downloader_name) + label = "暂停" + elif action_name in {"resume", "start"}: + ok = chain.set_downloading(task_hash, "start", name=downloader_name) + label = "恢复" + elif action_name in {"delete", "remove"}: + ok = chain.remove_torrents(hashs=[task_hash], downloader=downloader_name, delete_file=bool(delete_files)) + label = "删除" + else: + return {"success": False, "message": f"操作下载任务失败:不支持的动作 {action}"} + suffix = "(包含文件)" if action_name in {"delete", "remove"} and delete_files else "" + return { + "success": bool(ok), + "message": f"{label}下载任务{'成功' if ok else '失败'}:{task_hash[:8]}{suffix}", + "hash": task_hash, + "downloader": downloader_name or "", + "action": action_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作下载任务失败:{task_hash} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作下载任务失败:{exc}"} + + def _query_downloaders(self) -> Dict[str, Any]: + if SystemConfigOper is None or SystemConfigKey is None: + return {"success": False, "message": "查询下载器失败:当前环境缺少 MoviePilot 配置依赖。", "items": []} + try: + raw_items = SystemConfigOper().get(SystemConfigKey.Downloaders) or [] + items: List[Dict[str, Any]] = [] + for index, item in enumerate(raw_items, 1): + if not isinstance(item, dict): + continue + items.append({ + "index": index, + "name": str(item.get("name") or ""), + "type": str(item.get("type") or ""), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + }) + enabled = [item for item in items if item.get("enabled")] + if not items: + return {"success": True, "message": "未配置下载器。", "items": [], "enabled_count": 0} + lines = [f"下载器配置:共 {len(items)} 个,启用 {len(enabled)} 个"] + for item in items: + status = "启用" if item.get("enabled") else "停用" + default = ",默认" if item.get("default") else "" + lines.append(f"{item.get('index')}. {item.get('name') or '-'} | {item.get('type') or '-'} | {status}{default}") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "enabled_count": len(enabled), + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载器失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载器失败:{exc}", "items": []} + + def _query_sites(self, *, status: str = "active", name: str = "", limit: int = 30) -> Dict[str, Any]: + if SiteOper is None: + return {"success": False, "message": "查询站点失败:当前环境缺少 MoviePilot 站点依赖。", "items": []} + try: + status_name = str(status or "active").strip().lower() + name_filter = str(name or "").strip().lower() + sites = SiteOper().list_order_by_pri() or [] + items: List[Dict[str, Any]] = [] + for site in sites: + is_active = bool(getattr(site, "is_active", False)) + if status_name == "active" and not is_active: + continue + if status_name == "inactive" and is_active: + continue + site_name = str(getattr(site, "name", "") or "") + if name_filter and name_filter not in site_name.lower(): + continue + cookie = str(getattr(site, "cookie", "") or "") + items.append({ + "index": len(items) + 1, + "id": getattr(site, "id", None), + "name": site_name, + "domain": str(getattr(site, "domain", "") or ""), + "url": str(getattr(site, "url", "") or ""), + "pri": getattr(site, "pri", None), + "is_active": is_active, + "has_cookie": bool(cookie), + "downloader": str(getattr(site, "downloader", "") or ""), + "proxy": bool(getattr(site, "proxy", False)), + "timeout": getattr(site, "timeout", None), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 30)))] + label = {"active": "已启用", "inactive": "已停用", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{label}站点。", "items": [], "total": total} + lines = [f"PT 站点:{label},共 {total} 个,展示前 {len(items)} 个:"] + for item in items: + cookie_state = "有Cookie" if item.get("has_cookie") else "无Cookie" + active_state = "启用" if item.get("is_active") else "停用" + lines.append( + f"{item.get('index')}. {item.get('name') or '-'} | {item.get('domain') or '-'} | " + f"{active_state} | {cookie_state} | 优先级:{item.get('pri')} | 下载器:{item.get('downloader') or '默认'}" + ) + lines.append("说明:这里不会返回 Cookie 明文;如站点搜索失败,优先检查是否启用、Cookie 是否存在、站点绑定下载器是否可用。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询站点失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询站点失败:{exc}", "items": []} + + def _query_subscribes( + self, + *, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "查询订阅失败:当前环境缺少 MoviePilot 订阅依赖。", "items": []} + try: + status_name = str(status or "all").strip() + media_type_name = str(media_type or "all").strip().lower() + name_filter = str(name or "").strip().lower() + subscribes = SubscribeOper().list() or [] + items: List[Dict[str, Any]] = [] + for sub in subscribes: + state = str(getattr(sub, "state", "") or "") + if status_name != "all" and state != status_name: + continue + sub_type = str(getattr(sub, "type", "") or "").lower() + if media_type_name != "all" and media_type_name not in {sub_type, "movie" if sub_type == "电影" else sub_type, "tv" if sub_type == "电视剧" else sub_type}: + continue + title = str(getattr(sub, "name", "") or "") + if name_filter and name_filter not in title.lower(): + continue + items.append({ + "index": len(items) + 1, + "id": getattr(sub, "id", None), + "name": title or "未命名订阅", + "year": str(getattr(sub, "year", "") or ""), + "type": str(getattr(sub, "type", "") or ""), + "season": getattr(sub, "season", None), + "state": state, + "total_episode": getattr(sub, "total_episode", None), + "lack_episode": getattr(sub, "lack_episode", None), + "start_episode": getattr(sub, "start_episode", None), + "quality": str(getattr(sub, "quality", "") or ""), + "resolution": str(getattr(sub, "resolution", "") or ""), + "effect": str(getattr(sub, "effect", "") or ""), + "include": str(getattr(sub, "include", "") or ""), + "exclude": str(getattr(sub, "exclude", "") or ""), + "sites": getattr(sub, "sites", None), + "downloader": str(getattr(sub, "downloader", "") or ""), + "save_path": str(getattr(sub, "save_path", "") or ""), + "best_version": getattr(sub, "best_version", None), + "tmdbid": getattr(sub, "tmdbid", None), + "doubanid": str(getattr(sub, "doubanid", "") or ""), + "last_update": str(getattr(sub, "last_update", "") or ""), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 20)))] + status_label = {"R": "启用", "S": "暂停", "P": "待处理", "N": "完成", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{status_label}订阅。", "items": [], "total": total} + lines = [f"MP 订阅:{status_label},共 {total} 条,展示前 {len(items)} 条:"] + for item in items: + season = f" S{int(item.get('season')):02d}" if item.get("season") else "" + lack = item.get("lack_episode") + lack_text = f"缺 {lack} 集" if lack not in (None, "", 0) else "无缺集" + filters = " / ".join(value for value in [item.get("resolution"), item.get("effect"), item.get("quality")] if value) or "默认规则" + lines.append(f"{item.get('index')}. #{item.get('id')} {item.get('name')} ({item.get('year') or '-'}){season}") + lines.append(f" 状态:{item.get('state') or '-'} | {lack_text} | 规则:{filters} | 下载器:{item.get('downloader') or '默认'}") + lines.append("写入操作需确认:可发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”“删除订阅 1”。") + return {"success": True, "message": "\n".join(lines), "items": items, "total": total, "status": status_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询订阅失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询订阅失败:{exc}", "items": []} + + def _control_subscribe(self, *, action: str, subscribe_id: int) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "操作订阅失败:当前环境缺少 MoviePilot 订阅依赖。"} + sid = int(subscribe_id or 0) + if sid <= 0: + return {"success": False, "message": "操作订阅失败:订阅 ID 无效。"} + action_name = str(action or "").strip().lower() + try: + oper = SubscribeOper() + sub = oper.get(sid) + if not sub: + return {"success": False, "message": f"操作订阅失败:订阅 #{sid} 不存在。"} + old_info = sub.to_dict() if hasattr(sub, "to_dict") else {} + if action_name in {"search", "run"}: + if Scheduler is None: + return {"success": False, "message": "搜索订阅失败:当前环境缺少调度器。"} + Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True}) + return {"success": True, "message": f"已触发订阅搜索:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + if action_name in {"pause", "stop"}: + updated = oper.update(sid, {"state": "S"}) + label = "暂停" + elif action_name in {"resume", "start"}: + updated = oper.update(sid, {"state": "R"}) + label = "恢复" + elif action_name in {"delete", "remove"}: + sub_name = str(getattr(sub, "name", "") or "") + sub_year = str(getattr(sub, "year", "") or "") + oper.delete(sid) + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeDeleted, {"subscribe_id": sid, "subscribe_info": old_info}) + if SubscribeHelper: + SubscribeHelper().sub_done_async({"tmdbid": getattr(sub, "tmdbid", None), "doubanid": getattr(sub, "doubanid", None)}) + return {"success": True, "message": f"成功删除订阅:#{sid} {sub_name} ({sub_year})", "subscribe_id": sid, "action": action_name} + else: + return {"success": False, "message": f"操作订阅失败:不支持的动作 {action}"} + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeModified, { + "subscribe_id": sid, + "old_subscribe_info": old_info, + "subscribe_info": updated.to_dict() if updated and hasattr(updated, "to_dict") else {}, + }) + return {"success": True, "message": f"{label}订阅成功:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作订阅失败:{sid} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作订阅失败:{exc}", "subscribe_id": sid} + + @staticmethod + def _path_preview(value: Any, max_parts: int = 4) -> str: + text = str(value or "").strip() + if not text: + return "" + normalized = text.replace("\\", "/") + parts = [part for part in normalized.split("/") if part] + if len(parts) <= max_parts: + return normalized + prefix = "/" if normalized.startswith("/") else "" + return f"{prefix}.../" + "/".join(parts[-max_parts:]) + + @staticmethod + def _transfer_status_bool(status: str) -> Optional[bool]: + name = str(status or "all").strip().lower() + if name in {"success", "succeeded", "ok", "true", "成功", "已成功"}: + return True + if name in {"failed", "fail", "error", "false", "失败", "错误"}: + return False + return None + + def _query_download_history( + self, + *, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if DownloadHistory is None or DownloadHistoryOper is None: + return {"success": False, "message": "查询下载历史失败:当前环境缺少 MoviePilot 下载历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + title_text = str(title or "").strip() + hash_text = str(hash_value or "").strip() + oper = DownloadHistoryOper() + db = getattr(oper, "_db", None) + if db is None: + records = oper.list_by_page(page=1, count=500) or [] + if title_text: + title_lower = title_text.lower() + records = [ + item for item in records + if title_lower in str(getattr(item, "title", "") or "").lower() + or title_lower in str(getattr(item, "torrent_name", "") or "").lower() + or title_lower in str(getattr(item, "path", "") or "").lower() + ] + if hash_text: + records = [ + item for item in records + if str(getattr(item, "download_hash", "") or "").lower().startswith(hash_text.lower()) + ] + total = len(records) + selected_records = records[(page_num - 1) * page_size:(page_num - 1) * page_size + page_size] + else: + query = db.query(DownloadHistory) + if title_text: + like = f"%{title_text}%" + query = query.filter( + DownloadHistory.title.like(like) + | DownloadHistory.torrent_name.like(like) + | DownloadHistory.path.like(like) + ) + if hash_text: + query = query.filter(DownloadHistory.download_hash.like(f"{hash_text}%")) + query = query.order_by(DownloadHistory.date.desc(), DownloadHistory.id.desc()) + total = query.count() + selected_records = query.offset((page_num - 1) * page_size).limit(page_size).all() + + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=(page_num - 1) * page_size + 1): + task_hash = str(getattr(record, "download_hash", "") or "") + transfer_records = TransferHistory.list_by_hash(download_hash=task_hash) if TransferHistory is not None and task_hash else [] + transfer_success = any(bool(getattr(item, "status", False)) for item in transfer_records or []) + transfer_failed = any(not bool(getattr(item, "status", False)) for item in transfer_records or []) + if transfer_success: + transfer_status = "success" + transfer_status_text = "已入库" + elif transfer_failed: + transfer_status = "failed" + transfer_status_text = "整理失败" + else: + transfer_status = "none" + transfer_status_text = "未见整理记录" + transfer_dest = "" + transfer_error = "" + if transfer_records: + first_transfer = transfer_records[0] + transfer_dest = self._path_preview(getattr(first_transfer, "dest", "")) + transfer_error = str(getattr(first_transfer, "errmsg", "") or "")[:300] + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": str(getattr(record, "type", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash": task_hash, + "download_hash_short": task_hash[:8], + "torrent_name": str(getattr(record, "torrent_name", "") or ""), + "torrent_site": str(getattr(record, "torrent_site", "") or ""), + "username": str(getattr(record, "username", "") or ""), + "channel": str(getattr(record, "channel", "") or ""), + "path_preview": self._path_preview(getattr(record, "path", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + "transfer_status": transfer_status, + "transfer_status_text": transfer_status_text, + "transfer_count": len(transfer_records or []), + "transfer_dest_preview": transfer_dest, + } + if transfer_error and transfer_status == "failed": + item["transfer_error"] = transfer_error + items.append(item) + + title_label = f":{title_text or hash_text}" if title_text or hash_text else "" + if not items: + return { + "success": True, + "message": f"未找到下载历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + } + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"下载历史{title_label}:第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + details = [ + item.get("date") or "-", + f"站点:{item.get('torrent_site') or '-'}", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('download_hash_short') or '-'}", + f"整理:{item.get('transfer_status_text')}", + ] + lines.append(" " + " | ".join(details)) + if item.get("path_preview"): + lines.append(f" 保存:{item.get('path_preview')}") + if item.get("transfer_dest_preview"): + lines.append(f" 入库:{item.get('transfer_dest_preview')}") + if item.get("transfer_error"): + lines.append(f" 整理错误:{item.get('transfer_error')}") + lines.append("说明:这是只读查询,用于追踪下载提交后是否进入整理流程。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载历史失败:{exc}", "items": []} + + def _query_transfer_history( + self, + *, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if TransferHistory is None: + return {"success": False, "message": "查询整理历史失败:当前环境缺少 MoviePilot 整理历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + status_bool = self._transfer_status_bool(status) + title_text = str(title or "").strip() + search_text = title_text + if title_text and jieba is not None: + try: + search_text = "%".join(jieba.cut(title_text, HMM=False)) + except Exception: + search_text = title_text + + if search_text: + records = TransferHistory.list_by_title(title=search_text, page=1, count=-1, status=None) or [] + if status_bool is not None: + records = [item for item in records if bool(getattr(item, "status", False)) is status_bool] + else: + records = TransferHistory.list_by_page(page=1, count=-1, status=status_bool) or [] + + total = len(records) + start = (page_num - 1) * page_size + selected_records = records[start:start + page_size] + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=start + 1): + media_type = str(getattr(record, "type", "") or "") + if media_type_to_agent is not None: + try: + media_type = media_type_to_agent(media_type) + except Exception: + pass + status_ok = bool(getattr(record, "status", False)) + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": media_type, + "category": str(getattr(record, "category", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "mode": str(getattr(record, "mode", "") or ""), + "status": "success" if status_ok else "failed", + "status_text": "成功" if status_ok else "失败", + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash_short": str(getattr(record, "download_hash", "") or "")[:8], + "src_preview": self._path_preview(getattr(record, "src", "")), + "dest_preview": self._path_preview(getattr(record, "dest", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + } + errmsg = str(getattr(record, "errmsg", "") or "").strip() + if errmsg and not status_ok: + item["errmsg"] = errmsg[:300] + items.append(item) + + status_name = str(status or "all").strip().lower() + status_label = "成功" if status_bool is True else "失败" if status_bool is False else "全部" + title_label = f":{title_text}" if title_text else "" + if not items: + return { + "success": True, + "message": f"未找到{status_label}整理历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"整理历史{title_label}:{status_label},第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + label_parts = [ + item.get("status_text") or "-", + item.get("type") or "-", + item.get("mode") or "-", + item.get("date") or "-", + ] + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + lines.append(" " + " | ".join(label_parts)) + if item.get("dest_preview"): + lines.append(f" 目标:{item.get('dest_preview')}") + if item.get("errmsg"): + lines.append(f" 错误:{item.get('errmsg')}") + lines.append("说明:这是只读查询,用于判断下载后是否已经整理入库。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询整理历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询整理历史失败:{exc}", "items": []} + + def _execute_media_subscribe(self, keyword: str, immediate_search: bool) -> str: + if not all([MetaInfo, SubscribeChain]): + return "订阅失败:当前环境缺少 MoviePilot 订阅依赖。" + meta = MetaInfo(keyword) + try: + sid, message = SubscribeChain().add( + title=keyword, + year=meta.year, + mtype=meta.type, + season=meta.begin_season, + username="agentresourceofficer-feishu", + exist_ok=True, + message=False, + ) + if not sid: + return f"订阅失败:{keyword}\n原因:{message}" + lines = [f"已创建订阅:{keyword}", f"订阅ID:{sid}", f"结果:{message}"] + if immediate_search and Scheduler is not None: + 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"[AgentResourceOfficer][Feishu] 订阅媒体失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"订阅失败:{keyword}\n错误:{exc}" + + @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 _set_search_cache(self, cache_key: str, keyword: str, mediainfo: Any, results: List[Any]) -> None: + with self._search_cache_lock: + now = time.time() + expired_keys = [ + key + for key, item in self._search_cache.items() + if now - float((item or {}).get("ts") or 0) > 1800 + ] + for key in expired_keys: + self._search_cache.pop(key, None) + while len(self._search_cache) >= self._search_cache_limit: + oldest_key = min( + self._search_cache, + key=lambda key: float((self._search_cache.get(key) or {}).get("ts") or 0), + ) + self._search_cache.pop(oldest_key, None) + self._search_cache[cache_key] = { + "ts": now, + "keyword": keyword, + "mediainfo": mediainfo, + "results": list(results or []), + } + + 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 _run_p115_manual_transfer_batch(self, paths: List[str], chat_id: str, open_id: str) -> None: + summaries = [self._execute_p115_manual_transfer(path) for path in paths] + self.reply_text(chat_id, open_id, "\n\n".join(item for item in summaries if item)) + + def _run_p115_manual_transfer(self, path: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_p115_manual_transfer(path)) + + def _get_p115_manual_transfer_paths(self) -> List[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + raw = str(config.get("pan_transfer_paths") or "").strip() + return [line.strip() for line in raw.splitlines() if line.strip()] + except Exception as exc: + logger.warning(f"[AgentResourceOfficer][Feishu] 获取待整理目录失败:{exc}") + return [] + + 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 未初始化或未启用。" + result = servicer.monitorlife.once_transfer(path) + summary = self._format_p115_manual_transfer_result(result) + return summary or self._build_p115_manual_transfer_summary(log_path, log_offset, path) or f"刮削完成:{path}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 手动刮削失败:{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 "" + failed_items = result.get("failed_items") or [] + lines = [ + f"刮削完成:{path}", + f"总计:{result.get('total', 0)} 个项目(文件 {result.get('files', 0)},文件夹 {result.get('dirs', 0)})", + f"成功:{result.get('success', 0)} 个", + f"失败:{result.get('failed', 0)} 个", + f"跳过:{result.get('skipped', 0)} 个", + ] + if result.get("error"): + lines.append(f"错误:{result.get('error')}") + if failed_items: + lines.append("失败示例:") + lines.extend(f"- {item}" for item in failed_items[:3]) + if len(failed_items) > 3: + lines.append(f"- 还有 {len(failed_items) - 3} 项未展示") + lines.extend(self._p115_strm_followup_lines(path)) + return "\n".join(lines) + + def _p115_strm_followup_lines(self, path: str) -> List[str]: + hint = self._get_p115_strm_hint_path() or path + return [ + "如需增量生成 STRM,请再发送:生成STRM", + "如需按全部媒体库全量生成,请再发送:全量STRM", + f"如需指定路径全量生成,请再发送:指定路径STRM {hint}", + ] + + def _get_p115_strm_hint_path(self) -> Optional[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + paths = str(config.get("full_sync_strm_paths") or "").strip() + first_line = next((line.strip() for line in paths.splitlines() if line.strip()), "") + if not first_line: + return None + parts = first_line.split("#") + return parts[1].strip() if len(parts) >= 2 and parts[1].strip() else None + except Exception: + return None + + @staticmethod + def _safe_log_offset(log_path: Path) -> int: + try: + return log_path.stat().st_size if log_path.exists() else 0 + except Exception: + 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) + 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 = pattern.search(chunk) + if not match: + return None + summary = ( + f"刮削完成:{path}\n" + f"总计:{match.group('total')} 个项目(文件 {match.group('files')},文件夹 {match.group('dirs')})\n" + f"成功:{match.group('success')} 个\n" + f"失败:{match.group('failed')} 个\n" + f"跳过:{match.group('skipped')} 个" + ) + return summary + "\n" + "\n".join(self._p115_strm_followup_lines(path)) + except Exception: + return None + + def _submit_p115_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if PluginManager is not None: + try: + if not PluginManager().running_plugins.get("P115StrmHelper"): + self.reply_text(chat_id, open_id, "P115StrmHelper 未加载或未启用,无法执行 STRM 命令。") + return + except Exception: + pass + self._submit_moviepilot_command(command_text, chat_id, open_id) + + def _submit_moviepilot_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if eventmanager is None or EventType is None: + self.reply_text(chat_id, open_id, "当前环境缺少 MoviePilot 事件总线,无法转发该命令。") + return + eventmanager.send_event( + EventType.CommandExcute, + {"cmd": command_text, "source": None, "user": open_id or chat_id or "feishu"}, + ) + self.reply_text(chat_id, open_id, f"已接收命令:{command_text}\n任务已提交给 MoviePilot。") + + 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.plugin._extract_first_url(text) + if first_url and (self.plugin._is_115_url(first_url) or self.plugin._is_quark_url(first_url)): + return f"/smart_entry {text}".strip() + + alias_map = self.parse_alias_text(self.command_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_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) + + @staticmethod + def _is_duplicate_event_cross_instance(event_id: str, now: float) -> bool: + try: + _EVENT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _EVENT_CACHE_FILE.touch(exist_ok=True) + with _EVENT_CACHE_FILE.open("r+", 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"[AgentResourceOfficer][Feishu] 跨实例事件去重失败:{exc}") + return False + + def _is_allowed(self, chat_id: str, user_open_id: str) -> bool: + return bool( + self.allow_all + or (chat_id and chat_id in self.allowed_chat_ids) + or (user_open_id and user_open_id in self.allowed_user_ids) + ) + + @staticmethod + def _extract_text(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) + return re.sub(r"\s+", " ", text).strip() + + @staticmethod + def _is_help_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"帮助", "/help", "help"} + + @staticmethod + def _is_menu_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"菜单", "/menu", "menu", "面板", "控制面板"} + + def _build_help_text(self) -> str: + aliases = self.parse_alias_text(self.command_aliases) + alias_text = "\n".join(f"{key} -> {value}" for key, value in aliases.items()) or "未配置别名" + return ( + "可用命令:\n" + f"{', '.join(self.command_whitelist)}\n\n" + "别名:\n" + f"{alias_text}\n\n" + "快捷入口:发送“菜单”可查看可复制的快捷命令。" + ) + + @staticmethod + def _build_menu_text() -> str: + return ( + "快捷菜单\n" + "1. 云盘搜索 片名\n" + "2. 盘搜搜索 片名\n" + "3. 影巢搜索 片名\n" + "4. MP搜索 片名 / PT搜索 片名\n" + "5. 转存 片名(默认 115)\n" + "6. 夸克转存 片名\n" + "7. 下载 片名\n" + "8. 更新检查 片名\n" + "9. 选择 序号 / 详情 序号 / n\n" + "10. 115登录 / 115状态 / 115任务\n" + "11. 影巢签到 / 影巢签到日志" + ) + + @staticmethod + def _cache_key(chat_id: str, open_id: str) -> str: + return f"feishu::{chat_id or ''}::{open_id or ''}" + + @staticmethod + def _brief_response_error(data: Any) -> str: + if not isinstance(data, dict): + return "body=" + code = str(data.get("code") or "").strip() + msg = str(data.get("msg") or data.get("message") or "").strip() + parts: List[str] = [] + if code: + parts.append(f"code={code}") + if msg: + parts.append(f"msg={msg}") + return " ".join(parts) if parts else "body=" + + def reply_text(self, chat_id: str, open_id: str, text: str) -> None: + if not self.reply_enabled or not self.app_id or not self.app_secret: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else open_id + if not receive_id: + return + access_token = self._get_tenant_access_token() + if not access_token or RequestUtils is None: + return + url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={self.reply_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), + } + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[AgentResourceOfficer][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"[AgentResourceOfficer][Feishu] 发送文本失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def reply_qrcode_data_url(self, chat_id: str, 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"[AgentResourceOfficer][Feishu] 解码二维码失败:{exc}") + return + image_key = self._upload_image(image_bytes=image_bytes, file_name="p115-qrcode.png") + if image_key: + self._reply_image(chat_id, open_id, image_key) + + def _upload_image(self, image_bytes: bytes, file_name: str) -> Optional[str]: + if not image_bytes or RequestUtils is None: + return None + access_token = self._get_tenant_access_token() + if not access_token: + return None + response = RequestUtils(headers={"Authorization": f"Bearer {access_token}"}).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("[AgentResourceOfficer][Feishu] 上传图片失败:无响应") + 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"[AgentResourceOfficer][Feishu] 上传图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + return None + return str(((data.get("data") or {}).get("image_key")) or "").strip() or None + + def _reply_image(self, chat_id: str, open_id: str, image_key: str) -> None: + if not image_key or RequestUtils is None: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else 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={self.reply_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("[AgentResourceOfficer][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"[AgentResourceOfficer][Feishu] 发送图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def _get_tenant_access_token(self) -> Optional[str]: + if RequestUtils is None: + return None + 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("[AgentResourceOfficer][Feishu] 获取 tenant_access_token 失败:无响应") + return None + try: + data = response.json() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] token 响应解析失败:{exc}") + return None + token = data.get("tenant_access_token") + expire = int(data.get("expire") or 0) + if not token: + logger.error( + f"[AgentResourceOfficer][Feishu] token 缺失:{self._brief_response_error(data)}" + ) + return None + self._token_cache = {"token": token, "expires_at": now + expire} + return token diff --git a/AgentResourceOfficer/requirements.txt b/AgentResourceOfficer/requirements.txt new file mode 100644 index 0000000..e892782 --- /dev/null +++ b/AgentResourceOfficer/requirements.txt @@ -0,0 +1,3 @@ +requests +cloudscraper +lark-oapi==1.5.3 diff --git a/AgentResourceOfficer/schemas.py b/AgentResourceOfficer/schemas.py new file mode 100644 index 0000000..ace4d1b --- /dev/null +++ b/AgentResourceOfficer/schemas.py @@ -0,0 +1,259 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class HDHiveSearchSessionToolInput(BaseModel): + keyword: str = Field(..., description="要搜索的影片或剧集名称") + media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto") + year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录") + + +class HDHiveSessionPickToolInput(BaseModel): + session_id: str = Field(..., description="上一步搜索返回的会话 ID") + choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + + +class ShareRouteToolInput(BaseModel): + url: str = Field(..., description="115 或夸克分享链接") + path: Optional[str] = Field(default=None, description="目标目录") + access_code: Optional[str] = Field(default=None, description="提取码,可选") + + +class AssistantRouteToolInput(BaseModel): + text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接") + session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录") + mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive") + keyword: Optional[str] = Field(default=None, description="结构化搜索关键词") + url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克") + access_code: Optional[str] = Field(default=None, description="结构化提取码") + media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv") + year: Optional[str] = Field(default=None, description="结构化年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPickToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantHelpToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantSessionStateToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantSessionClearToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantCapabilitiesToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantReadinessToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class FeishuChannelHealthToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPulseToolInput(BaseModel): + pass + + +class AssistantStartupToolInput(BaseModel): + pass + + +class AssistantMaintainToolInput(BaseModel): + execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议") + limit: Optional[int] = Field(default=100, description="单次最多清理多少条") + + +class AssistantToolboxToolInput(BaseModel): + pass + + +class AssistantRequestTemplatesToolInput(BaseModel): + limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制") + names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run") + recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap") + include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略") + + +class AssistantSelfcheckToolInput(BaseModel): + pass + + +class AssistantHistoryToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录") + + +class AssistantExecuteActionToolInput(BaseModel): + name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115") + session: Optional[str] = Field(default="default", description="可选会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: Optional[int] = Field(default=None, description="需要选择编号时传入") + path: Optional[str] = Field(default=None, description="可选目标目录") + keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词") + media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型") + year: Optional[str] = Field(default=None, description="搜索类动作使用的年份") + url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接") + access_code: Optional[str] = Field(default=None, description="可选提取码") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤") + has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话") + stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话") + all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话") + limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数") + plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id") + prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecuteActionsToolInput(BaseModel): + actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body") + session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantWorkflowToolInput(BaseModel): + name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status") + session: Optional[str] = Field(default="default", description="工作流会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + keyword: Optional[str] = Field(default=None, description="搜索关键词") + choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1") + candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号") + resource_choice: Optional[int] = Field(default=None, description="影巢资源编号") + path: Optional[str] = Field(default=None, description="可选目标目录") + url: Optional[str] = Field(default=None, description="分享链接") + access_code: Optional[str] = Field(default=None, description="提取码") + media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv") + mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou") + year: Optional[str] = Field(default=None, description="年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + limit: Optional[int] = Field(default=20, description="推荐数量上限") + dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPreferencesToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好") + preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取") + reset: Optional[bool] = Field(default=False, description="是否重置偏好画像") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecutePlanToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划") + session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPlansToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行") + include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条计划") + + +class AssistantPlansClearToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条") + session: Optional[str] = Field(default=None, description="可选会话名;按会话清理") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行") + all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class AssistantRecoverToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议") + prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段") + limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话") + + +class AssistantSessionsToolInput(BaseModel): + kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要") + + +class AssistantSessionsClearToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话") + kind: Optional[str] = Field(default=None, description="按会话类型批量清理") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话") + stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话") + all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class P115QRCodeStartToolInput(BaseModel): + client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini") + + +class P115QRCodeCheckToolInput(BaseModel): + uid: str = Field(..., description="上一步二维码返回的 uid") + time: str = Field(..., description="上一步二维码返回的 time") + sign: str = Field(..., description="上一步二维码返回的 sign") + client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致") + + +class P115StatusToolInput(BaseModel): + pass + + +class P115PendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话") + + +class P115ResumePendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务") + + +class P115CancelPendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务") diff --git a/AgentResourceOfficer/services/__init__.py b/AgentResourceOfficer/services/__init__.py new file mode 100644 index 0000000..4c1538f --- /dev/null +++ b/AgentResourceOfficer/services/__init__.py @@ -0,0 +1 @@ +"""Service modules for Agent影视助手.""" diff --git a/AgentResourceOfficer/services/hdhive_openapi.py b/AgentResourceOfficer/services/hdhive_openapi.py new file mode 100644 index 0000000..970c5ff --- /dev/null +++ b/AgentResourceOfficer/services/hdhive_openapi.py @@ -0,0 +1,1113 @@ +from datetime import datetime +import base64 +import json +import re +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote +from zoneinfo import ZoneInfo + +import requests + +try: + from app.chain.media import MediaChain +except Exception: + MediaChain = None + +try: + from app.core.config import settings +except Exception: + settings = None + + +class HDHiveOpenApiService: + """Reusable HDHive execution layer for Agent影视助手.""" + + _signin_action_name = "checkIn" + _signin_router_tree = ["", {"children": ["(app)", {"children": ["__PAGE__", {}, None, None]}, None, None]}, None, None, True] + _login_api_candidates = [ + "/api/customer/user/login", + "/api/customer/auth/login", + ] + _login_page = "/login" + _login_action_router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Flogin%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D' + _login_action_fallback = "602b5a3af7ab2e93be6a14001ca83c1be491ccecea" + + def __init__( + self, + *, + api_key: str = "", + base_url: str = "https://hdhive.com", + timeout: int = 30, + ) -> None: + self.api_key = self.normalize_text(api_key) + self.base_url = (self.normalize_text(base_url) or "https://hdhive.com").rstrip("/") + self.timeout = self.safe_int(timeout, 30) + self._login_action_id = "" + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_slug(value: Any) -> str: + return str(value or "").strip().replace("-", "") + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def media_type_text(value: Any) -> str: + if value is None: + return "" + raw = str(getattr(value, "value", value)).strip().lower() + mapping = { + "电影": "movie", + "movie": "movie", + "电视剧": "tv", + "tv": "tv", + } + return mapping.get(raw, raw) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def base_headers(self) -> Dict[str, str]: + return { + "X-API-Key": self.api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + def api_url(self, path: str) -> str: + return f"{self.base_url.rstrip('/')}{path}" + + def tmdb_web_search_url(self, media_type: str, keyword: str) -> str: + query = quote(keyword) + if media_type == "movie": + return f"https://www.themoviedb.org/search/movie?query={query}" + if media_type == "tv": + return f"https://www.themoviedb.org/search/tv?query={query}" + return f"https://www.themoviedb.org/search?query={query}" + + def tmdb_web_search_headers(self) -> Dict[str, str]: + return { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + @staticmethod + def extract_year_from_release(value: Any) -> str: + match = re.search(r"(19|20)\d{2}", str(value or "")) + return match.group(0) if match else "" + + def tmdb_web_search_candidates( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[List[Dict[str, Any]], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + search_order = [media_type] if media_type in {"movie", "tv"} else ["tv", "movie"] + pattern = re.compile( + r'href="/(?Ptv|movie)/(?P\d+)"[^>]*>\s*' + r']*>\s*' + r'(?P<title>[^]*srcset="(?P[^"]*)"[^>]*src="(?P[^"]+)"[^>]*>' + r'.*?(?P[^<]+)', + re.S, + ) + candidates: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + errors: List[str] = [] + for search_type in search_order: + try: + response = requests.get( + self.tmdb_web_search_url(search_type, keyword), + headers=self.tmdb_web_search_headers(), + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + response.raise_for_status() + except Exception as exc: + errors.append(f"{search_type}:{exc}") + continue + html = response.text or "" + for match in pattern.finditer(html): + item_type = self.normalize_text(match.group("media_type")).lower() + tmdb_id = self.normalize_text(match.group("tmdb_id")) + if not tmdb_id or tmdb_id in seen_ids: + continue + item_year = self.extract_year_from_release(match.group("release")) + if year and item_year and item_year != year: + continue + seen_ids.add(tmdb_id) + candidates.append( + { + "title": self.normalize_text(match.group("title")), + "year": item_year, + "media_type": item_type or search_type, + "tmdb_id": tmdb_id, + "poster_path": self.normalize_text(match.group("src")), + } + ) + if len(candidates) >= candidate_limit: + return candidates, "" + return candidates, ";".join(errors) + + def request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any], str, int]: + if not self.api_key: + return False, {}, "未配置影巢 API Key", 400 + + try: + response = requests.request( + method=method.upper(), + url=self.api_url(path), + headers=self.base_headers(), + params=params, + json=payload if payload is not None else None, + timeout=timeout or self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + return False, {}, f"请求异常: {exc}", 0 + + try: + result = response.json() + except Exception: + result = { + "success": False, + "message": response.text[:300] if response.text else f"HTTP {response.status_code}", + "description": "接口未返回有效 JSON", + } + + if response.ok and isinstance(result, dict) and result.get("success", True): + return True, result, "", response.status_code + + message = "" + if isinstance(result, dict): + message = ( + result.get("description") + or result.get("message") + or result.get("code") + or f"HTTP {response.status_code}" + ) + if not message: + message = f"HTTP {response.status_code}" + return False, result if isinstance(result, dict) else {}, message, response.status_code + + def resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: + pan = str(item.get("pan_type") or "").lower() + points = item.get("unlock_points") + try: + points_value = int(points) if points is not None and str(points) != "" else 0 + except Exception: + points_value = 9999 + validate = str(item.get("validate_status") or "").lower() + resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] + sources = [str(v) for v in (item.get("source") or [])] + pan_rank = 0 if pan == "115" else 1 if pan == "quark" else 2 + points_rank = 0 if points_value <= 0 else 1 + validate_rank = 0 if validate in {"valid", ""} else 1 + resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 + source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 + return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) + + async def resolve_candidates_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[bool, Dict[str, Any], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + type_filter = "" if media_type in {"auto", "all", "*"} else media_type + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + + if not keyword: + return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" + if type_filter and type_filter not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie、tv 或 auto", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie、tv 或 auto" + chain_error = "" + medias = [] + if MediaChain is None: + chain_error = "MoviePilot MediaChain 不可用" + else: + try: + _, medias = await MediaChain().async_search(title=keyword) + except Exception as exc: + chain_error = f"TMDB 解析失败: {exc}" + try: + medias = list(medias or []) + except Exception: + medias = [] + + candidates: List[Dict[str, Any]] = [] + for media in medias: + item_type = self.media_type_text(getattr(media, "type", "")) + item_year = self.normalize_text(getattr(media, "year", "")) + if type_filter and item_type and item_type != type_filter: + continue + if year and item_year and item_year != year: + continue + tmdb_id = getattr(media, "tmdb_id", None) + if not tmdb_id: + continue + candidates.append( + { + "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", + "year": item_year, + "media_type": item_type or type_filter or "movie", + "tmdb_id": tmdb_id, + "poster_path": getattr(media, "poster_path", "") or "", + } + ) + if len(candidates) >= candidate_limit: + break + + fallback_used = False + fallback_message = "" + if not candidates: + web_candidates, web_error = self.tmdb_web_search_candidates( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if web_candidates: + candidates = web_candidates + fallback_used = True + else: + fallback_message = web_error + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(candidates), + "status_code": 200 if candidates else 404, + "message": "success" if candidates else "未找到可用于影巢搜索的 TMDB 候选", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "meta": { + "total": len(candidates), + "candidate_source": "tmdb_web_search" if fallback_used else "mediainfo_chain", + }, + } + if fallback_used: + result["fallback_reason"] = chain_error or "MediaChain 未返回候选" + elif chain_error: + result["chain_warning"] = chain_error + if not candidates and fallback_message: + result["fallback_error"] = fallback_message + if chain_error: + result["message"] = f"{chain_error};TMDB 网页搜索兜底也未命中" + elif not candidates and chain_error: + result["message"] = chain_error + return bool(candidates), result, result["message"] + + def search_resources(self, media_type: str, tmdb_id: str) -> Tuple[bool, Dict[str, Any], str]: + media_type = (media_type or "").strip().lower() + tmdb_id = self.normalize_text(tmdb_id) + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" + if not tmdb_id: + return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" + + ok, payload, message, status_code = self.request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"media_type": media_type, "tmdb_id": tmdb_id}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + return ok, result, message + + async def search_resources_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + result_limit: int = 12, + ) -> Tuple[bool, Dict[str, Any], str]: + result_limit = min(50, max(1, self.safe_int(result_limit, 12))) + ok, candidate_result, candidate_message = await self.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if not ok: + result = dict(candidate_result) + result["data"] = [] + return False, result, candidate_message + candidates = candidate_result.get("candidates") or [] + + merged_items: List[Dict[str, Any]] = [] + seen_slugs: set[str] = set() + last_status = 200 + + for candidate in candidates: + ok, payload, message = self.search_resources( + media_type=candidate["media_type"] or media_type, + tmdb_id=str(candidate["tmdb_id"]), + ) + last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status + if not ok: + continue + for resource in payload.get("data") or []: + slug = self.normalize_slug(resource.get("slug")) + if not slug or slug in seen_slugs: + continue + seen_slugs.add(slug) + annotated = dict(resource) + annotated["matched_tmdb_id"] = candidate["tmdb_id"] + annotated["matched_title"] = candidate["title"] + annotated["matched_year"] = candidate["year"] + merged_items.append(annotated) + + merged_items.sort(key=self.resource_sort_key) + merged_items = merged_items[:result_limit] + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(merged_items), + "status_code": last_status, + "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "data": merged_items, + "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, + } + return bool(merged_items), result, result["message"] + + def unlock_resource(self, slug: str) -> Tuple[bool, Dict[str, Any], str]: + slug = self.normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self.request( + "POST", + "/api/open/resources/unlock", + payload={"slug": slug}, + ) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_me(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/me") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_usage_today(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/usage/today") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_weekly_free_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/vip/weekly-free-quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def perform_checkin( + self, + *, + is_gambler: Optional[bool] = None, + trigger: str = "手动", + ) -> Tuple[bool, Dict[str, Any], str]: + gambler_mode = bool(is_gambler) + payload = {"is_gambler": True} if gambler_mode else None + ok, result_payload, message, status_code = self.request("POST", "/api/open/checkin", payload=payload) + data = result_payload.get("data") if isinstance(result_payload, dict) else {} + checked_in = bool((data or {}).get("checked_in")) if ok else False + if ok: + status_text = "签到成功" if checked_in else "今日已签到" + else: + status_text = "签到失败" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "trigger": trigger, + "is_gambler": gambler_mode, + "status": status_text, + "message": (data or {}).get("message") or result_payload.get("message") or message, + "data": data or {}, + } + return ok, result, message + + @staticmethod + def parse_cookie_string(cookie_str: Optional[str]) -> Dict[str, str]: + cookies: Dict[str, str] = {} + if not cookie_str: + return cookies + for cookie_item in str(cookie_str).split(";"): + if "=" in cookie_item: + name, value = cookie_item.strip().split("=", 1) + cookies[name] = value + return cookies + + @staticmethod + def _decode_token_user_id(token: str) -> str: + if not token or "." not in token: + return "" + try: + payload = token.split(".", 2)[1] + padding = "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8", "ignore") + data = json.loads(decoded) + return str(data.get("user_id") or data.get("sub") or data.get("id") or "").strip() + except Exception: + return "" + + @staticmethod + def _cookie_string_from_mapping(cookies: Dict[str, str]) -> str: + token_cookie = str((cookies or {}).get("token") or "").strip() + csrf_cookie = str((cookies or {}).get("csrf_access_token") or "").strip() + if not token_cookie: + return "" + cookie_items = [f"token={token_cookie}"] + if csrf_cookie: + cookie_items.append(f"csrf_access_token={csrf_cookie}") + return "; ".join(cookie_items) + + @classmethod + def _extract_login_action_id_from_text(cls, text: str) -> str: + patterns = [ + r'next-action"\s*:\s*"([a-fA-F0-9]{16,64})"', + r'name="next-action"\s+value="([a-fA-F0-9]{16,64})"', + r'createServerReference\("([a-f0-9]{40,})"[^\\n]+?"login"\)', + ] + for pattern in patterns: + match = re.search(pattern, text or "") + if match: + return str(match.group(1) or "").strip() + return "" + + def _discover_login_action_id(self, warm_text: str, scraper: Any) -> str: + if self._login_action_id: + return self._login_action_id + + action_id = self._extract_login_action_id_from_text(warm_text) + if action_id: + self._login_action_id = action_id + return action_id + + script_paths = re.findall( + r']+src="([^"]+/app/\(auth\)/login/page-[^"]+\.js)"', + warm_text or "", + ) + for script_path in script_paths: + script_url = script_path if script_path.startswith("http") else f"{self.base_url}{script_path}" + try: + resp = scraper.get( + script_url, + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Referer": f"{self.base_url}{self._login_page}", + "Accept": "*/*", + }, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + action_id = self._extract_login_action_id_from_text(getattr(resp, "text", "") or "") + if action_id: + self._login_action_id = action_id + return action_id + + self._login_action_id = self._login_action_fallback + return self._login_action_id + + @staticmethod + def _parse_server_action_error(response_text: str) -> str: + if not response_text: + return "" + try: + for line in response_text.splitlines(): + line = line.strip() + if not line.startswith("1:"): + continue + payload = json.loads(line[2:]) + error = payload.get("error") or {} + message = str(error.get("message") or "").strip() + description = str(error.get("description") or "").strip() + if message or description: + return f"{message} ({description})" if description and description != message else (message or description) + except Exception: + return "" + return "" + + def login_for_cookie(self, *, username: str, password: str) -> Tuple[bool, str, str]: + username = self.normalize_text(username) + password = self.normalize_text(password) + if not username or not password: + return False, "", "未配置影巢用户名或密码,无法自动刷新 Cookie" + + try: + import cloudscraper + scraper = cloudscraper.create_scraper() + except Exception: + scraper = requests + + login_url = f"{self.base_url}{self._login_page}" + warm_text = "" + try: + resp_warm = scraper.get( + login_url, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + warm_text = getattr(resp_warm, "text", "") or "" + except Exception: + pass + if "系统维护中" in warm_text or "maintenance" in warm_text.lower(): + return False, "", "影巢站点当前处于维护页,暂时无法自动登录刷新 Cookie" + + for path in self._login_api_candidates: + url = f"{self.base_url}{path}" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "application/json", + } + payload = {"username": username, "password": password} + try: + resp = scraper.post( + url, + headers=headers, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + + cookies_dict: Dict[str, str] = {} + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "API 登录成功" + + try: + data = resp.json() + except Exception: + data = {} + meta = (data.get("meta") or {}) if isinstance(data, dict) else {} + access_token = str(meta.get("access_token") or "").strip() + refresh_token = str(meta.get("refresh_token") or "").strip() + if access_token: + cookie_items = [f"token={access_token}"] + if refresh_token: + cookie_items.append(f"refresh_token={refresh_token}") + return True, "; ".join(cookie_items), "API 登录成功" + + action_id = self._discover_login_action_id(warm_text, scraper) + if action_id: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "text/plain;charset=UTF-8", + "next-action": action_id, + "next-router-state-tree": self._login_action_router_state, + } + body = json.dumps([{"username": username, "password": password}, "/"], separators=(",", ":")) + try: + resp = scraper.post( + login_url, + headers=headers, + data=body, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + resp = None + server_action_message = f"Server Action 登录请求异常: {exc}" + else: + server_action_message = "" + if resp is not None: + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "Server Action 登录成功" + action_error = self._parse_server_action_error(getattr(resp, "text", "") or "") + if action_error: + server_action_message = action_error + else: + server_action_message = "未解析到登录 Action" + + try: + from playwright.sync_api import sync_playwright + except Exception: + return False, "", server_action_message or "自动登录失败,且 Playwright 不可用" + + try: + proxy = None + try: + proxy_config = getattr(settings, "PROXY", None) if settings is not None else None + server = (proxy_config or {}).get("http") or (proxy_config or {}).get("https") + if server: + proxy = {"server": server} + except Exception: + proxy = None + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True, proxy=proxy) if proxy else pw.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + page.goto(login_url, wait_until="domcontentloaded", timeout=self.timeout * 1000) + for selector in [ + "input[name='username']", + "input[name='email']", + "input[type='email']", + "input[placeholder*='邮箱']", + "input[placeholder*='email']", + "input[placeholder*='用户名']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, username) + break + except Exception: + continue + for selector in [ + "input[name='password']", + "input[type='password']", + "input[placeholder*='密码']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, password) + break + except Exception: + continue + try: + button = ( + page.query_selector("button[type='submit']") + or page.query_selector("button:has-text('登录')") + or page.query_selector("button:has-text('Login')") + ) + if button: + button.click() + else: + page.keyboard.press("Enter") + except Exception: + page.keyboard.press("Enter") + try: + page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + cookies = context.cookies() + context.close() + browser.close() + except Exception as exc: + return False, "", f"Playwright 自动登录失败: {exc}" + + cookie_map = {str(item.get("name") or ""): str(item.get("value") or "") for item in cookies or []} + cookie_string = self._cookie_string_from_mapping(cookie_map) + if cookie_string: + return True, cookie_string, "Playwright 登录成功" + return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie" + + @classmethod + def _build_signin_tree_header(cls) -> str: + return quote(json.dumps(cls._signin_router_tree, separators=(",", ":"))) + + @staticmethod + def _build_signin_action_body(is_gambler: bool) -> str: + return json.dumps([bool(is_gambler)], separators=(",", ":")) + + @staticmethod + def _normalize_response_text(text: str) -> str: + if not text: + return "" + if "ä½" in text or "å·²" in text or "签到" in text: + try: + return text.encode("latin1", errors="ignore").decode("utf-8", errors="ignore") + except Exception: + return text + return text + + @classmethod + def _extract_signin_action_id_from_chunk(cls, chunk_text: str) -> str: + if not chunk_text: + return "" + patterns = [ + rf'createServerReference[\s\S]{{0,120}}?\("([a-f0-9]{{32,}})"[\s\S]{{0,1200}}?"{re.escape(cls._signin_action_name)}"', + rf'([a-f0-9]{{32,}}).{{0,240}}?"{re.escape(cls._signin_action_name)}"', + ] + for pattern in patterns: + match = re.search(pattern, chunk_text, re.S) + if match: + return match.group(1) + return "" + + @classmethod + def _parse_signin_action_response(cls, text: str) -> Tuple[bool, str]: + text = cls._normalize_response_text(text) + if not text: + return False, "签到响应为空" + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + _, payload = line.split(":", 1) + try: + data = json.loads(payload) + except Exception: + continue + if not isinstance(data, dict): + continue + if isinstance(data.get("response"), dict): + data = data["response"] + error = data.get("error") + if isinstance(error, dict): + message = cls._normalize_response_text(error.get("description") or error.get("message") or "签到失败") + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return False, message + message = cls._normalize_response_text(data.get("message") or data.get("description")) + success = data.get("success") + if message: + if success is False: + return False, message + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return True, message + return False, "签到响应格式异常" + + def _discover_signin_action_id(self, cookies: Dict[str, str], token: str, referer: str) -> str: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + try: + home_resp = requests.get( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception: + return "" + if home_resp.status_code != 200: + return "" + html = home_resp.text or "" + chunk_paths = list(dict.fromkeys(re.findall(r'/_next/static/chunks/[A-Za-z0-9._-]+\.js', html))) + for chunk_path in chunk_paths: + try: + chunk_resp = requests.get( + url=f"{self.base_url}{chunk_path}", + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/javascript,text/javascript,*/*;q=0.1", + "Connection": "close", + }, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=min(self.timeout, 20), + verify=False, + ) + except Exception: + continue + if chunk_resp.status_code != 200: + continue + action_id = self._extract_signin_action_id_from_chunk(chunk_resp.text or "") + if action_id: + return action_id + return "" + + def perform_legacy_web_checkin( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 400, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": "缺少可用的影巢网页 Cookie", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + if csrf_token: + headers["X-CSRF-TOKEN"] = csrf_token + + payload = {"is_gambler": True} if is_gambler else {} + try: + response = requests.post( + url=f"{self.base_url}/api/customer/user/checkin", + headers=headers, + cookies=cookies, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + verify=False, + ) + except Exception as exc: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"网页签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + try: + body = response.json() + except Exception: + body = {} + + message = "" + if isinstance(body, dict): + message = str(body.get("description") or body.get("message") or body.get("code") or "").strip() + if not message: + message = str(response.text or f"HTTP {response.status_code}").strip()[:200] + + lowered = message.lower() + already_signed = "已经签到" in message or "签到过" in message or "明天再来" in message + success = bool(response.status_code < 400 and (not isinstance(body, dict) or body.get("success") is not False)) + if already_signed: + success = True + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if already_signed else "签到成功" if success else "签到失败", + "message": message or ("签到成功" if success else f"HTTP {response.status_code}"), + "data": body if isinstance(body, dict) else {}, + "source": "hdhive_web_legacy", + } + return success, result, result["message"] + + def perform_web_checkin_with_fallback( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + legacy_ok, legacy_result, legacy_message = self.perform_legacy_web_checkin( + cookie_string=cookie_string, + is_gambler=is_gambler, + trigger=trigger, + ) + if legacy_ok: + return legacy_ok, legacy_result, legacy_message + + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + return legacy_ok, legacy_result, legacy_message + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + action_id = self._discover_signin_action_id(cookies, token, referer) + if not action_id: + message = "旧版网页签到接口不可用,且未能解析当前站点签到 Action;请更新影巢网页 Cookie 后重试" + legacy_result["message"] = message + legacy_result["status"] = "签到失败" + legacy_result["source"] = "hdhive_web_next_action" + return False, legacy_result, message + + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Content-Type": "text/plain;charset=UTF-8", + "Origin": self.base_url, + "Referer": f"{self.base_url}/", + "Authorization": f"Bearer {token}", + "next-action": action_id, + "next-router-state-tree": self._build_signin_tree_header(), + } + if csrf_token: + headers["x-csrf-token"] = csrf_token + + try: + response = requests.post( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + data=self._build_signin_action_body(is_gambler), + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception as exc: + return False, { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"Next Action 签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_next_action", + }, f"Next Action 签到请求异常: {exc}" + + redirect_target = str(response.headers.get("x-action-redirect") or response.headers.get("Location") or "").strip() + if "/login" in redirect_target: + message = "影巢网页 Cookie 已失效,请先在 HDHiveDailySign 中更新 Cookie 或重新自动登录" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {"redirect": redirect_target}, + "source": "hdhive_web_next_action", + } + return False, result, message + if response.status_code in (404, 405): + message = f"影巢网页签到入口暂不可用或 Cookie 已失效(HTTP {response.status_code}),请更新本插件里的影巢网页 Cookie 后重试" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return False, result, message + + response_text = "" + try: + response_text = response.content.decode("utf-8", errors="ignore") + except Exception: + response_text = response.text or "" + success, message = self._parse_signin_action_response(response_text) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if "已经签到" in message or "签到过" in message or "明天再来" in message else "签到成功" if success else "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return success, result, message diff --git a/AgentResourceOfficer/services/p115_transfer.py b/AgentResourceOfficer/services/p115_transfer.py new file mode 100644 index 0000000..536f9b5 --- /dev/null +++ b/AgentResourceOfficer/services/p115_transfer.py @@ -0,0 +1,823 @@ +import importlib +import re +import sys +from base64 import b64encode +from dataclasses import asdict, is_dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from zoneinfo import ZoneInfo + +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None + + +class P115TransferService: + """Reusable 115 share transfer execution layer for Agent影视助手.""" + + CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"} + QR_CLIENT_TYPES = { + "web", + "android", + "115android", + "ios", + "115ios", + "alipaymini", + "wechatmini", + "115ipad", + "tv", + "qandroid", + } + + def __init__( + self, + *, + default_target_path: str = "/待整理", + cookie: str = "", + prefer_direct: bool = True, + ) -> None: + self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理" + self.cookie = self.normalize_text(cookie) + self.prefer_direct = bool(prefer_direct) + + def set_cookie(self, cookie: str = "") -> None: + self.cookie = self.normalize_text(cookie) + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def _ensure_helper_import_paths() -> None: + candidate_dirs = [] + try: + plugin_parent = Path(__file__).resolve().parents[2] + candidate_dirs.append(str(plugin_parent)) + except Exception: + pass + try: + app_plugins_spec = importlib.util.find_spec("app.plugins") + for location in app_plugins_spec.submodule_search_locations or []: + candidate_dirs.append(str(Path(location).resolve())) + except Exception: + pass + for base in candidate_dirs: + path = Path(base) + if path.exists(): + text = str(path) + if text not in sys.path: + sys.path.append(text) + + @staticmethod + def is_115_share_url(url: str) -> bool: + host = urlparse(url).netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + def ensure_115_share_url(self, url: str, access_code: str = "") -> str: + clean_url = self.normalize_text(url) + if not clean_url: + return "" + access_code = self.normalize_text(access_code) + parsed = urlparse(clean_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if access_code and "password" not in query: + query["password"] = access_code + clean_url = urlunparse(parsed._replace(query=urlencode(query))) + return clean_url + + @staticmethod + def _extract_115_payload(url: str) -> Tuple[str, str]: + clean_url = str(url or "").strip() + if not clean_url: + return "", "" + try: + from p115client.util import share_extract_payload + + payload = share_extract_payload(clean_url) or {} + return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip() + except Exception: + parsed = urlparse(clean_url) + share_code = "" + match = re.search(r"/s/([^/?#]+)", parsed.path or "") + if match: + share_code = match.group(1).strip() + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip() + return share_code, receive_code + + @classmethod + def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]: + pairs: Dict[str, str] = {} + for part in cls.normalize_text(cookie).strip(";").split(";"): + if "=" not in part: + continue + key, value = part.split("=", 1) + key = key.strip() + value = value.strip() + if key and value: + pairs[key] = value + return pairs + + @classmethod + def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]: + if not cls.normalize_text(cookie): + return False, "未配置独立 115 Cookie" + pairs = cls.parse_cookie_pairs(cookie) + missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs)) + if missing: + return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie" + return True, "" + + def cookie_state(self) -> Dict[str, Any]: + configured = bool(self.normalize_text(self.cookie)) + pairs = self.parse_cookie_pairs(self.cookie) + cookie_keys = sorted(pairs.keys()) + if not configured: + return { + "configured": False, + "valid": False, + "mode": "none", + "cookie_keys": [], + "message": "未配置独立 115 会话,将优先复用 P115StrmHelper 已登录客户端", + } + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + return { + "configured": True, + "valid": cookie_ok, + "mode": "client_cookie" if cookie_ok else "invalid_cookie", + "cookie_keys": cookie_keys, + "message": "" if cookie_ok else cookie_message, + } + + @classmethod + def normalize_qrcode_client_type(cls, client_type: Any) -> str: + text = cls.normalize_text(client_type).lower() + return text if text in cls.QR_CLIENT_TYPES else "alipaymini" + + @staticmethod + def jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool, list, dict)): + return value + if is_dataclass(value): + return asdict(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + if hasattr(value, "__dict__"): + return {k: v for k, v in vars(value).items() if not k.startswith("_")} + return str(value) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + @staticmethod + def _safe_int(value: Any, default: int = -1) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _response_error(resp: Any) -> str: + if not isinstance(resp, dict): + return str(resp or "") + for key in ("error", "message", "msg", "errno"): + value = resp.get(key) + if value not in (None, ""): + return str(value) + return str(resp) + + @classmethod + def _is_already_saved_message(cls, value: Any) -> bool: + text = cls.normalize_text(value) + return any( + marker in text + for marker in ( + "已经转存", + "已转存", + "已经保存", + "已保存", + "already", + "exist", + ) + ) + + @staticmethod + def _response_ok(resp: Any) -> bool: + if not isinstance(resp, dict): + return False + if resp.get("state") is True: + return True + if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0): + return True + if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0): + return True + return False + + @staticmethod + def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]: + try: + P115TransferService._ensure_helper_import_paths() + from app.plugins.p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + try: + P115TransferService._ensure_helper_import_paths() + from p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + pass + return {} + + @staticmethod + def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]: + if PluginManager is None: + return None, "PluginManager 不可用" + try: + plugin = PluginManager().running_plugins.get("P115StrmHelper") + except Exception as exc: + return None, f"读取 P115StrmHelper 运行态失败: {exc}" + if not plugin: + return None, "P115StrmHelper 未加载" + + module_names = [] + plugin_module = getattr(plugin.__class__, "__module__", "") or "" + if plugin_module: + module_names.append(f"{plugin_module}.service") + module_names.extend( + [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ] + ) + + for module_name in module_names: + try: + self._ensure_helper_import_paths() + module = sys.modules.get(module_name) or importlib.import_module(module_name) + servicer = getattr(module, "servicer", None) + if servicer is not None: + return servicer, None + except Exception: + continue + return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer" + + def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + return None, helper_error or "P115StrmHelper 未加载" + client = getattr(servicer, "client", None) + if not client: + return None, "P115StrmHelper 未登录 115 或客户端不可用" + return client, "p115strmhelper_client" + + def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]: + if not self.cookie: + return None, "未配置独立 115 Cookie" + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + if not cookie_ok: + return None, cookie_message + try: + from p115client import P115Client + + return P115Client( + self.cookie, + check_for_relogin=False, + ensure_cookies=False, + console_qrcode=False, + ), "direct_cookie" + except Exception as exc: + return None, f"独立 115 Cookie 初始化失败: {exc}" + + @classmethod + def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_token() + check_response(resp) + resp_info = resp.get("data", {}) if isinstance(resp, dict) else {} + uid = str(resp_info.get("uid") or "") + qrcode_time = str(resp_info.get("time") or "") + sign = str(resp_info.get("sign") or "") + qrcode = P115Client.login_qrcode(uid) + if not isinstance(qrcode, (bytes, bytearray)): + return False, {}, "获取二维码失败:返回内容类型异常" + return True, { + "uid": uid, + "time": qrcode_time, + "sign": sign, + "client_type": final_client_type, + "tips": "请使用 115 App 扫码登录", + "qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}", + }, "success" + except Exception as exc: + return False, {}, f"获取 115 登录二维码失败: {exc}" + + @classmethod + def check_qrcode_login( + cls, + *, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + payload = {"uid": uid, "time": time_value, "sign": sign} + resp = P115Client.login_qrcode_scan_status(payload) + if not isinstance(resp, dict): + return False, {}, "检查二维码状态失败:返回内容类型异常" + check_response(resp) + status_code = (resp.get("data") or {}).get("status") + except Exception as exc: + return False, {}, f"检查二维码状态失败: {exc}" + + if status_code == 0: + return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码" + if status_code == 1: + return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认" + if status_code == -1 or status_code is None: + return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期" + if status_code == -2: + return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录" + if status_code != 2: + return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}" + + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type) + if not isinstance(resp, dict): + return False, {}, "获取登录结果失败:返回内容类型异常" + check_response(resp) + except Exception as exc: + return False, {}, f"获取登录结果失败: {exc}" + + cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None + if not isinstance(cookie_data, dict): + return False, {}, "登录成功但未返回 Cookie" + cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip() + cookie_ok, cookie_message = cls.validate_client_cookie(cookie) + if not cookie_ok: + return False, {}, cookie_message + return True, { + "status": "success", + "client_type": final_client_type, + "cookie": cookie, + "cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()), + }, "登录成功" + + def get_direct_client(self) -> Tuple[Optional[Any], str, str]: + client, source = self._get_cookie_p115_client() + if client: + return client, source, "" + cookie_error = source + client, source = self._get_loaded_p115_client() + if client: + return client, source, "" + return None, "none", source or cookie_error + + @classmethod + def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]: + last_error = "" + for module_name in [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ]: + try: + cls._ensure_helper_import_paths() + service_module = importlib.import_module(module_name) + servicer = getattr(service_module, "servicer", None) + if servicer is not None: + return servicer, None + last_error = f"{module_name} 未暴露 servicer" + except Exception as exc: + last_error = f"{module_name} 导入失败: {exc}" + return None, last_error or "P115StrmHelper 未安装或无法导入" + + def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + servicer, helper_error = self._import_servicer_fallback() + if not servicer: + return None, f"P115StrmHelper 未安装或无法导入: {helper_error}" + if not servicer: + return None, "P115StrmHelper 未初始化" + if not getattr(servicer, "client", None): + return None, "P115StrmHelper 未登录 115 或客户端不可用" + helper = getattr(servicer, "sharetransferhelper", None) + if not helper: + return None, "P115StrmHelper 分享转存模块不可用" + return helper, None + + def health(self) -> Tuple[bool, Dict[str, Any], str]: + cookie_state = self.cookie_state() + direct_client, direct_source, direct_error = self.get_direct_client() + direct_ready = direct_client is not None + helper, helper_error = self.get_share_helper() + helper_ready = bool(helper and not helper_error) + ready = direct_ready or helper_ready + message = "" if ready else direct_error or helper_error or "115 转存不可用" + return ready, { + "ready": ready, + "direct_ready": direct_ready, + "direct_source": direct_source if direct_ready else "", + "direct_message": "" if direct_ready else direct_error, + "helper_ready": helper_ready, + "helper_message": "" if helper_ready else helper_error, + "cookie_state": cookie_state, + "message": message or "success", + }, message + + def _get_or_create_path_cid(self, client: Any, path: str) -> int: + return self._get_path_cid(client, path, create=True) + + def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int: + target_path = self.normalize_pan_path(path) or "/" + if target_path == "/": + return 0 + get_kwargs = self._p115_request_kwargs(app=False) + mkdir_kwargs = self._p115_request_kwargs(app=True) + try: + resp = client.fs_dir_getid(target_path, **get_kwargs) + pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1) + if pid > 0: + return pid + except Exception: + pass + + if not create: + return -1 + + try: + resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs) + cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1) + if cid >= 0: + return cid + if self._response_ok(resp): + cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1) + if cid >= 0: + return cid + raise RuntimeError(self._response_error(resp)) + except Exception as exc: + raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc + + def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "path": target_path, + "items": [], + "file_count": 0, + "folder_count": 0, + "removed_count": 0, + "message": "", + } + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + result["direct_source"] = source + return False, result, result["message"] + + cid = self._get_path_cid(client, target_path, create=False) + if cid < 0: + result["ok"] = True + result["direct_source"] = source + result["message"] = "115 默认目录不存在,视为空目录" + return True, result, result["message"] + + payload = { + "cid": int(cid), + "limit": 1150, + "offset": 0, + "show_dir": 1, + "cur": 1, + "count_folders": 1, + } + items: list[dict[str, Any]] = [] + total = 0 + try: + while True: + resp = client.fs_files(payload, **self._p115_request_kwargs(app=False)) + if not isinstance(resp, dict): + result["message"] = "读取 115 目录失败:返回内容异常" + result["direct_source"] = source + return False, result, result["message"] + batch = resp.get("data") or [] + total = self._safe_int(resp.get("count"), total) + for entry in batch: + if not isinstance(entry, dict): + continue + fid = self._safe_int(entry.get("fid"), -1) + item_cid = self._safe_int(entry.get("cid"), -1) + is_dir = fid < 0 + item_id = item_cid if is_dir else fid + if item_id < 0: + continue + items.append( + { + "id": item_id, + "name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")), + "is_dir": is_dir, + "type": "folder" if is_dir else "file", + "raw": entry, + } + ) + payload["offset"] = int(payload["offset"]) + len(batch) + if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total: + break + except Exception as exc: + result["message"] = f"读取 115 目录失败: {exc}" + result["direct_source"] = source + return False, result, result["message"] + + file_count = len([item for item in items if not item.get("is_dir")]) + folder_count = len([item for item in items if item.get("is_dir")]) + result.update( + { + "ok": True, + "direct_source": source, + "cid": cid, + "items": items, + "file_count": file_count, + "folder_count": folder_count, + "message": "success", + } + ) + return True, result, "success" + + def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + client, source, client_error = self.get_direct_client() + result = { + "ok": False, + "direct_source": source, + "removed_count": 0, + "message": "", + } + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + return False, result, result["message"] + + ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0] + if not ids: + result.update({"ok": True, "message": "115 默认目录当前层已是空目录"}) + return True, result, result["message"] + + try: + resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"删除 115 目录内容失败: {exc}" + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "删除 115 目录内容失败" + result["raw"] = self.jsonable(resp) + return False, result, result["message"] + + result.update( + { + "ok": True, + "removed_count": len(ids), + "message": "115 默认目录已清空当前层", + "raw": self.jsonable(resp), + } + ) + return True, result, result["message"] + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path) + if not listed_ok: + return False, listed_result, listed_message + + items = listed_result.get("items") or [] + if not items: + listed_result["message"] = "115 默认目录当前层已是空目录" + return True, listed_result, listed_result["message"] + + delete_ok, delete_result, delete_message = self.delete_items(items) + merged = dict(listed_result) + merged.update( + { + "ok": delete_ok, + "removed_count": delete_result.get("removed_count", 0), + "direct_source": delete_result.get("direct_source", listed_result.get("direct_source")), + "delete_raw": delete_result.get("raw"), + "message": delete_message, + } + ) + return delete_ok, merged, delete_message + + def transfer_share_direct( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "strategy": "direct", + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + share_code, receive_code = self._extract_115_payload(share_url) + if not share_code or not receive_code: + result["message"] = "解析 115 分享链接失败,缺少分享码或提取码" + return False, result, result["message"] + + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 直转客户端" + result["data"] = {"direct_source": source} + return False, result, result["message"] + + try: + parent_id = self._get_or_create_path_cid(client, transfer_path) + except Exception as exc: + result["message"] = str(exc) + result["data"] = {"direct_source": source} + return False, result, result["message"] + + payload = { + "share_code": share_code, + "receive_code": receive_code, + "file_id": 0, + "cid": int(parent_id), + "is_check": 0, + } + try: + resp = client.share_receive(payload, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"调用 115 直转接口失败: {exc}" + result["data"] = {"direct_source": source, "parent_id": parent_id} + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "115 直转失败" + result["data"] = { + "direct_source": source, + "parent_id": parent_id, + "raw": self.jsonable(resp), + } + if self._is_already_saved_message(result["message"]): + result["ok"] = True + result["message"] = "115 直转已存在" + return True, result, result["message"] + return False, result, result["message"] + + result.update( + { + "ok": True, + "message": "115 直转成功", + "data": { + "direct_source": source, + "share_code": share_code, + "receive_code": receive_code, + "save_parent": transfer_path, + "parent_id": parent_id, + "raw": self.jsonable(resp), + }, + } + ) + return True, result, result["message"] + + def transfer_share( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + if self.prefer_direct: + direct_ok, direct_result, direct_message = self.transfer_share_direct( + url=share_url, + access_code=access_code, + path=transfer_path, + trigger=trigger, + ) + if direct_ok: + return True, direct_result, direct_message + result["data"]["direct_fallback"] = direct_result + + helper, helper_error = self.get_share_helper() + if helper_error or not helper: + direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message") + result["message"] = helper_error or direct_error or "P115StrmHelper 不可用" + return False, result, result["message"] + + try: + transfer_result = helper.add_share_115( + share_url, + notify=False, + pan_path=transfer_path, + ) + except Exception as exc: + result["message"] = f"调用 P115StrmHelper 转存失败: {exc}" + return False, result, result["message"] + + if not transfer_result or not transfer_result[0]: + error_message = "" + if isinstance(transfer_result, tuple): + if len(transfer_result) > 2: + error_message = self.normalize_text(transfer_result[2]) + elif len(transfer_result) > 1: + error_message = self.normalize_text(transfer_result[1]) + if self._is_already_saved_message(error_message): + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存已存在", + "data": {"raw": self.jsonable(transfer_result)}, + } + ) + return True, result, result["message"] + result["message"] = error_message or "115 转存失败" + result["data"] = {"raw": self.jsonable(transfer_result)} + return False, result, result["message"] + + media_info = transfer_result[1] if len(transfer_result) > 1 else None + save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path + parent_id = transfer_result[3] if len(transfer_result) > 3 else None + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存成功", + "data": { + "media_info": self.jsonable(media_info), + "save_parent": save_parent, + "parent_id": parent_id, + }, + } + ) + return True, result, result["message"] diff --git a/AgentResourceOfficer/services/quark_transfer.py b/AgentResourceOfficer/services/quark_transfer.py new file mode 100644 index 0000000..68261e8 --- /dev/null +++ b/AgentResourceOfficer/services/quark_transfer.py @@ -0,0 +1,664 @@ +import json +import random +import re +import time +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlparse, urlencode + +import requests + +from app.log import logger + +try: + from app.core.config import settings +except Exception: + settings = None + + +class QuarkTransferService: + """ + Reusable execution layer migrated out of QuarkShareSaver. + + This service intentionally focuses on transfer execution and directory + resolution. UI, plugin form logic, and entry adapters stay outside. + """ + + def __init__( + self, + *, + cookie: str = "", + timeout: int = 30, + default_target_path: str = "/飞书", + auto_import_cookiecloud: bool = True, + cookie_refresh_callback: Optional[Callable[[], str]] = None, + ) -> None: + self.cookie = self.clean_text(cookie) + self.timeout = max(10, self.safe_int(timeout, 30)) + self.default_target_path = self.normalize_path(default_target_path or "/飞书") + self.auto_import_cookiecloud = auto_import_cookiecloud + self.cookie_refresh_callback = cookie_refresh_callback + self.path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + @staticmethod + def extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + @classmethod + def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = cls.clean_text(share_text) + share_url = cls.extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = cls.clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = cls.clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + @classmethod + def validate_share_url(cls, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if cls.is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def set_cookie(self, cookie: str) -> None: + self.cookie = self.clean_text(cookie) + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self.cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _refresh_cookie(self) -> bool: + if not self.auto_import_cookiecloud or not self.cookie_refresh_callback: + return False + try: + cookie = self.clean_text(self.cookie_refresh_callback()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}") + return False + if not cookie: + return False + self.cookie = cookie + return True + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookie_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + try: + response = requests.request( + method=method.upper(), + url=url, + params=params or None, + json=json_body, + headers=self._build_headers(), + timeout=self.timeout, + ) + status_code = response.status_code + raw_body = response.text or "" + except requests.RequestException as exc: + return False, {}, f"请求失败: {exc}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = response.json() + except Exception: + text = str(raw_body)[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie(): + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookie_retry=False, + ) + + if status_code != 200: + if isinstance(data, dict): + code = self.clean_text(data.get("code")) + detail = self.clean_text(data.get("message") or data.get("msg")) + if detail: + if code: + return False, data, f"HTTP {status_code} [{code}]: {detail}" + return False, data, f"HTTP {status_code}: {detail}" + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self.clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self.safe_int(meta.get("_total"), 0) + count = self.safe_int(meta.get("_count"), len(current)) + size = max(1, self.safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + "raw": item, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + source_items = [item for item in (items or []) if isinstance(item, dict)] + + def build_fids(candidates: List[Dict[str, Any]]) -> List[str]: + result: List[str] = [] + for item in candidates: + fid = self.clean_text(item.get("fid")) + if fid: + result.append(fid) + return result + + def item_label(item: Dict[str, Any]) -> str: + return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid")) + + def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + fids = build_fids(candidates) + if not fids: + return False, {}, "默认目录当前层没有可删除项目" + payloads = [ + { + "action_type": 2, + "exclude_fids": [], + "filelist": [{"fid": fid} for fid in fids], + }, + { + "action_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + { + # Some web scripts historically used this misspelled key. + "actoin_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + ] + last_data: Dict[str, Any] = {} + last_message = "" + for index, payload in enumerate(payloads, start=1): + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/file/delete", + params={ + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + }, + json_body=payload, + ) + if ok: + if isinstance(data, dict): + data["delete_payload_variant"] = index + return True, data, "" + last_data = data if isinstance(data, dict) else {} + last_message = message or last_message + return False, last_data, last_message or "夸克删除失败" + + filelist: List[Dict[str, Any]] = [] + for item in source_items: + fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else "" + if fid: + filelist.append({"fid": fid}) + if not filelist: + return False, {}, "默认目录当前层没有可删除项目" + + ok, data, message = call_delete(source_items) + if ok: + data["deleted_count"] = len(filelist) + data["delete_mode"] = "batch" + return True, data, "" + + if len(source_items) <= 1: + return False, data, message or "夸克删除失败" + + deleted_count = 0 + failed_items: List[Dict[str, Any]] = [] + for item in source_items: + single_ok, single_data, single_message = call_delete([item]) + if single_ok: + deleted_count += 1 + continue + failed_items.append({ + "fid": self.clean_text(item.get("fid")), + "name": item_label(item), + "message": single_message or "删除失败", + "result": single_data, + }) + + result = { + "deleted_count": deleted_count, + "failed_count": len(failed_items), + "failed_items": failed_items[:20], + "delete_mode": "single_fallback", + "batch_error": message or "夸克批量删除失败", + "batch_result": data, + } + if failed_items: + return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败" + return True, result, "" + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path) + if not ok: + return False, {}, target_fid or "定位夸克目录失败" + + ok, children, message = self.list_children(target_fid) + if not ok: + return False, {}, message or "读取夸克目录失败" + + files = [item for item in children if not bool(item.get("dir"))] + folders = [item for item in children if bool(item.get("dir"))] + if not children: + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "默认目录当前层为空" + + ok, delete_result, message = self.delete_items(children) + removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0) + if not ok: + return False, { + "target_path": normalized_path, + "target_fid": target_fid, + "file_count": len(files), + "folder_count": len(folders), + "removed_count": removed_count, + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "failed_items": (delete_result or {}).get("failed_items") or [], + "delete_result": delete_result, + }, message or "夸克清空默认目录失败" + + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "delete_result": delete_result, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "success" + + def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self.list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self.clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self.normalize_path(path or self.default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self.path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self.path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self.find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self.create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self.path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self.clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self.safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self.list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code) + ok, message = self.validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + if not self.cookie: + self._refresh_cookie() + if not self.cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self.get_stoken(pwd_id, final_code) + if not ok: + return False, {}, message + + ok, share_items, message = self.get_share_items(pwd_id, stoken) + if not ok: + return False, {}, message + + ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path) + if not ok: + return False, {}, target_fid + + ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + return False, {}, message + + ok, task, message = self.wait_task(task_id) + if not ok: + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + return True, result, "success" diff --git a/FeishuCommandBridgeLong/README.md b/FeishuCommandBridgeLong/README.md new file mode 100644 index 0000000..66acbcf --- /dev/null +++ b/FeishuCommandBridgeLong/README.md @@ -0,0 +1,109 @@ +# FeishuCommandBridgeLong + +MoviePilot 的飞书长连接桥接插件。当前定位是兼容/备份入口;新用户更推荐直接使用 `Agent影视助手` 内置的飞书入口。 + +## 这版的定位 + +- 保留旧飞书桥接的轻量远程操作体验 +- 作为迁移期兼容插件继续可用 +- 新功能优先进入 `Agent影视助手`,避免飞书入口和资源执行逻辑继续分叉 +- 如果只想装一个插件完成云盘资源整合 + 飞书入口,优先安装并开启 `Agent影视助手` 的内置飞书入口 + +## 当前能力 + +- 飞书长连接接收 `im.message.receive_v1` +- 智能单入口:自动识别片名、115 链接、夸克链接、盘搜搜索 +- 影巢两段式搜索:先选影片,再看资源 +- `详情` / `审查` / `n 下一页` 会话续接 +- MoviePilot 原生搜索、下载、订阅、订阅搜索 +- `P115StrmHelper` 的手动整理、增量 STRM、全量 STRM +- 115 扫码登录与状态查询 +- 待继续 115 任务查看、继续、取消 + +## 执行后端 + +- `旧桥接直连` + 适合保持现有飞书操作习惯,速度快。 +- `自动优先新主线,失败回落旧桥接` + 优先委托 `Agent影视助手`,失败再退回旧桥接。 +- `仅走 Agent影视助手 新主线` + 调试和后续统一主干时更合适。 + +日常老环境可以继续用 `旧桥接直连`。新环境建议改用 `Agent影视助手` 内置飞书入口;如果暂时仍使用本插件,建议切到 `仅走 Agent影视助手 新主线`,让资源动作统一落到 Agent影视助手。 + +## 新推荐入口 + +`Agent影视助手` 已内置可选 `Feishu Channel`,开启后可以直接接收飞书长连接消息,并复用同一套 `assistant/route`、`assistant/pick`、115 扫码和待任务续跑能力。 + +迁移建议: + +1. 在本插件里先关闭 `启用插件`。 +2. 到 `Agent影视助手` 中打开 `启用内置飞书入口`。 +3. 迁移同一组飞书 `App ID / App Secret / Verification Token / 白名单`。 +4. 确认 `GET /api/v1/plugin/AgentResourceOfficer/feishu/health` 显示运行正常。 + +## 常用飞书命令 + +```txt +处理 流浪地球2 +影巢搜索 流浪地球2 +yc流浪地球2 +2流浪地球2 + +盘搜搜索 流浪地球2 +ps流浪地球2 +1流浪地球2 + +链接 https://115cdn.com/s/xxxx path=/待整理 +链接 https://pan.quark.cn/s/xxxx path=/飞书 + +选择 1 +选择 1 path=/最新动画 + +详情 +审查 +n 下一页 +``` + +## 115 相关命令 + +```txt +115登录 +115扫码 +检查115登录 +115登录状态 +115状态 +115帮助 +115任务 +继续115任务 +取消115任务 +``` + +- 当飞书桥接走 `Agent影视助手` 新主线时,`115登录` 会直接拉起扫码登录流程 +- 如果飞书回复里带了二维码图片,直接用 115 App 扫码即可 +- 某次 115 转存因为登录或会话问题失败后,可直接回复 `115任务` 查看当前待处理任务 +- 登录成功后回复 `检查115登录`,会自动尝试继续上一次待处理的 115 任务 + +## 智能单入口说明 + +- 发片名:进入影巢或盘搜搜索流程 +- 发 115 / 夸克链接:自动识别并转存,其中 115 链接会优先委托 `Agent影视助手`,确保失败后的待任务、扫码续跑和取消任务都在同一条会话链里 +- `path=/目录`、`位置=目录` 都支持 +- 裸链接也支持,不一定要带 `处理` 或 `链接` 前缀 + +## 智能体 API + +插件提供两条更适合外部智能体调用的入口: + +```txt +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/route +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick +``` + +`route` 负责分流,`pick` 负责继续选择。飞书消息入口和这两条 API 用的是同一套会话逻辑。 + +## 依赖 + +```txt +lark-oapi==1.5.3 +``` diff --git a/FeishuCommandBridgeLong/__init__.py b/FeishuCommandBridgeLong/__init__.py new file mode 100644 index 0000000..4e1b478 --- /dev/null +++ b/FeishuCommandBridgeLong/__init__.py @@ -0,0 +1,4111 @@ +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 diff --git a/FeishuCommandBridgeLong/requirements.txt b/FeishuCommandBridgeLong/requirements.txt new file mode 100644 index 0000000..db1f7ac --- /dev/null +++ b/FeishuCommandBridgeLong/requirements.txt @@ -0,0 +1 @@ +lark-oapi==1.5.3 diff --git a/HdhiveOpenApi/README.md b/HdhiveOpenApi/README.md new file mode 100644 index 0000000..fc59ab9 --- /dev/null +++ b/HdhiveOpenApi/README.md @@ -0,0 +1,217 @@ +# HdhiveOpenApi + +MoviePilot 影巢 OpenAPI 插件。 + +这个插件的目标很明确: + +把影巢的核心能力直接接进 MoviePilot,包括: + +- 用户信息查询 +- 每日签到 +- 资源搜索 +- 资源解锁 +- 115 自动转存 +- 分享管理 +- 用量与配额查询 + +--- + +## 当前版本重点 + +当前版本已经覆盖这些核心能力: + +1. 用户信息查询 +2. 每日签到 +3. 资源查询与解锁 +4. 分享管理 +5. 用量与配额 +6. 115 自动转存 + +其中“资源查询与解锁”这条链路是当前最重要的部分。 + +--- + +## 公开 Skill 模板 + +如果你想把这套能力交给 AI 智能体,仓库里已经提供了一份可以直接复用的公开 Skill 模板: + +- [skills/hdhive-search-unlock-to-115/README.md](../skills/hdhive-search-unlock-to-115/README.md) +- [skills/hdhive-search-unlock-to-115/SKILL.md](../skills/hdhive-search-unlock-to-115/SKILL.md) +- [skills/hdhive-search-unlock-to-115/PROMPTS.md](../skills/hdhive-search-unlock-to-115/PROMPTS.md) + +适合场景: + +- 让别的机器快速复现 +- 让别的智能体直接调用统一流程 +- 让搜索、确认、解锁、115 落地形成固定工作流 + +推荐搭配支持技能和工作流编排的智能体工作台使用,例如腾讯 WorkBuddy,或其它兼容 Skill 工作流的客户端。 + +--- + +## 资源搜索方式 + +这个插件支持两种搜索方式: + +### 1. 按 TMDB ID 搜索 + +适合已经知道 TMDB ID 的场景。 + +示例: + +```text +GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&tmdb_id=550 +``` + +### 2. 按关键词搜索 + +这是当前更推荐的方式。 + +插件会先借助 MoviePilot 的媒体搜索能力,把片名转换成 TMDB 候选,再去影巢查资源。 + +示例: + +```text +GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&keyword=超级马里奥兄弟大电影 +``` + +支持附加参数: + +- `year=2023` +- `candidate_limit=5` +- `limit=10` + +--- + +## 资源解锁 + +按 `slug` 解锁资源: + +```text +POST /api/v1/plugin/HdhiveOpenApi/resources/unlock +{ + "slug": "资源slug" +} +``` + +如果是 115 资源,还可以在解锁时直接要求自动转存: + +```text +POST /api/v1/plugin/HdhiveOpenApi/resources/unlock +{ + "slug": "资源slug", + "transfer_115": true, + "path": "/待整理" +} +``` + +--- + +## 115 自动转存 + +插件已经支持把解锁得到的 115 分享链接直接交给 `P115StrmHelper`。 + +默认思路是: + +- 解锁资源 +- 如果解锁结果是 115 链接 +- 自动转存到 `/待整理` + +所以这条链路现在可以变成: + +`搜索 -> 选择资源 -> 解锁 -> 自动落到 115 /待整理` + +前提: + +- `P115StrmHelper` 已安装 +- 115 已登录 +- `/待整理` 目录有效 + +--- + +## 非 Premium 账号说明 + +当前实测结论: + +- 非 Premium 账号也可以正常搜索资源 +- 部分接口是 Premium 限制的 + +常见情况: + +- `/account` 可能提示 Premium 限制 +- `/vip/weekly-free-quota` 可能提示 Premium 限制 +- 但 `resources/search` 依然可以使用 + +所以对大部分“搜资源 / 解锁资源”的实际需求来说,非 Premium 用户仍然有使用价值。 + +--- + +## 智能体最佳实践 + +如果你想把这套能力交给 AI 智能体,仓库里更适合写“解决问题的思路”,而不是绑定某个本地 Skill 或脚本实现。 + +推荐思路: + +`插件做能力,智能体做调度` + +也就是把流程拆成下面几步: + +1. 智能体接收用户输入的片名或 TMDB ID +2. 优先调用插件已经暴露的稳定接口,不直接拼影巢原始 API +3. 如果是片名搜索: + - 先让插件完成关键词到候选影片的解析 + - 如果候选存在歧义,再补充 1 到 2 个主演名帮助用户确认版本 +4. 向用户展示前 10 个资源候选 +5. 等用户按编号选择后,再执行解锁 +6. 如果结果是 115 资源,再继续自动转存到目标目录 +7. 如果资源需要积分,必须先征求用户确认,再继续解锁 + +这样做的好处是: + +- 更省 token +- 更稳定 +- 更容易复现 +- 更容易复用到别的机器和智能体环境 + +不推荐的做法: + +- 让智能体现场拼影巢原始 API +- 让智能体自己维护 `slug`、Cookie 或其它运行时状态 +- 为了区分同名影片而临时做网页登录、网页搜索或人工拼接流程 + +如果需要,你也可以直接从仓库里的公开模板开始: + +- [skills/hdhive-search-unlock-to-115/README.md](../skills/hdhive-search-unlock-to-115/README.md) + +--- + +## 已包含的插件目录 + +仓库里已经包含: + +```text +plugins/hdhiveopenapi/__init__.py +plugins.v2/hdhiveopenapi/__init__.py +icons/hdhive.ico +``` + +并且已在: + +```text +package.json +package.v2.json +``` + +中注册。 + +--- + +## 适合谁用 + +这个插件最适合下面这类用户: + +- 已经在用 MoviePilot +- 手里有影巢 Open API Key +- 想在 MoviePilot 内直接完成资源搜索与解锁 +- 想把 115 资源自动放进 `/待整理` +- 想给 AI 智能体一个稳定的影巢入口 diff --git a/QuarkShareSaver/README.md b/QuarkShareSaver/README.md new file mode 100644 index 0000000..1791c62 --- /dev/null +++ b/QuarkShareSaver/README.md @@ -0,0 +1,45 @@ +# QuarkShareSaver + +轻量夸克分享转存插件。 + +它只负责一件事: + +- 把夸克分享链接直接转存到你自己的夸克网盘目录 + +适合的调用方式: + +- 智能体调用插件 API +- 飞书桥接发送简短命令 + +推荐接口: + +- `GET /api/v1/plugin/QuarkShareSaver/health` +- `GET /api/v1/plugin/QuarkShareSaver/folders?path=/` +- `POST /api/v1/plugin/QuarkShareSaver/share/info` +- `POST /api/v1/plugin/QuarkShareSaver/transfer` + +`transfer` 请求体示例: + +```json +{ + "url": "https://pan.quark.cn/s/xxxxxxxx", + "access_code": "abcd", + "path": "/来自分享/夸克" +} +``` + +飞书推荐命令: + +```text +夸克转存 https://pan.quark.cn/s/xxxxxxxx pwd=abcd path=/最新动画 +``` + +配置重点: + +- `Cookie` 使用浏览器登录 `pan.quark.cn` 后复制完整 Cookie +- `默认保存目录` 建议填一个固定路径,例如 `/来自分享/夸克` + +这类轻插件更适合做“稳定执行层”: + +- 智能体负责理解意图和补参数 +- 插件负责真正转存 diff --git a/QuarkShareSaver/__init__.py b/QuarkShareSaver/__init__.py new file mode 100644 index 0000000..1369467 --- /dev/null +++ b/QuarkShareSaver/__init__.py @@ -0,0 +1,1113 @@ +import hmac +import json +import random +import re +import time +from datetime import datetime +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen +from fastapi import Request + +from app.log import logger +from app.plugins import _PluginBase + +try: + from app.core.config import settings +except Exception: + settings = None + +try: + from app.schemas import NotificationType +except Exception: + NotificationType = None + +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None + + +class QuarkShareSaver(_PluginBase): + plugin_name = "夸克分享转存" + plugin_desc = "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/quark.ico" + plugin_version = "0.1.0" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "quarksharesaver_" + plugin_order = 32 + auth_level = 1 + + _enabled = False + _notify = True + _cookie = "" + _default_target_path = "/飞书" + _timeout = 30 + _auto_import_cookiecloud = True + _import_cookiecloud_once = False + + _share_url = "" + _access_code = "" + _target_path = "" + _transfer_once = False + + _last_transfer_key = "last_transfer" + _last_error_key = "last_error" + _path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "cookie": self._cookie, + "default_target_path": self._default_target_path, + "timeout": self._timeout, + "auto_import_cookiecloud": self._auto_import_cookiecloud, + "import_cookiecloud_once": self._import_cookiecloud_once, + "share_url": self._share_url, + "access_code": self._access_code, + "target_path": self._target_path, + "transfer_once": self._transfer_once, + } + if overrides: + config.update(overrides) + return config + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _save_state(self, key: str, value: Any) -> None: + try: + self.save_data(key=key, value=value) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 保存状态失败 {key}: {exc}") + + def _load_state(self, key: str, default: Any = None) -> Any: + try: + value = self.get_data(key) + return default if value is None else value + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 读取状态失败 {key}: {exc}") + return default + + def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None: + self._save_state( + self._last_error_key, + { + "action": action, + "message": message, + "payload": payload or {}, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + + def _notify_message(self, title: str, text: str) -> None: + if not self._notify or not hasattr(self, "post_message"): + return + try: + if NotificationType is not None: + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + else: + self.post_message(title=title, text=text) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 发送通知失败: {exc}") + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _try_import_cookiecloud_cookie(self, *, force: bool = False) -> Tuple[bool, str]: + if self._cookie and not force: + return True, "已存在 Cookie,跳过自动导入" + cookie, message = self._load_cookiecloud_quark_cookie() + if not cookie: + logger.info(f"[QuarkShareSaver] CookieCloud 导入未命中: {message}") + return False, message + self._cookie = cookie + logger.info(f"[QuarkShareSaver] 已从 CookieCloud 导入夸克 Cookie,长度: {len(cookie)}") + return True, "已从 CookieCloud 导入夸克 Cookie" + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + @staticmethod + def _extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + def _extract_share_info(self, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = self._clean_text(share_text) + share_url = self._extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = self._clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = self._clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = self._clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def _is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + def _validate_share_url(self, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if self._is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self._cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookiecloud_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + final_url = url + if params: + query = urlencode([(key, "" if value is None else value) for key, value in params.items()]) + final_url = f"{url}?{query}" if query else url + payload = None + if json_body is not None: + payload = json.dumps(json_body).encode("utf-8") + try: + request = UrlRequest( + url=final_url, + data=payload, + headers=self._build_headers(), + method=method.upper(), + ) + with urlopen(request, timeout=self._timeout) as response: + status_code = getattr(response, "status", 200) + raw_body = response.read() + except HTTPError as exc: + status_code = exc.code + raw_body = exc.read() if hasattr(exc, "read") else b"" + except URLError as exc: + return False, {}, f"请求失败: {exc.reason}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = json.loads(raw_body.decode("utf-8")) + except Exception: + text = raw_body.decode("utf-8", errors="ignore")[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code == 401 and allow_cookiecloud_retry and self._auto_import_cookiecloud: + imported, _ = self._try_import_cookiecloud_cookie(force=True) + if imported: + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookiecloud_retry=False, + ) + + if status_code != 200: + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self._clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def _get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self._safe_int(meta.get("_total"), 0) + count = self._safe_int(meta.get("_count"), len(current)) + size = max(1, self._safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def _list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def _find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self._list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def _create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self._clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def _ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path or self._default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self._create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _resolve_existing_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + return False, "", f"目录不存在: {built}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self._clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def _wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self._safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self._clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def _check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self._list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + remember: bool = True, + trigger: str = "插件 API", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self._extract_share_info(share_text, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + + if not self._enabled: + return False, {}, "插件未启用" + if not self._cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + self._remember_error("get_stoken", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, share_items, message = self._get_share_items(pwd_id, stoken) + if not ok: + self._remember_error("get_share_items", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, target_fid, normalized_path = self._ensure_target_dir(target_path or self._default_target_path) + if not ok: + self._remember_error("ensure_target_dir", target_fid, {"path": target_path or self._default_target_path}) + return False, {}, target_fid + + ok, task_id, message = self._create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + self._remember_error("create_save_task", message, {"pwd_id": pwd_id, "path": normalized_path}) + return False, {}, message + + ok, task, message = self._wait_task(task_id) + if not ok: + self._remember_error("wait_task", message, {"task_id": task_id}) + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + if remember: + self._save_state(self._last_transfer_key, result) + self._notify_message( + "夸克分享转存完成", + ( + f"保存目录:{normalized_path}\n" + f"任务ID:{task_id}\n" + f"顶层条目:{len(share_items)}" + ), + ) + return True, result, "success" + + def init_plugin(self, config: dict = None): + config = config or {} + self._enabled = bool(config.get("enabled")) + self._notify = bool(config.get("notify", True)) + self._cookie = self._clean_text(config.get("cookie")) + self._default_target_path = self._normalize_path(config.get("default_target_path") or "/飞书") + self._timeout = max(10, self._safe_int(config.get("timeout"), 30)) + self._auto_import_cookiecloud = bool(config.get("auto_import_cookiecloud", True)) + self._import_cookiecloud_once = bool(config.get("import_cookiecloud_once")) + + self._share_url = self._clean_text(config.get("share_url")) + self._access_code = self._clean_text(config.get("access_code")) + self._target_path = self._normalize_path(config.get("target_path") or self._default_target_path) + self._transfer_once = bool(config.get("transfer_once")) + self._path_cache = {"/": "0"} + + if self._import_cookiecloud_once or (self._auto_import_cookiecloud and not self._cookie): + imported_cookie, message = self._try_import_cookiecloud_cookie(force=self._import_cookiecloud_once) + if self._import_cookiecloud_once: + self._import_cookiecloud_once = False + self.update_config(self._build_config({"cookie": self._cookie, "import_cookiecloud_once": False})) + elif imported_cookie: + self.update_config(self._build_config({"cookie": self._cookie})) + if imported_cookie and self._notify: + self._notify_message("夸克 Cookie 已导入", message) + + if self._transfer_once: + self._transfer_once = False + self.update_config(self._build_config({"transfer_once": False})) + if self._enabled and self._share_url: + ok, _, message = self.transfer_share( + self._share_url, + access_code=self._access_code, + target_path=self._target_path, + remember=True, + trigger="插件页面立即转存", + ) + if not ok: + self._notify_message("夸克分享转存失败", message) + + def get_state(self) -> bool: + return self._enabled and bool(self._cookie) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_api(self) -> List[Dict[str, Any]]: + return [ + {"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查 Cookie 与默认目录状态"}, + {"path": "/folders", "endpoint": self.api_folders, "methods": ["GET"], "summary": "列出夸克网盘目录"}, + {"path": "/share/info", "endpoint": self.api_share_info, "methods": ["POST"], "summary": "解析夸克分享链接顶层条目"}, + {"path": "/transfer", "endpoint": self.api_transfer, "methods": ["POST"], "summary": "把夸克分享链接转存到指定目录"}, + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "notify", "label": "发送站内通知"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VTextField", "props": {"model": "timeout", "label": "请求超时(秒)", "type": "number"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "auto_import_cookiecloud", "label": "Cookie 为空时自动从 CookieCloud 导入"} + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "import_cookiecloud_once", "label": "立即从 CookieCloud 重新导入一次"} + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "浏览器登录 pan.quark.cn 后复制完整 Cookie", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "default_target_path", + "label": "默认保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "推荐给智能体或飞书调用的接口:\n" + "POST /api/v1/plugin/QuarkShareSaver/transfer\n" + "参数:url, access_code, path。\n" + "飞书建议命令:夸克转存 分享链接 pwd=提取码 path=/最新动画\n" + "如果你启用了本地 CookieCloud,插件可以自动导入 quark.cn Cookie。" + ), + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_once", "label": "立即转存一次"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "target_path", + "label": "本次保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_url", + "label": "夸克分享链接", + "placeholder": "https://pan.quark.cn/s/xxxx", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "access_code", + "label": "提取码(可留空)", + "placeholder": "abcd", + }, + } + ], + } + ], + }, + ], + } + ], self._build_config() + + def get_page(self) -> List[dict]: + last_transfer = self._load_state(self._last_transfer_key, default={}) or {} + last_error = self._load_state(self._last_error_key, default={}) or {} + + transfer_lines = [ + f"最近一次:{last_transfer.get('time') or '暂无'}", + f"保存目录:{last_transfer.get('target_path') or '-'}", + f"任务ID:{last_transfer.get('task_id') or '-'}", + f"顶层条目:{last_transfer.get('saved_count') or 0}", + ] + if last_transfer.get("items"): + transfer_lines.append("示例条目:" + ", ".join(last_transfer.get("items")[:5])) + + error_lines = [ + f"最近错误动作:{last_error.get('action') or '暂无'}", + f"错误时间:{last_error.get('time') or '-'}", + f"错误信息:{last_error.get('message') or '-'}", + ] + + return [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"variant": "tonal"}, + "content": [ + { + "component": "VCardText", + "text": ( + "夸克分享转存插件负责做一件事:把夸克分享链接稳定转存到自己的夸克网盘。" + "推荐让智能体和飞书只调用这一个稳定入口,不要自己拼夸克接口。" + ), + } + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近转存"}, + {"component": "VCardText", "text": "\n".join(transfer_lines)}, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近错误"}, + {"component": "VCardText", "text": "\n".join(error_lines)}, + ], + } + ], + }, + ], + } + ] + + def get_service(self) -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + pass + + async def api_health(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok = False + message = "" + if self._enabled and self._cookie: + ok, message = self._check_cookie() + return { + "success": ok if self._enabled and self._cookie else False, + "message": "success" if ok else (message or "插件未启用或未配置 Cookie"), + "data": { + "plugin_enabled": self._enabled, + "cookie_configured": bool(self._cookie), + "default_target_path": self._default_target_path, + "timeout": self._timeout, + }, + } + + async def api_folders(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + path = self._normalize_path(request.query_params.get("path") or "/") + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"path": path, "items": []}} + + ok, folder_id, normalized = self._resolve_existing_dir(path) + if not ok: + return {"success": False, "message": folder_id or "目录不存在", "data": {"path": path, "items": []}} + + ok, items, message = self._list_children(folder_id) + dirs = [ + {"fid": item.get("fid"), "name": item.get("name"), "path": f"{normalized.rstrip('/')}/{item.get('name')}".replace("//", "/")} + for item in items + if item.get("dir") + ] + return {"success": ok, "message": "success" if ok else message, "data": {"path": normalized, "items": dirs}} + + async def api_share_info(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + share_url = body.get("url") or body.get("share_url") or "" + access_code = body.get("access_code") or body.get("pwd") or "" + share_url, pwd_id, final_code = self._extract_share_info(share_url, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return {"success": False, "message": message, "data": {}} + if not pwd_id: + return {"success": False, "message": "未识别到有效夸克分享链接", "data": {}} + + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"pwd_id": pwd_id}} + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + return {"success": False, "message": message, "data": {"pwd_id": pwd_id}} + + ok, items, message = self._get_share_items(pwd_id, stoken) + return { + "success": ok, + "message": "success" if ok else message, + "data": { + "pwd_id": pwd_id, + "access_code": final_code, + "items": items[:20], + "count": len(items), + }, + } + + async def api_transfer(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok, result, message = self.transfer_share( + share_text=body.get("url") or body.get("share_url") or "", + access_code=body.get("access_code") or body.get("pwd") or "", + target_path=body.get("path") or body.get("target_path") or self._default_target_path, + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": message, "data": result} diff --git a/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md b/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md new file mode 100644 index 0000000..e0bfda4 --- /dev/null +++ b/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md @@ -0,0 +1,192 @@ +# 外部智能体接入 Agent影视助手 + +当前插件版本:`Agent影视助手 0.2.68` + +当前 helper 版本:`agent-resource-officer 0.1.46` + +让 `OpenClaw`、`Hermes`、`WorkBuddy` 或其他外部智能体,也能稳定调用 MoviePilot 的搜片、转存、下载、签到和修复能力。 + +核心思路很简单:外部智能体负责理解你说的话、调用 `Agent影视助手`、展示结果;真正的资源搜索、转存、下载和账号操作,都交给 MoviePilot 里的插件执行。 + +--- + +## 一步接入 + +把下面这段直接发给你的外部智能体: + +```text +请从这个仓库创建并使用 agent-resource-officer Skill: +https://github.com/liuyuexi1987/MoviePilot-Plugins + +创建后请依次读取: +1. skills/agent-resource-officer/SKILL.md +2. skills/agent-resource-officer/EXTERNAL_AGENTS.md +3. docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md + +连接配置: +ARO_BASE_URL=http://MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN + +如果你的客户端支持 MoviePilot 官方 MCP,也请同时接入: +MCP 地址:http://MoviePilot地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN + +分工规则: +1. 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询,可以优先用 MCP。 +2. 云盘搜索、盘搜、影巢、转存、夸克转存、115转存、下载、更新检查、编号选择、翻页、详情、Cookie 修复,继续优先用 agent-resource-officer skill / helper。 +3. 只有当前会话真的加载出 mcp__moviepilot__* 工具,才算 MCP 已接通;没接通时不要假装在用 MCP。 + +请把配置写入 ~/.config/agent-resource-officer/config。 +然后运行 readiness 验证连接,成功后按文档规则接入。 +``` + +`ARO_API_KEY` 在 MoviePilot 管理后台的系统设置 / 安全设置里找。 + +--- + +## 连接地址怎么填 + +先判断 MoviePilot 和智能体是不是在同一台机器。 + +### 同机部署 + +如果 MoviePilot 和智能体在同一台电脑或同一个容器网络里,可以这样填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +这也是最简单的情况。 + +### 跨机器部署 + +如果 MoviePilot 在 NAS,智能体在 Win / Mac 电脑上,`ARO_BASE_URL` 必须填 NAS 的实际地址: + +```bash +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +这里的 `127.0.0.1` 只代表智能体自己这台机器,不是 NAS。 + +如果你有多套 MoviePilot,要特别注意: + +- `ARO_BASE_URL` 指向哪套 MoviePilot,`下载 / MP搜索 / PT搜索 / 转存` 就使用哪套 MoviePilot。 +- 如果当前 MoviePilot 只用于网盘或 STRM,不要在这套实例里确认 PT 下载。 +- 如果 MoviePilot 和 qBittorrent 不在一台机器,可在 Agent影视助手设置里填写 `PT 下载保存路径`,路径要按目标 NAS / qB 的真实下载目录填写。 + +跨机器部署详细说明见 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)。 + +--- + +## 手动添加 MCP + +有些智能体不会自动读取或启用 MoviePilot MCP,需要你在智能体的 MCP 设置里手动添加。 + +填写: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS,地址要写 NAS 的实际地址: + +```text +MCP 地址:http://你的NAS地址:3000/api/v1/mcp +``` + +添加后,需要在智能体里确认 MCP 已启用,并且当前会话能看到类似 `mcp__moviepilot__*` 的工具。 + +如果看不到这些工具,就说明 MCP 没有真正加载成功。此时不要让智能体假装在用 MCP,资源流继续走 `agent-resource-officer skill / helper`。 + +--- + +## 怎么用 + +接入完成后,直接对智能体说: + +| 命令 | 作用 | +|---|---| +| `搜索 蜘蛛侠` | 搜索云盘资源,默认走盘搜 | +| `云盘搜索 蜘蛛侠` | 盘搜 + 影巢一起搜 | +| `MP搜索 蜘蛛侠` / `PT搜索 蜘蛛侠` | 走 MoviePilot 原生 PT 搜索 | +| `转存 蜘蛛侠` | 默认等同 `115转存 蜘蛛侠` | +| `115转存 蜘蛛侠` | 搜索后转存到 115 | +| `夸克转存 蜘蛛侠` | 搜索后转存到夸克 | +| `下载 蜘蛛侠` | 搜索并生成 PT 下载计划 | +| `更新检查 蜘蛛侠` | 检查是否有新资源 | +| `115登录` | 扫码登录 115 | +| `影巢签到` | 执行影巢签到 | + +完整命令列表见:`docs/ALL_COMMANDS.md`。 + +--- + +## MCP 要不要接 + +MoviePilot 官方 MCP 可以接,但它和 `agent-resource-officer skill / helper` 的定位不同。 + +推荐这样分工: + +| 场景 | 推荐入口 | +|---|---| +| 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询 | 官方 MCP | +| 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情、Cookie 修复 | `agent-resource-officer skill / helper` | +| `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流 | 优先 `agent-resource-officer skill / helper` | + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +``` + +认证头: + +```text +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +注意:只有当前智能体客户端真的加载出了 `mcp__moviepilot__*` 工具,才算 MCP 已接通。没有接通时,不要让智能体假装在用 MCP;资源流继续走 `agent-resource-officer`。 + +--- + +## 给智能体看的执行规则 + +这部分规则已经写在 `agent-resource-officer` Skill 里,普通用户不用背。 + +接入时只要让外部智能体读取本仓库里的 Skill,它就会知道哪些命令必须走 `route / pick`、哪些动作需要确认、哪些结果不能重排编号。 + +--- + +## 长线程维护 + +微信、飞书、WorkBuddy、Claw 这类长线程用久后,可能会出现: + +- `15详情` 被误解成 `选择 15` +- 编号续接到旧搜索结果 +- 一直套用旧格式或旧规则 + +这时直接对智能体说: + +```text +校准影视技能 +``` + +这条命令会让智能体重新加载影视助手的关键规则。不要在普通 `搜索 / 更新检查 / 检查` 前主动清会话,否则会破坏正常编号续接。 + +--- + +## 相关文档 + +- 全部命令一览:`docs/ALL_COMMANDS.md` +- [跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [Skill 说明](../skills/agent-resource-officer/SKILL.md) +- 外部智能体详细规范:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` diff --git a/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md b/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md new file mode 100644 index 0000000..2bbb3d1 --- /dev/null +++ b/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md @@ -0,0 +1,181 @@ +# Agent影视助手跨机器部署 + +当前插件版本:`Agent影视助手 0.2.68` + +当前 helper 版本:`agent-resource-officer 0.1.46` + +这份文档只讲一种常见情况: + +```text +MoviePilot 在 NAS / Docker / 远程主机 +外部智能体在 Win / Mac 电脑 +``` + +这属于正常用法,不是特殊模式。关键只有一个:智能体要能访问到 MoviePilot。 + +--- + +## 先填对 ARO_BASE_URL + +外部智能体所在电脑的配置文件一般是: + +```text +~/.config/agent-resource-officer/config +``` + +如果 MoviePilot 在 NAS,配置应类似: + +```text +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要写: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +因为这里的 `127.0.0.1` 代表智能体自己这台电脑,不是 NAS。 + +只有 MoviePilot 和智能体在同一台机器时,才用: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +--- + +## 多套 MoviePilot 时要注意 + +`ARO_BASE_URL` 指向哪套 MoviePilot,下面这些命令就使用哪套 MoviePilot: + +```text +MP搜索 +PT搜索 +下载 +订阅 +转存 +更新检查 +``` + +如果你有一套 MoviePilot 只用于网盘 / STRM,不要在这套实例里确认 PT 下载。 + +如果你真正下载用的是 NAS 上另一套 MoviePilot,就把 `ARO_BASE_URL` 指向那一套。 + +--- + +## MP 和 qB 不同机时 + +如果 MoviePilot 和 qBittorrent 不在一台机器,可以在 `Agent影视助手` 设置页填写: + +```text +PT 下载保存路径 +``` + +简单理解: + +- MoviePilot 和 qB 在同一台机器:通常不用填。 +- MoviePilot 和 qB 不在一台机器:填 qB 能识别的真实下载目录。 + +示例: + +```text +/downloads +/volume1/downloads +local:/downloads +``` + +不要填你当前电脑上的临时路径,除非 qB 也真的在这台电脑上。 + +--- + +## 盘搜 API 地址按 MoviePilot 视角填 + +这里容易混: + +- `ARO_BASE_URL` 是外部智能体访问 MoviePilot 的地址。 +- `盘搜 API 地址` 是 MoviePilot 插件访问 PanSou 的地址。 + +如果 PanSou 和 MoviePilot 在同一台 NAS / Docker 网络里,`盘搜 API 地址` 要填 MoviePilot 那边能访问到的地址,不一定是你电脑能访问到的地址。 + +--- + +## Cookie 修复读的是哪台电脑 + +这些命令会用到浏览器 Cookie: + +```text +刷新影巢Cookie +修复影巢签到 +刷新夸克Cookie +修复夸克转存 +``` + +跨机器时,它们读取的是**智能体所在电脑**的浏览器登录态,然后写回 NAS 上的 MoviePilot。 + +所以如果 MoviePilot 在 NAS、智能体在 Mac: + +1. 在 Mac 浏览器里登录 `https://hdhive.com` 或 `https://pan.quark.cn`。 +2. 再让智能体执行修复命令。 +3. 不需要去 NAS 桌面上找浏览器 Cookie。 + +--- + +## 最小验证 + +在智能体所在机器执行: + +```bash +python3 scripts/aro_request.py readiness +``` + +如果通过,说明智能体已经能访问 MoviePilot 插件。 + +再试一个只读命令: + +```bash +python3 scripts/aro_request.py route "115状态" +``` + +如果也能返回,跨机器主链基本就通了。 + +--- + +## 常见错误 + +### 1. NAS 环境还写 127.0.0.1 + +表现:智能体连接失败、请求打到自己电脑。 + +解决:把 `ARO_BASE_URL` 改成 NAS 的局域网 IP 或域名。 + +### 2. 改了仓库文件,但 MoviePilot 还在跑旧插件 + +仓库里的文件改完后,不等于容器里的插件已经更新。 + +如果页面或接口还是旧表现,先确认 MoviePilot 实际加载的是最新插件。 + +### 3. 长线程被旧上下文污染 + +表现: + +- `15详情` 被当成 `选择 15` +- 编号接到旧搜索结果 +- 明明更新了规则,智能体还是按旧说法执行 + +直接对智能体说: + +```text +校准影视技能 +``` + +不要在普通搜索前固定清会话,否则会破坏正常编号续接。 + +--- + +## 推荐阅读 + +- [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- 全部命令:`docs/ALL_COMMANDS.md` +- [插件安装说明](./PLUGIN_INSTALL.md) diff --git a/docs/ALL_COMMANDS.md b/docs/ALL_COMMANDS.md new file mode 100644 index 0000000..859afb2 --- /dev/null +++ b/docs/ALL_COMMANDS.md @@ -0,0 +1,288 @@ +# Agent影视助手命令总览 + +这份文档只做一件事:把当前主线命令讲清楚。 + +适用范围: + +- MoviePilot 插件 API +- Agent影视助手内置飞书入口 +- 外部智能体通过 `agent-resource-officer` skill / helper 调用 + +不同入口会有少量别名差异,但主命令尽量保持一致。 + +--- + +## 先记住 4 条 + +1. `搜索 <片名>` 默认走盘搜,不是 PT 搜索。 +2. `转存 <片名>` 默认等同 `115转存 <片名>`。 +3. `下载 <片名>` 只走 MoviePilot 原生 PT 下载链,不会改成云盘转存。 +4. `下载1` 是生成下载计划,不会立刻真实下载;确认后才执行。 + +--- + +## 搜索类 + +| 命令 | 作用 | +|---|---| +| `MP搜索 <片名>` | 走 MoviePilot 原生 PT 搜索 | +| `PT搜索 <片名>` | 同 `MP搜索` | +| `原生搜索 <片名>` | 同 `MP搜索` | +| `搜索 <片名>` | 默认走盘搜 | +| `盘搜搜索 <片名>` | 只看盘搜 | +| `影巢搜索 <片名>` | 只看影巢 | +| `云盘搜索 <片名>` | 盘搜 + 影巢一起搜 | +| `下载 <片名>` | 先识别媒体,再走 MoviePilot 原生 PT 搜索和下载计划链 | +| `更新检查 <片名>` | 检查当前媒体是否有更新资源 | +| `检查 <片名>` | 同 `更新检查` | + +补充说明: + +- `MP搜索` / `PT搜索` 遇到片名歧义时,通常会先让你选正确影片或剧集。 +- `下载 <片名>` 如果片名有歧义,也会先让你选影片,再继续 PT 下载链。 + +--- + +## 选择、详情、翻页 + +| 命令 | 作用 | +|---|---| +| `1` | 继续处理当前第 1 条结果 | +| `1详情` | 查看当前第 1 条详情 | +| `选择 1` | 显式选择第 1 条 | +| `选择 1 详情` | 显式查看第 1 条详情 | +| `下载1` | 对当前第 1 条 PT 结果生成下载计划 | +| `n` | 下一页 | +| `下一页` | 同 `n` | + +这里最容易混淆的是 `1`: + +- 在 PT 结果里,`1` 通常是继续第 1 条,并生成或确认下载计划 +- 在云盘结果里,`1` 通常是继续第 1 条,并执行对应选择/转存逻辑 + +如果你只想看详情,用 `1详情` 最稳。 + +--- + +## 转存类 + +| 命令 | 作用 | +|---|---| +| `转存 <片名>` | 默认等同 `115转存 <片名>` | +| `115转存 <片名>` | 搜索后优先转存到 115 | +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `转存资源 <片名>` | 同 `转存 <片名>` | + +补充说明: + +- 现在主线规则是:`转存` 默认就是 `115转存` +- 只有你明确说 `夸克转存`,才会走夸克 + +--- + +## 直接处理分享链接 + +直接发送分享链接即可,无需额外前缀: + +| 输入 | 效果 | +|---|---| +| `https://115.com/s/xxxxx` | 自动走 115 转存 | +| `https://pan.quark.cn/s/xxxxx` | 自动走夸克转存 | + +如有提取码,也可以一起发。 + +--- + +## PT 下载类 + +| 命令 | 作用 | +|---|---| +| `下载 <片名>` | 先找片,再列出 PT 资源或直接生成计划 | +| `下载1` | 给当前第 1 条 PT 结果生成下载计划 | +| `执行计划` | 执行当前待确认计划 | +| `确认计划` | 同 `执行计划` | +| `计划列表` | 查看当前待确认或已保存计划 | +| `取消计划` | 清理当前计划 | + +PT 下载主线是: + +```text +下载 流浪地球2 +下载1 +执行计划 +``` + +默认先计划,再确认执行。 + +--- + +## 订阅类 + +| 命令 | 作用 | +|---|---| +| `订阅 <片名>` | 创建订阅 | +| `订阅并搜索 <片名>` | 创建订阅并立刻跑一次搜索 | +| `订阅列表` | 查看订阅列表 | +| `搜索订阅 <编号>` | 手动刷新某条订阅 | +| `暂停订阅 <编号>` | 暂停订阅 | +| `恢复订阅 <编号>` | 恢复订阅 | +| `删除订阅 <编号>` | 删除订阅 | + +--- + +## 115 相关 + +| 命令 | 作用 | +|---|---| +| `115登录` | 生成 115 扫码登录 | +| `检查115登录` | 检查 115 扫码是否成功 | +| `115状态` | 查看当前 115 登录状态 | +| `115任务` | 查看待继续的 115 任务 | +| `继续115任务` | 继续上次 115 任务 | +| `取消115任务` | 取消待处理 115 任务 | +| `清空115转存目录` | 清空 115 默认转存目录 | + +--- + +## 夸克相关 + +| 命令 | 作用 | +|---|---| +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `清空夸克转存目录` | 清空夸克默认转存目录 | +| `刷新夸克Cookie` | 从本机浏览器重新导入夸克 Cookie | +| `修复夸克转存` | 走夸克转存修复链 | + +--- + +## 影巢相关 + +| 命令 | 作用 | +|---|---| +| `影巢搜索 <片名>` | 搜影巢资源 | +| `影巢签到` | 执行影巢签到 | +| `影巢签到日志` | 查看影巢签到日志 | +| `刷新影巢Cookie` | 从本机浏览器重新导入影巢 Cookie | +| `修复影巢签到` | 走影巢签到修复链 | + +--- + +## 下载器 / 站点 / 状态 + +| 命令 | 作用 | +|---|---| +| `下载任务` | 查看当前下载任务 | +| `下载历史` | 查看下载历史 | +| `入库历史` | 查看最近入库/整理历史 | +| `最近入库` | 查看最近入库活动 | +| `站点` | 查看 PT 站点状态 | +| `下载器` | 查看下载器状态 | +| `MP识别 <片名>` | 查看 MoviePilot 对该媒体的识别结果 | +| `追踪 <片名>` | 看某个媒体从搜索到下载到入库的状态 | +| `最近` | 看最近下载和入库动态 | +| `诊断 <片名>` | 诊断为什么没有入库 | + +--- + +## 推荐 / 发现 + +这组能力代码已接入,但当前不属于这轮重点实测主线。 +更适合先在测试环境、短线程或本地会话里验证后再长期使用。 + +| 命令 | 作用 | +|---|---| +| `推荐` | 热门推荐 | +| `热门电影` | 热门电影推荐 | +| `热门电视剧` | 热门剧集推荐 | +| `豆瓣热门` | 豆瓣热门榜单 | +| `正在热映` | 当前热映 | +| `今日番剧` | 今日番剧放送 | + +--- + +## 智能决策类 + +这组命令代码已接入,但状态切换和分支很多,目前不建议新手直接把它当成熟主线使用。 +更适合外部智能体或熟悉工作流后,在测试环境先跑通。 + +| 命令 | 作用 | +|---|---| +| `资源决策 <片名>` | 综合搜索并给出当前最佳方案 | +| `智能决策 <片名>` | 同 `资源决策` | +| `确认执行` | 执行当前推荐动作 | +| `先看详情` | 看当前推荐详情 | +| `先做计划` | 为当前推荐先生成计划 | +| `换影巢` | 切到影巢结果 | +| `换盘搜` | 切到盘搜结果 | +| `换PT` | 切到 MP/PT 结果 | +| `保守一点` | 切到更保守策略 | +| `激进一点` | 切到更激进策略 | +| `只用夸克` | 只保留夸克云盘 | +| `只用115` | 只保留 115 云盘 | +| `只走PT` | 只保留 MP/PT | +| `继续决策` | 延续当前决策流程 | +| `跟进` | 查看当前媒体后续进展 | + +--- + +## 偏好设置 + +这组功能代码已接入,但这轮几乎没有做真实使用测试。 +如果你只是第一次上手,可以先跳过;等主线命令稳定后再启用。 + +| 命令 | 作用 | +|---|---| +| `查看偏好` | 查看当前片源偏好 | +| `保存偏好 <内容>` | 更新片源偏好 | +| `重置偏好` | 恢复默认偏好 | +| `评分策略` | 查看评分和自动化规则 | + +--- + +## AI识别增强 + +这一组需要启用 `AI识别增强` 插件。 + +| 命令 | 作用 | +|---|---| +| `失败样本` | 查看失败样本 | +| `工作清单` | 查看待处理识别样本 | +| `样本洞察` | 看失败样本统计 | +| `重放样本 <编号>` | 重跑某个失败样本 | +| `重跑样本 <编号> 保留样本` | 重跑但不自动移除样本 | + +--- + +## 外部智能体维护 + +这一组主要给 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体用。 + +| 命令 | 作用 | +|---|---| +| `校准影视技能` | 让外部智能体重新读取当前 Skill 硬规则 | +| `帮助` | 查看帮助 | +| `版本` | 查看插件版本 | + +如果长线程用久了,出现: + +- `15详情` 被误判成 `选择 15` +- 编号串到旧搜索结果 +- 一直套用旧格式 + +优先先清 session,再重新读取 skill。详细见: + +- [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [Skill 说明](../skills/agent-resource-officer/SKILL.md) + +--- + +## 飞书入口的额外说明 + +飞书入口除了上面这些主命令外,还有一些历史兼容别名。 + +新手建议: + +1. 直接使用完整命令,不要依赖缩写。 +2. 尤其优先用 `MP搜索`、`盘搜搜索`、`影巢搜索`、`云盘搜索`、`转存`、`115转存`、`夸克转存`、`下载` 这些主命令。 + +如果你只想最稳地用飞书,就把它当成一个命令聊天窗口,不要自己发明新句式。 diff --git a/docs/GITHUB_PUBLISH.md b/docs/GITHUB_PUBLISH.md new file mode 100644 index 0000000..092564e --- /dev/null +++ b/docs/GITHUB_PUBLISH.md @@ -0,0 +1,63 @@ +# GitHub 发布说明 + +## 推荐仓库名 + +```text +MoviePilot-Plugins +``` + +## 推荐描述 + +```text +Personal MoviePilot plugin suite for agent-driven resource workflows, AI recognition fallback, Feishu control, HDHive, Quark and media refresh helpers +``` + +## 发布建议 + +- 开始发版或仓库维护前,先执行一次: + - `bash scripts/repo-hygiene.sh` +- 如果想一条命令跑完整发版前检查,优先执行: + - `bash scripts/release-preflight.sh` +- README 首页保持中文 +- GitHub 仓库描述使用简短英文 +- 当前对外文档优先以 `docs/INDEX.md` 为导航;不要把历史规划文档当成当前说明 +- 如果只想快速查维护/发布命令,不想先读长文,直接看: + - `docs/MAINTENANCE_COMMANDS.md` +- 如果只想单独跑底层发布检查,再执行: + - `bash scripts/pre-release-check.sh` +- 如果只想先验证“当前状态”文档有没有版本漂移,可以单独执行: + - `python3 scripts/check-doc-current-state.py` +- Release 附件可上传 `dist/` 下生成的插件 ZIP,以及 `dist/skills/` 下生成的公开 Skill ZIP;校验文件在 Release 附件中使用 `PLUGIN_` / `SKILL_` 前缀避免重名 +- `Release Preflight` workflow 通过后会把插件 ZIP、Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json` 上传为 Actions artifact,可直接下载核对或作为 Release 附件来源 +- 可以用 `bash scripts/create-draft-release.sh --dry-run` 预览 Release 附件和说明,再去掉 `--dry-run` 创建 Draft Release +- 也可以手动运行 GitHub Actions -> Draft Release;默认 `dry_run=true`,并会上传 release asset artifact 供核对 +- Draft Release 核对无误后,用 `gh release edit --draft=false --latest --target main` 发布正式 Release +- 正式发布后执行 `bash scripts/verify-release-download.sh `,确认公开附件可下载且校验通过 +- GitHub Actions 已支持手动运行,可在 Actions -> Release Preflight -> Run workflow 主动触发一次完整发布检查 +- 具体发版步骤见:[RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) + +## 当前对外文档 + +真正对用户和外部智能体公开的主文档,发布前至少确认这几份没有落后于代码: + +- `README.md` +- `docs/INDEX.md` +- `docs/PLUGIN_INSTALL.md` +- `docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md` +- `docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md` + +## 当前 ZIP 覆盖 + +`release-preflight.sh` 的完整检查阶段会生成当前清单里的 8 个本地安装包: + +- `AIRecognizerEnhancer` +- `AgentResourceOfficer` +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `QuarkShareSaver` + +## 历史说明 + +早期 `v2.0.0-alpha.1` 是旧 AI Gateway 拆分阶段的首发说明,已移到历史文档: + +- [RELEASE_v2.0.0-alpha.1.md](./RELEASE_v2.0.0-alpha.1.md) diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..0fbd184 --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,55 @@ +# 文档索引 + +这份索引只做一件事:让你按目标快速落到当前有效文档。历史文档只保留,不作为当前操作手册。 + +## 我现在要装和用 + +1. [README.md](../README.md) +2. [ALL_COMMANDS.md](./ALL_COMMANDS.md) +3. [PLUGIN_INSTALL.md](./PLUGIN_INSTALL.md) +4. [AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +5. 如果 `MoviePilot` 不在当前机器,再看 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) + +## 我现在要接外部智能体 + +1. [AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +2. 如果跨机器,再看 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +3. 智能体安装 Skill 时会读取 [skills/agent-resource-officer/SKILL.md](../skills/agent-resource-officer/SKILL.md),普通用户一般不用手读。 + +## 我现在要打包和发布 + +1. [PACKAGING.md](./PACKAGING.md) +2. [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) +3. [GITHUB_PUBLISH.md](./GITHUB_PUBLISH.md) + +## 我现在要做仓库维护 + +1. 先跑: + `bash scripts/repo-hygiene.sh` +2. 如果准备发版,再跑: + `bash scripts/release-preflight.sh` +3. 再看: + [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) +4. 如需命令速查: + [MAINTENANCE_COMMANDS.md](./MAINTENANCE_COMMANDS.md) +5. 如需当前发版口径: + [GITHUB_PUBLISH.md](./GITHUB_PUBLISH.md) + +## 当前有效文档清单 + +- [ALL_COMMANDS.md](./ALL_COMMANDS.md) +- [PLUGIN_INSTALL.md](./PLUGIN_INSTALL.md) +- [AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [PACKAGING.md](./PACKAGING.md) +- [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) +- [GITHUB_PUBLISH.md](./GITHUB_PUBLISH.md) +- [MAINTENANCE_COMMANDS.md](./MAINTENANCE_COMMANDS.md) + +## 历史归档文档 + +- [REBUILD_AGENT_SUITE.md](./REBUILD_AGENT_SUITE.md) + 早期重构规划记录,只用于回看设计演进 + +- [RELEASE_v2.0.0-alpha.1.md](./RELEASE_v2.0.0-alpha.1.md) + 旧 AI Gateway 阶段的历史发布草稿,不作为当前发布说明 diff --git a/docs/MAINTENANCE_COMMANDS.md b/docs/MAINTENANCE_COMMANDS.md new file mode 100644 index 0000000..93602d9 --- /dev/null +++ b/docs/MAINTENANCE_COMMANDS.md @@ -0,0 +1,193 @@ +# 仓库维护命令索引 + +这份文档只列当前常用的仓库维护与发布命令,不解释历史方案。 + +## 当前状态 + +- 当前插件版本:`AgentResourceOfficer 0.2.68` +- 当前 Skill helper 版本:`0.1.46` +- 当前 Release: + +## 最常用入口 + +- 仓库卫生检查: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 发版前完整检查: + +```bash +bash scripts/release-preflight.sh +``` + +- 低层发布检查: + +```bash +bash scripts/pre-release-check.sh +``` + +## 推荐顺序 + +- 日常看状态或准备整理仓库: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 想清理本地生成文件或顺手删除 `dist/`: + +```bash +bash scripts/clean-generated.sh +bash scripts/clean-generated.sh --dist +``` + +- 准备发版、打包、更新 Draft Release 之前: + +```bash +bash scripts/release-preflight.sh +``` + +- 准备在 GitHub 上创建或更新 Draft Release: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 想确认最近一次 GitHub Actions 产物是否完整: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +## 状态与审计 + +- 检查当前状态文档是否和代码版本一致: + +```bash +python3 scripts/check-doc-current-state.py +``` + +- 审计远端和本地历史分支: + +```bash +python3 scripts/audit-remote-branches.py +``` + +- 归档本地非 `main` 分支到 `archive/*` tag: + +```bash +python3 scripts/archive-local-branches.py +python3 scripts/archive-local-branches.py --apply +``` + +## 打包与发布 + +- 创建 Draft Release 前 dry-run: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 创建 Draft Release: + +```bash +bash scripts/create-draft-release.sh +``` + +- 用当前 `dist/` 覆盖已有 Draft Release 附件: + +```bash +bash scripts/update-draft-release-assets.sh +``` + +- 校验公开 Release 下载附件: + +```bash +bash scripts/verify-release-download.sh +``` + +- 单独打包公开 Skill ZIP: + +```bash +bash scripts/package-skills.sh +``` + +## Artifact 与产物校验 + +- 下载并校验最近一次成功的 `Release Preflight` workflow artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +bash scripts/verify-release-preflight-artifact.sh +``` + +- 校验本地 release 资产目录: + +```bash +bash scripts/verify-release-assets.sh +bash scripts/verify-release-assets.sh /path/to/release-assets +``` + +- 校验插件 ZIP: + +```bash +DIST_DIR=dist bash scripts/verify-dist.sh +``` + +- 校验 Skill ZIP: + +```bash +DIST_DIR=dist/skills bash scripts/verify-skill-dist.sh +``` + +## 汇总输出 + +- 打印插件 ZIP Markdown 表格: + +```bash +bash scripts/print-release-summary.sh +``` + +- 打印 Skill ZIP Markdown 表格: + +```bash +bash scripts/print-skill-release-summary.sh +``` + +- 生成 Release notes: + +```bash +bash scripts/generate-release-notes.sh +``` + +## 帮助 + +这些脚本现在都支持 `--help` 或 `-h`,包括: + +- `repo-hygiene.sh` +- `release-preflight.sh` +- `pre-release-check.sh` +- `check-skills.sh` +- `clean-generated.sh` +- `package-plugin.sh` +- `package-skills.sh` +- `sync-repo-layout.sh` +- `sync-package-v2.sh` +- `create-draft-release.sh` +- `update-draft-release-assets.sh` +- `generate-release-notes.sh` +- `write-dist-sha256.sh` +- `patch-p115strmhelper-mp-compat.sh` +- `verify-release-preflight-artifact.sh` +- `verify-ci-artifact.sh` +- `verify-release-download.sh` +- `verify-release-assets.sh` +- `verify-dist.sh` +- `verify-skill-dist.sh` +- `print-release-summary.sh` +- `print-skill-release-summary.sh` +- `check-doc-current-state.py` +- `audit-remote-branches.py` +- `archive-local-branches.py` diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md new file mode 100644 index 0000000..fa45285 --- /dev/null +++ b/docs/PACKAGING.md @@ -0,0 +1,227 @@ +# 插件和 Skill ZIP 打包说明 + +开始打包或发布前,先执行一次仓库卫生检查: + +```bash +bash scripts/repo-hygiene.sh +``` + +如果只想快速查维护/发布命令,不想先读完整文档,直接看: + +- `docs/MAINTENANCE_COMMANDS.md` + +如果要直接跑完整发版前检查,执行: + +```bash +bash scripts/release-preflight.sh +``` + +## 目标 + +用于生成可在 MoviePilot 本地上传安装的插件 ZIP 包,以及可复制到外部智能体环境的公开 Skill ZIP 包。 + +打包内容会保留以下标准结构: + +```text +/ + __init__.py + README.md + requirements.txt +``` + +## 一键打包 + +在仓库根目录执行: + +```bash +bash scripts/package-plugin.sh +bash scripts/package-plugin.sh --help +``` + +默认打包 `AIRecognizerEnhancer`。 + +查看当前可打包插件: + +```bash +bash scripts/package-plugin.sh --list +``` + +只打包全部插件、不运行完整发布检查: + +```bash +bash scripts/package-plugin.sh --all +``` + +`--all` 和 `pre-release-check.sh` 会在打包前清理 `dist/*.zip`、`SHA256SUMS.txt` 和 `MANIFEST.json`;完整发布检查还会清理并重建 `dist/skills/`,避免旧版本产物混在发布附件里。 + +`--all` 会在打包后自动生成 `SHA256SUMS.txt`、`MANIFEST.json` 并执行 `scripts/verify-dist.sh`。 + +如需打包其他插件,例如 `AgentResourceOfficer` 或飞书桥接插件: + +```bash +bash scripts/package-plugin.sh AgentResourceOfficer +bash scripts/package-plugin.sh FeishuCommandBridgeLong +``` + +脚本会自动先同步一次官方仓库布局,再生成 ZIP。 + +同步脚本会根据 `package.json` 自动发现根目录中带 `__init__.py` 的源码插件,并同步到 `plugins/` 和 `plugins.v2/`。 + +插件名会优先按 `package.json` 做大小写不敏感匹配。例如 `hdhiveopenapi` 会被规范为 `HdhiveOpenApi`,生成的 ZIP 根目录也会保持标准插件 ID。 + +如果插件代码目录来自 `plugins/` 或 `plugins.v2/`,但说明文档保留在仓库顶层同名目录下,打包脚本会自动把顶层 `README.md` 补进 ZIP。 + +发布前完整检查会一次打包当前仓库清单里的可本地安装插件: + +```bash +bash scripts/pre-release-check.sh +``` + +如果改了 `package.json`,可以先同步派生清单: + +```bash +bash scripts/sync-package-v2.sh +``` + +`pre-release-check.sh` 也会自动运行这个同步脚本;如果 `package.v2.json` 因此发生变化,工作区检查会失败并提示先提交。 + +完整检查会在 `dist/` 下额外生成 `SHA256SUMS.txt` 和 `MANIFEST.json`,用于核对每个 ZIP 的 SHA256,并给自动化脚本读取插件 ID、展示名、版本、文件名和大小。 + +完整检查还会在 `dist/skills/` 下生成公开 Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json`: + +```bash +bash scripts/package-skills.sh +bash scripts/verify-skill-dist.sh +``` + +如需只刷新当前 `dist/*.zip` 的校验清单和机器可读 manifest: + +```bash +bash scripts/write-dist-sha256.sh +``` + +如果只想校验已经生成或从 `Release Preflight` artifact 下载下来的完整发布产物目录: + +```bash +bash scripts/verify-release-assets.sh +bash scripts/verify-dist.sh +bash scripts/verify-skill-dist.sh +``` + +也可以校验其他目录: + +```bash +bash scripts/verify-release-assets.sh /path/to/downloaded-artifact +DIST_DIR=/path/to/downloaded-artifact bash scripts/verify-dist.sh +DIST_DIR=/path/to/downloaded-artifact/skills bash scripts/verify-skill-dist.sh +``` + +如果要生成可复制到 GitHub Release 的 Markdown 表格: + +```bash +bash scripts/print-release-summary.sh +``` + +如果本地运行测试后产生了 `__pycache__`、`.pyc` 或 `.DS_Store`,可以清理生成物: + +```bash +bash scripts/clean-generated.sh +bash scripts/clean-generated.sh --dist +``` + +如果要下载并校验最近一次成功 `Release Preflight` artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +如果要创建 GitHub Draft Release,先 dry-run: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +也可以走 GitHub Actions 手动 dry-run: + +```bash +gh workflow run draft-release.yml -f tag= -f dry_run=true +``` + +当前完整检查覆盖: + +- `AIRecognizerEnhancer` +- `AgentResourceOfficer` +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `QuarkShareSaver` + +完整检查还会校验: + +- 仓库内发布脚本和 Skill shell helper 必须能通过 shell 语法检查 +- 插件代码和仓库内 Skill helper 脚本必须能通过 Python 语法检查 +- `AgentResourceOfficer` 和 `hdhive-search-unlock-to-115` Skill helper 的本地 `selftest` 必须通过 +- `AgentResourceOfficer` Skill 的 `external-agent` 入口必须能输出 `external_agent.v1`、3 个最小工具和有效 `EXTERNAL_AGENTS.md`;`workbuddy` 仅作为兼容别名保留,并已标记为 deprecated。 +- `AgentResourceOfficer` 和 `hdhive-search-unlock-to-115` Skill helper 版本必须同步到 README 和 CHANGELOG +- `AgentResourceOfficer` 和 `hdhive-search-unlock-to-115` Skill 安装脚本的 `--dry-run` 必须通过 +- 如果设置 `RUN_AGENT_RESOURCE_OFFICER_LIVE_SMOKE=1`,完整检查还会执行 `scripts/smoke-agent-resource-officer.py --include-search`,对本机 MoviePilot 做真实只读 smoke +- 以上 Skill 检查可以单独运行 `bash scripts/check-skills.sh` +- 发布脚本中的插件清单必须和 `package.json` 一致 +- `package-plugin.sh --list` 输出必须和发布插件清单一致 +- `package.json` 插件市场展示字段和图标文件必须存在 +- `package.json` 中 `version`、`labels`、`level`、`history` 等字段类型必须符合预期 +- `package.json` 中每个插件必须标记 `v2: true` +- `package.json` 当前版本必须出现在对应插件的 `history` 中 +- `package.json` 中每个插件都必须能在根目录、`plugins/` 或 `plugins.v2/` 找到 `__init__.py` +- 仓库首页 `README.md` 必须列出 `package.json` 中每个插件的 ID、展示名和当前版本 +- `docs/PLUGIN_INSTALL.md` 必须列出当前版本对应的 ZIP 文件名 +- `dist/SHA256SUMS.txt` 必须随 ZIP 一起生成 +- `dist/MANIFEST.json` 必须随 ZIP 一起生成 +- `dist/skills/` 必须生成公开 Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json` +- `scripts/verify-dist.sh` 必须能验证 ZIP SHA256、MANIFEST、插件元数据、基础目录结构和不应发布的生成文件 +- `scripts/verify-skill-dist.sh` 必须能验证 Skill ZIP SHA256、MANIFEST、基础目录结构、不应发布的生成文件,以及 `agent-resource-officer` ZIP 解压后的 `external-agent` 入口 +- `scripts/verify-release-assets.sh` 必须能一次校验插件 ZIP 和 Skill ZIP +- `scripts/verify-release-preflight-artifact.sh` 必须能下载并校验 GitHub Actions artifact +- `scripts/print-release-summary.sh` 必须能基于 `MANIFEST.json` 输出 Release Markdown 表格 +- `scripts/generate-release-notes.sh` 必须能生成统一 Release 正文,并包含 `external-agent / external-agent --full` 重点 +- `.github/workflows/ci.yml` 和 `draft-release.yml` 必须使用 artifact 上传步骤,并包含插件 ZIP、Skill ZIP、`SHA256SUMS.txt`、`MANIFEST.json` +- `draft-release.yml` 必须保留手动触发、`dry_run` 输入和创建 Draft Release 所需的 `contents: write` 权限 +- Markdown 文档中的本地相对链接必须存在 +- 仓库文本中不能包含已知本机路径、历史密码、历史 API Key 或 Bearer JWT 片段 +- 每个 ZIP 必须包含 `/__init__.py` +- 每个 ZIP 必须包含 `/README.md` +- ZIP 中不能包含 `__pycache__`、`.pyc`、`.pyo`、`.DS_Store` + +## 输出位置 + +打包结果输出到: + +```text +dist/ +dist/skills/ +``` + +文件名格式: + +```text +-.zip +``` + +例如: + +```text +AgentResourceOfficer-<当前版本>.zip +``` + +## 使用方式 + +1. 打开 MoviePilot +2. 进入 设置 -> 插件 +3. 选择本地安装插件 +4. 上传 `dist/` 下生成的 ZIP 文件 + +## 注意事项 + +- `plugin_version` 取自目标插件目录下的 `__init__.py` +- 如果改了版本号,重新运行脚本即可生成对应文件名 +- `dist/` 目录默认不纳入 Git 版本管理 +- 提交前建议以 `bash scripts/release-preflight.sh` 作为最终验收 diff --git a/docs/PLUGIN_INSTALL.md b/docs/PLUGIN_INSTALL.md new file mode 100644 index 0000000..456011f --- /dev/null +++ b/docs/PLUGIN_INSTALL.md @@ -0,0 +1,188 @@ +# 插件安装说明 + +这份文档只讲普通用户怎么安装、先装什么、装完从哪里开始。 + +如果你只是新手,不需要看打包、发布、维护命令。 + +--- + +## 先装哪两个 + +优先安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这两个就是当前主线: + +- `Agent影视助手`:飞书命令入口、外部智能体入口、盘搜、影巢、115、夸克、MP/PT 下载。 +- `AI识别增强`:MoviePilot 原生识别失败时,用 LLM 做一层兜底。 + +旧插件可以先不装。 + +--- + +## 插件仓库安装 + +在 MoviePilot 插件市场里添加自定义插件仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +然后在插件市场安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这是最推荐的安装方式。 + +--- + +## 本地 ZIP 安装 + +如果你拿到的是 Release 里的 ZIP 包,也可以在 MoviePilot 插件页本地上传安装。 + +普通用户只需要优先认这两个包: + +```text +AgentResourceOfficer-0.2.68.zip +AIRecognizerEnhancer-0.1.12.zip +``` + +其他旧插件包只用于兼容旧链路,新装一般不用优先安装。 + +当前 Release 里还可能看到这些旧插件包: + +```text +FeishuCommandBridgeLong-0.5.26.zip +HdhiveOpenApi-0.3.0.zip +QuarkShareSaver-0.1.0.zip +``` + +--- + +## 装完 Agent影视助手后做什么 + +打开 `Agent影视助手` 设置页面,按你要用的功能填写: + +| 你想用的功能 | 需要配置 | +|---|---| +| 飞书命令入口 | 飞书应用的 `App ID` / `App Secret` | +| 盘搜搜索 | `盘搜 API 地址` | +| 影巢搜索 | `影巢 OpenAPI Key` | +| 115 转存 | `115 默认目录`,然后发 `115登录` 扫码 | +| 夸克转存 | 夸克 Cookie 或 CookieCloud | +| PT 下载 | 通常依赖 MoviePilot 原生下载器;MP 和 qB 不同机时可填 `PT 下载保存路径` | + +不用的功能可以先不填,插件会自动跳过。 + +--- + +## 不接智能体,只用飞书 + +如果你不使用外部智能体,只想把飞书当成命令入口: + +1. 在插件设置页配好飞书。 +2. 确认只保留一个飞书入口监听,避免旧飞书插件和新插件同时收消息。 +3. 直接在飞书里发命令。 + +常用命令: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 接外部智能体 + +如果你要让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体控制 MoviePilot,安装插件后还要让智能体安装 `agent-resource-officer skill / helper`。 + +最短路径: + +1. MoviePilot 安装并启用 `Agent影视助手`。 +2. 把 [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) 里的提示词发给你的智能体。 +3. 智能体按文档安装 skill,并填写: + +```text +ARO_BASE_URL=http://你的MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS、智能体在 Win / Mac,请看: + +[跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) + +### MCP 怎么办 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,也可以同时接: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +建议分工: + +- 查插件列表、下载器状态、站点状态、历史记录、工作流这类 MoviePilot 管理信息,可以优先用 MCP。 +- 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、Cookie 修复,继续优先用 `agent-resource-officer skill / helper`。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也继续优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +--- + +## AI识别增强怎么用 + +`AI识别增强` 不需要额外 Gateway。 + +它直接复用 MoviePilot 当前已经启用的 LLM 配置,在原生文件名识别失败时做兜底,然后把结果交回 MoviePilot 原生整理链。 + +详细说明见:[AI识别增强](../AIRecognizerEnhancer/README.md) + +--- + +## 旧插件还要不要装 + +新装一般不需要优先安装旧插件。 + +| 旧插件 | 用途 | 建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 旧影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 旧夸克独立转存 | 主能力已收进 Agent影视助手 | + +如果你是老环境迁移,可以暂时保留;如果是新装,先用 `Agent影视助手`。 + +--- + +## 维护者文档 + +如果你只是普通用户,到这里就够了。 + +资源主线:`Agent影视助手 / AgentResourceOfficer 0.2.68` + +当前 Skill helper:`agent-resource-officer 0.1.46` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +维护命令路径:`docs/MAINTENANCE_COMMANDS.md` + +如果你要打包、发布或维护仓库,再看: + +- [维护命令](./MAINTENANCE_COMMANDS.md) +- [发布检查](./RELEASE_CHECKLIST.md) +- [打包说明](./PACKAGING.md) diff --git a/docs/REBUILD_AGENT_SUITE.md b/docs/REBUILD_AGENT_SUITE.md new file mode 100644 index 0000000..b2d52be --- /dev/null +++ b/docs/REBUILD_AGENT_SUITE.md @@ -0,0 +1,127 @@ +# 重构计划:Agent Suite + +> 这是历史重构规划文档,主要用于回看设计演进。 +> 当前安装、接入、发布请优先看 `README.md`、`docs/INDEX.md`、`docs/PLUGIN_INSTALL.md`、`docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md`。 + +这个仓库接下来不再继续沿着“功能越分越散”的方向增长,而是进入一次有边界的重构: + +- 保留旧插件作为可运行的 `legacy` 参考 +- 在新分支上并行重建两套新插件 +- 等新插件链路跑稳后,再逐步归档旧插件 + +当前重构分工如下: + +## 目标插件 + +### 1. Agent影视助手 + +定位: + +- 智能体友好的资源工作流主插件 +- 对外统一承接搜索、选择、解锁、转存、签到、远程消息入口 + +计划整合的现有能力: + +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `HDHiveDailySign` +- `QuarkShareSaver` + +首期能力边界: + +- 盘搜 / 影巢搜索与候选选择 +- 影巢资源解锁 +- 115 / 夸克自动转存 +- 通用分享链接路由 +- MP 原生 Agent Tool / 插件 API / 智能体会话入口 +- 飞书桥接后续按需委托 + +### 2. AI识别增强 + +定位: + +- MoviePilot 原生识别失败后的本地 AI 识别增强插件 +- 不再依赖外部 AI Gateway 作为必经链路 + +计划承接的现有能力: + +- 已全部收敛到 `AIRecognizerEnhancer` + +首期能力边界: + +- 识别失败事件兜底 +- 直接调用 MP 内置 LLM 配置进行结构化识别 +- 自动二次整理 +- 为后续“自定义识别词建议”预留扩展点 + +## 旧插件处理原则 + +重构期间,以下目录优先保留;自用魔改和旧签到插件可逐步从公开仓库下架: + +- `FeishuCommandBridgeLong` +- `HdhiveOpenApi` +- `QuarkShareSaver` + +处理原则: + +- 旧插件继续作为线上可运行版本 +- `FeishuCommandBridgeLong` 当前继续保留为兼容飞书入口,不做删除 +- `HDHiveDailySign`、`ZspaceMediaFreshMix` 更适合本地自用,不再作为公开主线插件继续发布 +- 新功能尽量优先落到新插件设计里 +- 旧插件只做必要修复,不再继续扩张边界 + +## 迁移顺序 + +建议按下面顺序逐步迁移,避免同时重写太多链路: + +1. `Agent影视助手` 先完成目录骨架、配置模型和入口设计 +2. 先搬入 `QuarkShareSaver` 的稳定执行能力 +3. 再搬入 `HdhiveOpenApi` 的搜索、解锁、转存能力 +4. 接入原生 Agent Tool 与统一 API +5. 最后把 `FeishuCommandBridgeLong` 收缩为消息入口和会话层 +6. 单独重写 `AI识别增强` + +## 为什么不是一个插件 + +这次明确不做“超级大插件”,原因很实际: + +- 搜索/转存/签到属于资源工作流 +- 识别失败兜底属于整理工作流 +- 两类逻辑耦合过深后,配置、排障和升级成本都会显著升高 + +最终目标是: + +- 对外看起来像一套统一产品 +- 仓库内部保留两个清晰边界 + +## 分支与备份 + +本次重构采用: + +- 备份归档后再开新分支 +- 在 `codex/rebuild-agent-suite` 上推进 + +仓库外备份文件已单独存放,作为重构前快照。 + +## 当前状态 + +当前已完成: + +- 仓库快照备份 +- 重构分支创建 +- `Agent影视助手` 目录、配置模型、执行层、统一 API 已落地 +- `Agent影视助手` 已接通影巢搜索/解锁、115 转存、夸克转存、盘搜搜索与直链路由 +- `Agent影视助手` 已接通原生 Agent Tool 和智能体会话式 API +- `Agent影视助手` 已补齐影巢候选分页与 `详情` / `审查` 按需补主演,飞书新主线不再缺这段交互 +- `Agent影视助手` 已补齐 `P115StrmHelper` 新版 MoviePilot 兼容补丁脚本,115 健康检查已验证 `p115_ready=true` +- `Agent影视助手` 已新增 115 轻量直转层,分享链接落盘可优先不走 `P115StrmHelper.sharetransferhelper`,失败时再回退旧执行层 +- `FeishuCommandBridgeLong` 保持线上可运行,默认继续走 `legacy` 快路径 +- `FeishuCommandBridgeLong` 已支持切换到 `auto`,把智能入口委托给 `Agent影视助手` +- 运行环境已完成双链路验证:`legacy` 日常可用,`auto` 可接手统一资源工作流 +- `AIRecognizerEnhancer` 已进入 `0.1.11` 阶段,可直接复用 MoviePilot 当前 LLM 配置,在 `NameRecognize` 阶段做本地结构化兜底,并支持失败样本维护、样本洞察、精简摘要、直接转建议、批量建议、写入动作、样本出队、样本复查和批量复查;当识别词建议模型退化时会自动切到精确规则兜底 + +下一步重点: + +1. 继续把影巢签到、用户态、配额态能力评估是否并入 `Agent影视助手` +2. 继续打磨 `AIRecognizerEnhancer` 的提示词、失败样本洞察和识别词建议质量 +3. 继续完善 `AgentResourceOfficer` Skill 与外部智能体的低 token、可恢复、可审计调用链路 diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..ba13699 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,209 @@ +# Release Checklist + +发布前按这个顺序执行,避免漏包、错包或上传旧 ZIP。 + +如果你只想走一条完整命令,直接执行: + +```bash +bash scripts/release-preflight.sh +``` + +它会先跑 `repo-hygiene.sh`,再跑 `pre-release-check.sh`。 + +如果你只想快速查维护/发布命令,不想通读整份清单,直接看: + +- `docs/MAINTENANCE_COMMANDS.md` + +## 1. 确认工作区 + +```bash +git status --short --branch +``` + +工作区应当干净。 + +## 2. 查看插件清单 + +```bash +bash scripts/package-plugin.sh --list +``` + +确认输出的插件和版本符合本次发布预期。 + +同时确认当前对外文档没有落后: + +- `README.md` +- `docs/INDEX.md` +- `docs/PLUGIN_INSTALL.md` +- `docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md` +- `docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md` + +## 3. 执行完整检查 + +如果只改了 Skill,可以先跑轻量检查: + +```bash +bash scripts/check-skills.sh +``` + +最终发布前仍然执行完整检查: + +```bash +bash scripts/release-preflight.sh +``` + +这个命令会先跑 `repo-hygiene.sh`,再执行 `pre-release-check.sh`;后者会同步 `plugins/` 和 `plugins.v2/`,检查元数据、Skill helper、ZIP 内容,并重新生成插件 ZIP、Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json`。 + +其中也会自动执行: + +```bash +python3 scripts/check-doc-current-state.py +``` + +用来校验当前状态文档中的插件版本、helper 版本和 release URL 没有落后于代码。 + +如果本机已经跑着可访问的 MoviePilot,并且 `~/.config/agent-resource-officer/config` 已配置 `ARO_BASE_URL` / `ARO_API_KEY`,建议追加一次真实链路检查: + +```bash +RUN_AGENT_RESOURCE_OFFICER_LIVE_SMOKE=1 bash scripts/pre-release-check.sh +``` + +## 4. 上传 ZIP + +Release 附件上传 `dist/` 下的插件 ZIP、`dist/skills/` 下的 Skill ZIP。创建 Draft Release 时,脚本会把校验文件改名为唯一附件名,避免 GitHub Release 附件重名: + +- `PLUGIN_SHA256SUMS.txt` +- `PLUGIN_MANIFEST.json` +- `SKILL_SHA256SUMS.txt` +- `SKILL_MANIFEST.json` + +本地核对命令: + +```bash +ls -1 dist/*.zip +ls -1 dist/skills/*.zip +cat dist/SHA256SUMS.txt +cat dist/MANIFEST.json +cat dist/skills/SHA256SUMS.txt +cat dist/skills/MANIFEST.json +bash scripts/verify-release-assets.sh +bash scripts/verify-dist.sh +bash scripts/verify-skill-dist.sh +bash scripts/print-release-summary.sh +bash scripts/print-skill-release-summary.sh +``` + +不要上传历史旧包。`pre-release-check.sh` 会在打包前清理旧 ZIP。 + +## 5. 远端确认 + +推送后确认 GitHub Actions 通过: + +```bash +gh run list --limit 3 +``` + +`Release Preflight` workflow 通过后会在该 run 的 Artifacts 区域生成 `moviepilot-release-assets-`,里面包含本次插件 ZIP、Skill ZIP、`SHA256SUMS.txt` 和 `MANIFEST.json`。Draft Release 附件中的校验文件会使用 `PLUGIN_` / `SKILL_` 前缀避免重名。 + +如需在本地下载并校验最近一次成功 `Release Preflight` artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +也可以指定 run id: + +```bash +bash scripts/verify-release-preflight-artifact.sh 25017759143 +``` + +如果已经从 GitHub Release 页面下载了全部附件,也可以直接校验下载目录: + +```bash +bash scripts/verify-release-download.sh +bash scripts/verify-release-assets.sh /path/to/release-assets +``` + +如果 Draft Release 已存在,需要用当前 `dist/` 重新覆盖 notes 和附件: + +```bash +bash scripts/update-draft-release-assets.sh --skip-check +``` + +也可以在 GitHub 页面手动运行:Actions -> Release Preflight -> Run workflow。 + +## 6. 创建 Draft Release + +先 dry-run,确认附件和说明能生成: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +确认无误后创建 GitHub Draft Release: + +```bash +bash scripts/create-draft-release.sh +``` + +也可以在 GitHub Actions 手动触发: + +```bash +gh workflow run draft-release.yml -f tag= -f dry_run=true +``` + +dry-run 通过后会生成 `moviepilot-release-assets--` artifact,可先下载核对。确认无误后,再用 `dry_run=false` 创建 Draft Release。 + +## 7. 发布正式 Release + +Draft Release 核对无误后发布正式 Release: + +```bash +gh release edit --draft=false --latest --target main +``` + +发布后确认状态、tag 和公开附件: + +```bash +gh release view --json tagName,isDraft,isPrerelease,url,publishedAt,targetCommitish +git ls-remote --tags origin "refs/tags/" +bash scripts/verify-release-download.sh +``` + +正式发布后,`isDraft` 应为 `false`,公开下载校验必须通过。 + +## 8. 发布后清理 + +发布完成后,顺手清理本地过期的远端引用,并检查是否有已经不再需要的发布分支: + +```bash +git fetch --prune origin +git branch -r +python3 scripts/audit-remote-branches.py +``` + +如果远端已经收干净,但本地还留着大量历史分支,可先看 dry-run: + +```bash +python3 scripts/archive-local-branches.py +``` + +确认无误后再执行: + +```bash +python3 scripts/archive-local-branches.py --apply +``` + +这个脚本会先把本地历史分支转成 `archive/` 本地 tag,再删除分支名。 + +如果只是想一条命令快速看当前仓库分支卫生状态,可以执行: + +```bash +bash scripts/repo-hygiene.sh +``` + +注意: + +- 远端分支如果是通过 `squash merge` 合并,`git merge-base --is-ancestor` 不能直接作为删分支依据。 +- 删除前先确认该分支没有关联 PR,且不再需要保留为历史参考。 +- 如果只是本地看到“远端分支还在”,先 `fetch --prune`,不要直接假设远端没清理。 diff --git a/docs/RELEASE_v2.0.0-alpha.1.md b/docs/RELEASE_v2.0.0-alpha.1.md new file mode 100644 index 0000000..30330a0 --- /dev/null +++ b/docs/RELEASE_v2.0.0-alpha.1.md @@ -0,0 +1,92 @@ +# v2.0.0-alpha.1 历史发布文案 + +> 这是旧 AI Gateway 拆分阶段的历史发布草稿,仅保留作归档参考。 +> 当前仓库已经演进为多插件套件,发布前请以 `docs/GITHUB_PUBLISH.md` 和 `scripts/release-preflight.sh` 为准。 + +## GitHub Release 页面填写 + +### Tag version + +```text +v2.0.0-alpha.1 +``` + +### Release title + +```text +v2.0.0-alpha.1 首个拆分仓库版本 +``` + +### 是否勾选 Pre-release + +建议: + +- 勾选 + +因为当前版本仍属于 `alpha` 阶段。 + +### 是否勾选 latest + +建议: + +- 不要手动强调为稳定版 + +## 建议上传的附件 + +建议在 GitHub Release 页面上传这个 ZIP: + +```text +dist/AIRecoginzerForwarder-v2.0.0-alpha.1.zip +``` + +这个 ZIP 已经是可用于 MoviePilot 本地安装的插件包。 + +## Tag + +```text +v2.0.0-alpha.1 +``` + +## Title + +```text +v2.0.0-alpha.1 首个拆分仓库版本 +``` + +## Release Notes + +```md +## v2.0.0-alpha.1 首个拆分仓库版本 + +这是 `MoviePilot-Plugins` 仓库的首个 `v2.0` alpha 版本。 + +本版本的目标,是将 MoviePilot 插件本体从运行时网关中拆分出来,形成更适合 GitHub 和 NAS 用户使用的双仓库发布结构。 + +## 本版本包含 + +- 独立插件仓库结构 +- AI Gateway 对接配置 +- 异步回调处理 +- 二次整理触发逻辑 +- `standard` / `enhanced` 识别增强模式 + +## 当前定位 + +- 插件仓库只负责 MoviePilot 插件本体 +- Gateway 运行时由独立镜像仓库提供 +- 默认与 `moviepilot-ai-recognizer-gateway` 配套使用 + +## 适用场景 + +- MoviePilot 原生识别失败补救 +- PT 资源标准命名识别 +- 网盘拼音、漏词、规避命名识别 +- 本地文件与云盘挂载回调后二次整理 + +## 首发建议 + +- Release 页面上传插件 ZIP +- 仓库安装与本地 ZIP 安装都保留 +- 插件默认推荐与 `moviepilot-ai-recognizer-gateway` 配套使用 +- 默认更推荐同机 Docker / 同网络部署,不建议默认走跨主机方案 +``` diff --git a/icons/agentresourceofficer.png b/icons/agentresourceofficer.png new file mode 100644 index 0000000..790c73d Binary files /dev/null and b/icons/agentresourceofficer.png differ diff --git a/icons/agentresourceofficer.svg b/icons/agentresourceofficer.svg new file mode 100644 index 0000000..398914b --- /dev/null +++ b/icons/agentresourceofficer.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/airecoginzerforwarder.png b/icons/airecoginzerforwarder.png new file mode 100644 index 0000000..67f549f Binary files /dev/null and b/icons/airecoginzerforwarder.png differ diff --git a/icons/airecoginzerforwarder.svg b/icons/airecoginzerforwarder.svg new file mode 100644 index 0000000..05b94f5 --- /dev/null +++ b/icons/airecoginzerforwarder.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/airecognizerenhancer.svg b/icons/airecognizerenhancer.svg new file mode 100644 index 0000000..b7fa394 --- /dev/null +++ b/icons/airecognizerenhancer.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icons/feishucommandbridgelong.png b/icons/feishucommandbridgelong.png new file mode 100644 index 0000000..2322d01 Binary files /dev/null and b/icons/feishucommandbridgelong.png differ diff --git a/icons/hdhive.ico b/icons/hdhive.ico new file mode 100644 index 0000000..c093cc5 Binary files /dev/null and b/icons/hdhive.ico differ diff --git a/icons/quark.ico b/icons/quark.ico new file mode 100644 index 0000000..eb8eb5e Binary files /dev/null and b/icons/quark.ico differ diff --git a/package.json b/package.json index f0c656a..183ddbc 100644 --- a/package.json +++ b/package.json @@ -1099,5 +1099,274 @@ "0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。", "0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。" } + }, + "AgentResourceOfficer": { + "name": "Agent影视助手", + "description": "统一承接影巢搜索/解锁、115 转存、夸克转存、飞书入口与智能体接口的资源工作流主插件。", + "labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁", + "version": "0.2.68", + "icon": "agentresourceofficer.png", + "author": "liuyuexi1987", + "level": 1, + "v2": true, + "history": { + "0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。", + "0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。", + "0.2.66": "为 request_templates 增加三类入口的 entry_playbooks,直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。", + "0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。", + "0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。", + "0.2.63": "为 compact 顶层短命令增加执行语义字段:command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred;外部智能体现在可以机械判断 直接读 还是 先确认再写。", + "0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。", + "0.2.61": "为 compact 失败回执增加统一 error_summary;外部智能体现在可以直接读取失败标签、建议说明,以及 preferred_command / compact_commands 这样的最短恢复命令。", + "0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commands;mp_recent_activity 也补齐 followup_summary,外部智能体可直接读取最短下一步命令。", + "0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。", + "0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。", + "0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary;执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。", + "0.2.56": "把评分后的确认提示下沉为统一 decision 摘要,score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。", + "0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。", + "0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。", + "0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose,并让生命周期/执行后追踪统一返回 diagnosis_summary。", + "0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup,而不是退回会话检查或新任务。", + "0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe,外部智能体可以通过低 token 模板直接续接执行后追踪。", + "0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint,方便外部智能体一跳续接执行后追踪。", + "0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。", + "0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。", + "0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint,让外部智能体执行计划后能直接读取建议下一步。", + "0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck,并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。", + "0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。", + "0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。", + "0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。", + "0.2.42": "补齐 compact session/recover 协议里的 action_templates;外部智能体读取会话状态或恢复入口时,也能拿到完整的结构化下一步模板。", + "0.2.41": "补齐 PT 只读会话的 action_templates;下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。", + "0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。", + "0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。", + "0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。", + "0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe,外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。", + "0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。", + "0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。", + "0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。", + "0.2.33": "统一 MP 原生命令前缀解析;下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。", + "0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。", + "0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary,避免外部智能体误读上下文。", + "0.2.30": "细化评分风险结构:hard_risk_reasons 表示真正阻断自动化的风险,risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。", + "0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。", + "0.2.28": "插件展示名统一改为 Agent影视助手,并同步仓库文档、Skill 文案和兼容插件引用。", + "0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。", + "0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id,再确认执行。", + "0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。", + "0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。", + "0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。", + "0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。", + "0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。", + "0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。", + "0.2.19": "新增 MP 搜索结果详情入口;MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。", + "0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。", + "0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。", + "0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。", + "0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。", + "0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。", + "0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。", + "0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。", + "0.2.11": "MP 下载/订阅命令支持无空格自然写法,例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”;自然语言写入默认生成 plan_id,确认后才执行。", + "0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”,飞书与智能体可不用结构化 mode 参数。", + "0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。", + "0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。", + "0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。", + "0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。", + "0.2.05": "新增低 token score_summary,帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。", + "0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status,并在未初始化时优先提示保存偏好。", + "0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。", + "0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。", + "0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。", + "0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。", + "0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。", + "0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。", + "0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。", + "0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。", + "0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state,避免把已禁用但仍加载的旧插件误判为冲突。", + "0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议,方便判断是否能从旧桥接迁移。", + "0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。", + "0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。", + "0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。", + "0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health,支持内置智能助手检查飞书入口状态。", + "0.1.108": "内置可选飞书入口 Channel,并为 assistant 回执补充 write_effect/error_code 标准字段。", + "0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。", + "0.1.106": "assistant/startup 会带 recommended_request_templates,外部智能体启动后可直接按推荐参数拉取低 token 模板流程。", + "0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。", + "0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message,方便外部智能体在写入前提示用户确认。", + "0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。", + "0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。", + "0.1.101": "推荐调用会带 url_template,外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。", + "0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey,避免外部智能体误用 Bearer 鉴权。", + "0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。", + "0.1.98": "recommended_recipe_detail 会带 first_call,直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。", + "0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail,直接给出推荐流程的首个模板、确认模板和写入模板,外部智能体可直接照此编排。", + "0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason,外部智能体不必再自己挑选最适合的 recipe。", + "0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds,自检也会验证这些汇总特征。", + "0.1.94": "assistant/request_templates 回执会带场景化 recipes,外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。", + "0.1.93": "assistant/request_templates 回执会带 recommended_sequence,直接给出推荐调用顺序,外部智能体可以少做一层启动编排。", + "0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_seconds,execution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates,方便外部智能体决定缓存策略。", + "0.1.91": "assistant/request_templates 支持 include_templates=false,可只返回模板名、无效项和执行策略,进一步减少 token。", + "0.1.90": "请求模板协议增加 schema_version=request_templates.v1,startup/toolbox 也携带 request_templates_schema_version,方便外部智能体做兼容判断。", + "0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。", + "0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation,外部智能体可区分只读、dry-run、计划写入和真实执行动作。", + "0.1.87": "request_templates 每个模板都会带 description,外部智能体可以直接判断模板用途,减少额外解释和 token 消耗。", + "0.1.86": "request_templates 每个模板都会带 tool_args,区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。", + "0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。", + "0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit,方便结构化智能体直接用 body 请求过滤模板。", + "0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。", + "0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names;原生 Tool 同步支持 names 参数。", + "0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool,外部智能体可只拉请求模板而不拉完整启动包。", + "0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates,并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。", + "0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body,外部智能体无需猜维护调用方式。", + "0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history,方便外部智能体审计维护动作;GET dry-run 仍不写历史。", + "0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。", + "0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run,即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。", + "0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。", + "0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。", + "0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain,支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。", + "0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions,外部智能体可直接判断是否值得做低风险维护清理。", + "0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all,避免外部智能体把全部计划数误判为待执行计划数。", + "0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。", + "0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。", + "0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates,外部智能体可拿启动包直接执行推荐恢复动作。", + "0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup,一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议,减少外部智能体开场多次探测。", + "0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck,便于外部智能体开场自检协议健康。", + "0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool,让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。", + "0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。", + "0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。", + "0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。", + "0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true,外部智能体原样回放模板即可保持低 token。", + "0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。", + "0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。", + "0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。", + "0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。", + "0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。", + "0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。", + "0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。", + "0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。", + "0.1.52": "assistant/recover 新增 compact=true 低 token 回执,agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。", + "0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。", + "0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。", + "0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan,外部智能体可按恢复协议直接续跑。", + "0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session,便于从会话列表直接恢复。", + "0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。", + "0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作,action_templates.action_body 可原样回传执行计划。", + "0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。", + "0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。", + "0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。", + "0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。", + "0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。", + "0.1.40": "新增 assistant/history 与 history Tool,记录最近批量动作和预设工作流执行摘要,便于外部智能体判断进度、排障和恢复上下文。", + "0.1.39": "新增 assistant/readiness 与 readiness Tool,外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示,再决定是否开始执行。", + "0.1.38": "新增 assistant/workflow 与 run_workflow Tool,外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。", + "0.1.37": "新增 assistant/actions 与 execute_actions Tool,外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。", + "0.1.36": "新增 assistant/action 与 execute_action Tool,外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。", + "0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates,外部智能体可直接按返回模板继续调用,不再自己拼下一步参数。", + "0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。", + "0.1.33": "新增活跃会话列表 API 与原生 Tool,并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。", + "0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。", + "0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。", + "0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool,便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。", + "0.1.29": "新增 Agent影视助手 帮助 Tool,并让统一智能入口在空输入或帮助语义下直接返回推荐用法,降低 MP 智能助手首次调用门槛。", + "0.1.28": "新增 Agent影视助手 统一智能入口原生 Tool:smart_entry / smart_pick,MP 智能助手可直接复用飞书同款处理/选择主链。", + "0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool,避免仍显示骨架态提示。", + "0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。", + "0.1.25": "新增 115 待处理任务标准 API:查看、继续、取消,便于飞书、CLI 与外部脚本直接调用。", + "0.1.24": "新增 115 待处理任务原生 Agent Tool:查看、继续、取消,MP 智能助手可直接调用待处理任务能力。", + "0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。", + "0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。", + "0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。", + "0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录,可自动继续上次未完成的 115 操作。", + "0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。", + "0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。", + "0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。", + "0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。", + "0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。", + "0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。", + "0.1.13": "新增 115 扫码登录原生 Agent Tool,智能助手可直接发起二维码并轮询登录状态。", + "0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。", + "0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。", + "0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本,115 健康检查已验证可用。", + "0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。", + "0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。", + "0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API,让 Agent影视助手 开始承接用户态能力。", + "0.1.6": "新增 Agent影视助手 自己的智能入口 API,支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。", + "0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。", + "0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。", + "0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。", + "0.1.2": "新增原生 Agent Tool:影巢会话搜索、会话继续选择、通用分享链接路由。", + "0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。", + "0.1.0": "首个可用版本,已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。" + } + }, + "FeishuCommandBridgeLong": { + "name": "飞书命令桥接", + "description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。", + "labels": "飞书,长连接,115,影巢,夸克,智能体,命令", + "version": "0.5.26", + "icon": "feishucommandbridgelong.png", + "author": "liuyuexi1987", + "level": 1, + "v2": true, + "history": { + "0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。", + "0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。", + "0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。", + "0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。", + "0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。", + "0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。", + "0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。", + "0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。", + "0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片,也支持回复检查115登录继续轮询 Agent影视助手 会话。", + "0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。", + "0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。", + "0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。", + "0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。", + "0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手,影巢/115/夸克开始走新主干。", + "0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。", + "0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。", + "0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。", + "0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。", + "0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。", + "0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。", + "0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。", + "0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。", + "0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。", + "0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。", + "0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。", + "0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。", + "0.5.0": "新增处理/选择双命令与智能体 API,统一分流夸克链接、115 链接与影巢搜索解锁流程。", + "0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。", + "0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。", + "0.2.3": "统一插件身份为 FeishuCommandBridgeLong,修复插件市场安装状态匹配。", + "0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。" + } + }, + "HdhiveOpenApi": { + "name": "影巢 OpenAPI", + "description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。", + "labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到", + "version": "0.3.0", + "icon": "hdhive.ico", + "author": "liuyuexi1987", + "level": 1, + "v2": true, + "history": { + "0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。" + } + }, + "QuarkShareSaver": { + "name": "夸克分享转存", + "description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。", + "labels": "夸克,Quark,分享,转存,网盘,智能体,飞书", + "version": "0.1.0", + "icon": "quark.ico", + "author": "liuyuexi1987", + "level": 1, + "v2": true, + "history": { + "0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。" + } } } diff --git a/package.v2.json b/package.v2.json index da0a17e..fefbf07 100644 --- a/package.v2.json +++ b/package.v2.json @@ -124,7 +124,7 @@ "history": { "v2.1.9": "更新依赖库", "v2.1.8": "修复 OpenAI API >=1.0.0 兼容性问题", - "v2.1.7":"独立安装OpenAi SDK依赖", + "v2.1.7": "独立安装OpenAi SDK依赖", "v2.1.6": "支持自定义辅助识别提示词", "v2.1.5": "兼容一些模型返回json数据信息用markdown语法包裹的情况", "v2.1.4": "不处理http链接", @@ -541,7 +541,7 @@ "v2.0.1": "支持独立的订阅链接配置, 覆写代理组和出站代理; 优化数据结构; 修复分享链接解析问题", "v1.4.2": "优化移动端 UI; 支持显示节点链接", "v1.4.1": "修复配置模板保存错误, 请重新配置Clash模板", - "v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards", + "v1.4.0": "优化 UI; 支持连接多个 Clash Dashboards", "v1.3.3": "通过emoji识别国家; 按国家分组节点; mrs格式支持", "v1.3.2": "注册插件动作", "v1.3.1": "支持配置 Hosts", @@ -662,8 +662,7 @@ "v0.2.0": "优化配置页UI布局,修复回复消息携带多余类型前缀的问题", "v0.1.0": "初始版本" } - } -, + }, "AIRecognizerEnhancer": { "name": "AI识别增强", "description": "直接复用 MoviePilot 当前 LLM 配置,在原生识别失败后做本地结构化识别兜底,并交回原生链路继续二次识别。", @@ -687,5 +686,270 @@ "0.1.1": "新增失败样本查看、自定义识别词建议和一键追加写入能力,让 AI 识别增强开始和 MoviePilot 原生 CustomIdentifiers 闭环联动。", "0.1.0": "首个可用版本,复用 MoviePilot 当前 LLM 配置,在原生识别失败后通过 Chain NameRecognize 做本地结构化兜底。" } + }, + "AgentResourceOfficer": { + "name": "Agent影视助手", + "description": "统一承接影巢搜索/解锁、115 转存、夸克转存、飞书入口与智能体接口的资源工作流主插件。", + "labels": "Agent,影巢,HDHive,115,夸克,Quark,智能体,转存,解锁", + "version": "0.2.68", + "icon": "agentresourceofficer.png", + "author": "liuyuexi1987", + "level": 1, + "history": { + "0.2.68": "收口云盘搜索/转存/影巢签到恢复链:固定“转存/下载/云盘搜索/更新检查”口径,补齐 115/夸克默认目录清理、影巢立即签到与 Cookie 一键修复命令,并同步主页与 Skill 文档。", + "0.2.67": "收口外部智能体入口细节:隐藏 workbuddy_quickstart 旧 recipe 展示名,为 external-agent / commands 增加 deprecated alias 语义,并统一当前状态文档。", + "0.2.66": "为 request_templates 增加三类入口的 entry_playbooks,直接给出 helper 命令、HTTP 端点、Tool 名称和推荐读取字段,进一步减少外部智能体与 MP 内置智能体的接入编排逻辑。", + "0.2.65": "为 request_templates 和 helper 增加模板编排元数据,明确服务端/客户端角色、三类入口范式,以及 startup -> decide -> route -> followup 的推荐最小执行流。", + "0.2.64": "把外部智能体执行契约与最小执行循环下沉到 request_templates 返回;新接入的智能体现在可以直接从模板元数据拿到 startup -> decide -> route -> policy -> followup 脚手架。", + "0.2.63": "为 compact 顶层短命令增加执行语义字段:command_policy、preferred_requires_confirmation、fallback_requires_confirmation、can_auto_run_preferred;外部智能体现在可以机械判断 直接读 还是 先确认再写。", + "0.2.62": "把 error_summary、followup_summary、score_summary.decision 三层短命令继续上浮到 compact 主响应顶层;外部智能体现在只读 preferred_command / compact_commands 和 command_source 就能续跑。", + "0.2.61": "为 compact 失败回执增加统一 error_summary;外部智能体现在可以直接读取失败标签、建议说明,以及 preferred_command / compact_commands 这样的最短恢复命令。", + "0.2.60": "为 score_summary.decision 和 followup_summary 增加 preferred_command、fallback_command 与 compact_commands;mp_recent_activity 也补齐 followup_summary,外部智能体可直接读取最短下一步命令。", + "0.2.59": "新增统一 跟进 入口;有已执行计划时自动追执行后状态,有片名时直接查生命周期,否则退回最近活动,外部智能体只保留一个短入口也能续接。", + "0.2.58": "压缩本地/PT 高跟踪入口;新增 后续、状态、记录、入库、诊断、最近 等短命令,并让推荐命令优先吐这套更省 token 的自然语言写法。", + "0.2.57": "把写入动作后的追踪提示下沉为统一 followup_summary;执行计划、统一后续追踪和本地/PT 诊断现在都会返回稳定的后续标签、建议说明和推荐命令。", + "0.2.56": "把评分后的确认提示下沉为统一 decision 摘要,score_summary 现在会稳定返回决策标签、建议说明和推荐命令,便于飞书、外部智能体和 MP 内置入口共用同一套下一步提示。", + "0.2.55": "新增插件级智能体默认评分策略设置,允许统一配置 PT 最低做种数、建议确认分数线、自动入库分数线与默认自动化开关;新会话默认偏好与评分策略公开数据现在统一读取这些值。", + "0.2.54": "新增 preferences_onboarding 模板组、评分策略自然语言只读入口与 helper 命令;补齐偏好/评分 smoke 覆盖,并修正能力摘要里的 auto_ingest 默认值。", + "0.2.53": "新增本地/PT 入库诊断主线;补齐 mp_ingest_status、mp_ingest_failures、mp_recent_activity、mp_local_diagnose,并让生命周期/执行后追踪统一返回 diagnosis_summary。", + "0.2.52": "调整 recover 优先级:当前会话最近一条计划已执行时,恢复入口会优先推荐 query_execution_followup,而不是退回会话检查或新任务。", + "0.2.51": "把 execution_followup 下沉为正式 request template 和 followup recipe,外部智能体可以通过低 token 模板直接续接执行后追踪。", + "0.2.50": "新增 query_execution_followup 统一只读入口,并补齐 assistant/action compact 的 error_code、recommended_action 和 follow_up_hint,方便外部智能体一跳续接执行后追踪。", + "0.2.49": "新增 query_execution_followup 统一只读入口,外部智能体可按最近已执行计划自动追踪下载、订阅或入库后续状态。", + "0.2.48": "把 recommended_action 和 follow_up_hint 下沉到 plan_execute 原始 data 与用户可读消息里,非 compact 调用也能直接续接下一步。", + "0.2.47": "在 execute_plan compact 结果中补充 recommended_action 和 follow_up_hint,让外部智能体执行计划后能直接读取建议下一步。", + "0.2.46": "把 execute_plan 的 follow-up 样本加入 selfcheck,并纳入 live smoke 回归,避免 PT 下载、订阅与云盘转存的后续动作模板回退。", + "0.2.45": "执行 plan_id 成功后,按 PT 下载、订阅或云盘转存 workflow 返回更明确的后续动作模板,方便外部智能体继续追踪状态。", + "0.2.44": "统一 assistant/plan/execute 的 compact 回执;失败态和执行态现在都会返回稳定的 write_effect、error_code、result_summary 与结果列表摘要,方便外部智能体续接。", + "0.2.43": "调整 recover 优先级为业务续接优先于偏好初始化;已有 PT/云盘会话时,恢复入口会先推荐继续当前任务。", + "0.2.42": "补齐 compact session/recover 协议里的 action_templates;外部智能体读取会话状态或恢复入口时,也能拿到完整的结构化下一步模板。", + "0.2.41": "补齐 PT 只读会话的 action_templates;下载任务、站点、下载器、订阅列表等场景现在会给外部智能体正确的结构化下一步模板。", + "0.2.40": "收紧 PT 只读会话的下一步建议;下载任务、站点、下载器、订阅列表等场景不再给出误导性的控制动作提示。", + "0.2.39": "修复 workflow/tool 直调下的控制计划安全;空下载任务或空订阅列表时,不再为 mp_download_control / mp_subscribe_control 生成无效 plan_id。", + "0.2.38": "修复空订阅列表下的订阅控制安全;自然语言编号必须命中当前会话列表,避免把“搜索订阅 1”误写成订阅 ID=1 的计划。", + "0.2.37": "新增 mp_pt_mainline 与 mp_recommendation 请求模板 recipe,外部智能体可低 token 拉取 MP 原生 PT 主线与推荐主线模板,不再猜 workflow body。", + "0.2.36": "优化评分展示文案;硬性阻断显示为硬风险,普通偏好未命中显示为提醒,避免智能体把软提醒误判为不可用。", + "0.2.35": "修正 MP 推荐回退过滤;热门电影、热门电视剧 在回退到 tmdb_trending 时仍保留电影/电视剧类型,不再混入另一类结果。", + "0.2.34": "修正 MP 原生搜索结果的下载提示;明确下载资源 序号会先生成下载计划,不会静默下载。", + "0.2.33": "统一 MP 原生命令前缀解析;下载历史蜘蛛侠、追踪蜘蛛侠、入库失败蜘蛛侠、暂停订阅1 等无空格/冒号写法不再误落到资源搜索。", + "0.2.32": "修复订阅列表自然语言解析;订阅列表 蜘蛛侠、订阅列表:蜘蛛侠、订阅列表蜘蛛侠 现在稳定走只读查询,不会被通用订阅写入计划覆盖。", + "0.2.31": "收紧 compact 协议中的评分摘要返回;普通站点、下载器、任务诊断不再继承上一轮搜索的 score_summary,避免外部智能体误读上下文。", + "0.2.30": "细化评分风险结构:hard_risk_reasons 表示真正阻断自动化的风险,risk_reasons 保留为确认前提醒,避免软提醒被误算为阻断。", + "0.2.29": "收口 MP 原生 PT 主线:补齐做种/热度/字幕/站点等评分理由,下载/订阅/控制统一走 plan_id 确认链路,并强化 MP 原生推荐续接。", + "0.2.28": "插件展示名统一改为 Agent影视助手,并同步仓库文档、Skill 文案和兼容插件引用。", + "0.2.27": "优化盘搜和影巢资源列表的下一步提示;默认引导外部智能体先生成计划,再确认执行。", + "0.2.26": "新增云盘写入计划入口;盘搜和影巢资源可用“计划选择 1”先生成 plan_id,再确认执行。", + "0.2.25": "修复云盘会话最佳/详情选择安全;盘搜和影巢资源阶段的“最佳片源”只展示详情,不会误选最后一条执行。", + "0.2.24": "补齐 PT 下载自动化闭环;仅在用户开启自动入库且评分达标、无硬风险时,下载选择和下载最佳才会直接提交。", + "0.2.23": "新增偏好画像自然语言入口;可用“偏好”“保存偏好 ...”“重置偏好”查看、保存或重置智能体片源偏好。", + "0.2.22": "新增计划确认自然语言入口;可用“执行计划”或“执行 plan-xxx”确认执行已生成的下载、订阅或控制计划。", + "0.2.21": "新增“下载最佳”入口;在 MP 搜索会话中按最高评分 PT 候选生成下载计划,仍需用户确认 plan_id 后才会下载。", + "0.2.20": "新增 MP 搜索最佳候选详情入口;智能体可用“最佳片源”或 mp_search_best 直接查看当前评分最高 PT 候选。", + "0.2.19": "新增 MP 搜索结果详情入口;MP 搜索后“选择 1”会先展示 PT 详情、评分理由和风险,再由用户确认是否下载。", + "0.2.18": "新增 MP 原生媒体识别详情入口;智能体可用“识别 片名”或 mp_media_detail 工作流确认 TMDB/Douban/IMDB 信息后再搜索、下载或订阅。", + "0.2.17": "新增 MP 生命周期追踪聚合入口;智能体可用“追踪 片名”一次查看下载任务、下载历史和整理/入库历史。", + "0.2.16": "新增 MP 下载历史查询,并按 hash 关联整理/入库状态;智能体可用“下载历史 片名”追踪资源是否已提交下载和是否落库。", + "0.2.15": "新增 MP 整理/入库历史查询;智能体可用“入库历史”“入库失败 片名”判断下载后是否已落库,接口只返回脱敏摘要。", + "0.2.14": "新增 MP 订阅列表查询与订阅控制计划;智能体可查看订阅规则,并对搜索、暂停、恢复、删除订阅生成 plan_id 后确认执行。", + "0.2.13": "新增 MP 下载器与 PT 站点环境诊断入口;只返回启用状态、优先级、绑定下载器和 Cookie 是否存在,不暴露 Cookie 明文。", + "0.2.12": "补齐 MP 原生下载任务查询与任务控制入口;智能体可查看下载中任务,并对暂停、恢复、删除生成 plan_id 后确认执行。", + "0.2.11": "MP 下载/订阅命令支持无空格自然写法,例如“下载1”“下载第1个”“订阅蜘蛛侠”“订阅并搜索蜘蛛侠”;自然语言写入默认生成 plan_id,确认后才执行。", + "0.2.10": "推荐列表选择支持自然语言指定后续来源,例如“选择 1 盘搜”“选择1影巢”“选 2 mp”,飞书与智能体可不用结构化 mode 参数。", + "0.2.09": "热门推荐入口支持自然语言别名,例如“看看最近有什么热门影视”“豆瓣热门电影”“正在热映”“今日番剧”,智能体和飞书可直接用人话触发 MP 推荐。", + "0.2.08": "MP 热门推荐列表支持保存会话并按编号继续搜索,智能体可把推荐条目直接转入 MP 原生搜索、影巢或盘搜。", + "0.2.07": "影巢搜索默认使用自动媒体类型识别,未指定电影/剧集时不再提前按电影过滤,修复新剧搜索被误判无结果的问题。", + "0.2.06": "新增 scoring_policy 能力,结构化暴露插件内置云盘/PT 评分规则与硬门槛,方便智能体解释但不重打分。", + "0.2.05": "新增低 token score_summary,帮助智能体直接读取云盘和 PT 评分推荐、风险与确认建议。", + "0.2.04": "增强智能体偏好引导协议,主响应返回低 token preference_status,并在未初始化时优先提示保存偏好。", + "0.2.03": "新增智能体偏好画像、云盘/PT 分源评分、MP 原生搜索下载订阅推荐工作流,并让写入动作优先生成 plan_id。", + "0.2.02": "新增影巢资源搜索/解锁总开关与单资源积分上限,降低外部智能体误解锁高积分资源的风险。", + "0.2.01": "移除 get_state 中的主动 Agent Tool 重载,避免插件状态轮询时反复打印工具加载日志。", + "0.1.119": "新增本插件内置影巢签到日志,可通过 API、飞书或智能体查看最近签到、自动刷新 Cookie 和失败原因。", + "0.1.118": "本插件内置影巢 Cookie 自动刷新:签到兜底失败时可使用账号密码自动登录、保存新 Cookie 并重试。", + "0.1.117": "影巢签到收口到本插件:新增定时签到配置、默认赌狗模式、网页 Cookie 兜底和智能入口签到命令。", + "0.1.116": "新增 workbuddy_quickstart 请求模板和 route_text 模板,方便 WorkBuddy、微信侧智能体复现标准接入口。", + "0.1.115": "assistant/route 支持 MP搜索、原生搜索、搜索资源、搜索 前缀,统一外部智能体与飞书入口的原生 MP 搜索用法。", + "0.1.114": "飞书冲突检测会结合旧桥接配置、health 和 get_state,避免把已禁用但仍加载的旧插件误判为冲突。", + "0.1.113": "飞书健康检查补充 ready_to_start、safe_to_enable、缺失项和迁移建议,方便判断是否能从旧桥接迁移。", + "0.1.112": "修正 assistant/startup 在无可恢复会话时仍推荐 continue 的问题,避免外部智能体被空会话误导。", + "0.1.111": "飞书配置页补充回复 ID 类型和命令白名单,便于从旧飞书桥接完整迁移。", + "0.1.110": "飞书健康检查新增旧桥接运行状态和冲突提示,避免双飞书入口抢消息。", + "0.1.109": "新增 MP 原生 Tool agent_resource_officer_feishu_health,支持内置智能助手检查飞书入口状态。", + "0.1.108": "内置可选飞书入口 Channel,并为 assistant 回执补充 write_effect/error_code 标准字段。", + "0.1.107": "assistant/startup 会根据恢复状态动态推荐 bootstrap 或 continue 模板流程。", + "0.1.106": "assistant/startup 会带 recommended_request_templates,外部智能体启动后可直接按推荐参数拉取低 token 模板流程。", + "0.1.105": "assistant/request_templates 的文本摘要会直接显示推荐流程、首步调用和确认提示,方便低 token 场景直接阅读。", + "0.1.104": "recommended_recipe_detail 会带 first_confirmation_template 和 confirmation_message,方便外部智能体在写入前提示用户确认。", + "0.1.103": "recipe= 支持 plan、maintain、continue、bootstrap 等短别名,回执会带 requested_recipe、selected_recipe 和 recipe_aliases。", + "0.1.102": "assistant/request_templates 支持 recipe= 参数,可直接按 safe_bootstrap、plan_then_confirm、continue_existing_session 或 maintenance_cycle 拉取整套推荐流程。", + "0.1.101": "推荐调用会带 url_template,外部智能体可用 {base_url} 和 {MP_API_TOKEN} 直接拼出 HTTP 调用地址。", + "0.1.100": "assistant/request_templates 与推荐调用会明确给出 auth.mode=query_apikey,避免外部智能体误用 Bearer 鉴权。", + "0.1.99": "recommended_recipe_detail 会带完整 calls 列表,外部智能体可按推荐流程逐步执行。", + "0.1.98": "recommended_recipe_detail 会带 first_call,直接给出首个模板的 HTTP 调用和 MP Tool 调用参数,外部智能体可直接执行第一步。", + "0.1.97": "assistant/request_templates 回执会带 recommended_recipe_detail,直接给出推荐流程的首个模板、确认模板和写入模板,外部智能体可直接照此编排。", + "0.1.96": "assistant/request_templates 回执会直接给出 recommended_recipe 与 recommended_recipe_reason,外部智能体不必再自己挑选最适合的 recipe。", + "0.1.95": "recipes 会直接带 requires_confirmation、has_write_effect 和最小 cache_ttl_seconds,自检也会验证这些汇总特征。", + "0.1.94": "assistant/request_templates 回执会带场景化 recipes,外部智能体可直接选择安全启动、先计划后执行、继续既有会话等预设流程。", + "0.1.93": "assistant/request_templates 回执会带 recommended_sequence,直接给出推荐调用顺序,外部智能体可以少做一层启动编排。", + "0.1.92": "request_templates 每个模板都会带 cache_scope 和 cache_ttl_seconds,execution_policy 也会汇总 cacheable_templates 与 non_cacheable_templates,方便外部智能体决定缓存策略。", + "0.1.91": "assistant/request_templates 支持 include_templates=false,可只返回模板名、无效项和执行策略,进一步减少 token。", + "0.1.90": "请求模板协议增加 schema_version=request_templates.v1,startup/toolbox 也携带 request_templates_schema_version,方便外部智能体做兼容判断。", + "0.1.89": "assistant/request_templates 回执会带 execution_policy 汇总,直接列出可免确认执行、需要确认执行和存在写入副作用的模板名。", + "0.1.88": "request_templates 每个模板都会带 side_effect 和 requires_confirmation,外部智能体可区分只读、dry-run、计划写入和真实执行动作。", + "0.1.87": "request_templates 每个模板都会带 description,外部智能体可以直接判断模板用途,减少额外解释和 token 消耗。", + "0.1.86": "request_templates 每个模板都会带 tool_args,区分 HTTP 参数和 MP Tool 参数,避免外部智能体误用 body/query。", + "0.1.85": "request_templates 每个模板都会带对应的 MP 原生 tool 名,外部智能体可在 HTTP 调用和 MP Tool 调用之间直接切换。", + "0.1.84": "assistant/request_templates 支持 POST JSON body 传入 names/limit,方便结构化智能体直接用 body 请求过滤模板。", + "0.1.83": "assistant/startup 的核心 tools/endpoints 和 capabilities compact 推荐启动列表显式包含请求模板入口,外部智能体只读启动包也能发现模板能力。", + "0.1.82": "assistant/request_templates 支持 names/name/template 过滤,只返回指定模板,并回传 selected_names 与 invalid_names;原生 Tool 同步支持 names 参数。", + "0.1.81": "新增 assistant/request_templates 只读入口和 agent_resource_officer_request_templates 原生 Tool,外部智能体可只拉请求模板而不拉完整启动包。", + "0.1.80": "assistant/startup 与 assistant/toolbox 直接返回统一 request_templates,并由 assistant/selfcheck 检查模板齐全性,方便外部智能体按模板调用。", + "0.1.79": "assistant/startup.maintenance 直接返回 safe_to_execute、execute_method、dry_run_method、execute_endpoint 和 execute_body,外部智能体无需猜维护调用方式。", + "0.1.78": "assistant/maintain 在 POST 执行维护后写入 assistant/history,方便外部智能体审计维护动作;GET dry-run 仍不写历史。", + "0.1.77": "assistant/selfcheck 新增 maintain dry-run 和维护模板 compact 检查,确保维护协议本身也纳入健康检查。", + "0.1.76": "assistant/maintain 的 GET 请求固定为 dry-run,即使带 execute=true 也不会执行清理;只有 POST execute=true 才会实际维护。", + "0.1.75": "assistant/capabilities 增加 assistant_maintain 字段说明,并把 assistant/maintain 纳入 compact endpoint 和推荐启动链路。", + "0.1.74": "assistant/selfcheck 新增 maintain endpoint 和 maintain Tool 检查,确保维护入口已正确纳入外部智能体工具清单。", + "0.1.73": "新增 assistant/maintain 与 agent_resource_officer_maintain,支持 dry-run 查看低风险维护建议,也支持 execute=true 执行过期会话和已执行计划清理。", + "0.1.72": "assistant/startup.maintenance 增加 stale_sessions、saved_plans_executed 和 recommended_actions,外部智能体可直接判断是否值得做低风险维护清理。", + "0.1.71": "assistant/plans compact 回执中 total 改为当前过滤命中数,并补充 total_all,避免外部智能体把全部计划数误判为待执行计划数。", + "0.1.70": "assistant/startup.maintenance 增加低风险清理模板:清理过期会话、清理已执行计划;不会自动清理待执行计划。", + "0.1.69": "assistant/startup 增加 maintenance 计数,直接返回活跃会话、保存计划和待执行计划数量,便于外部智能体判断恢复或清理。", + "0.1.68": "assistant/startup 直接携带恢复用 session、session_id 和 action_templates,外部智能体可拿启动包直接执行推荐恢复动作。", + "0.1.67": "新增 assistant/startup 与 agent_resource_officer_startup,一次返回启动状态、自检结果、核心工具、端点、默认目录和恢复建议,减少外部智能体开场多次探测。", + "0.1.66": "assistant/pulse 和 compact assistant/capabilities 推荐启动链路加入 assistant/selfcheck,便于外部智能体开场自检协议健康。", + "0.1.65": "新增 agent_resource_officer_selfcheck 原生 Tool,让 MP 智能助手可直接执行 Agent影视助手 compact 协议自检。", + "0.1.64": "新增 assistant/selfcheck 轻量协议自检,快速确认 compact 模板、布尔解析和基础协议字段是否健康。", + "0.1.63": "统一 dry_run、stop_on_error、include_raw_results、prefer_unexecuted、all_plans、stale_only、all_sessions、execute 等 POST 布尔字段解析,避免字符串 false/0/off 被误判。", + "0.1.62": "统一 POST JSON compact 参数的布尔解析,避免外部智能体传入字符串 false/0/off 时被误判为开启精简回执。", + "0.1.61": "action_templates 默认为支持精简回执的 assistant 端点注入 compact=true,外部智能体原样回放模板即可保持低 token。", + "0.1.60": "assistant/route 与 assistant/pick 新增 compact=true 低 token 回执,减少智能入口搜索、选择、翻页和落盘主链路的嵌套负载。", + "0.1.59": "assistant/action 新增 compact=true 低 token 回执,外部智能体原样回放 action_template 时可直接获取单动作摘要。", + "0.1.58": "assistant/capabilities 与 assistant/readiness 新增 compact=true 低 token 回执,减少外部智能体启动阶段的能力发现和就绪检查负载。", + "0.1.57": "assistant/actions、assistant/workflow 与 assistant/plan/execute 新增 compact=true 低 token 回执,减少批量执行、工作流计划和计划执行链路的嵌套负载。", + "0.1.56": "assistant/history 与 assistant/plans 新增 compact=true 低 token 回执,便于外部智能体低成本查看执行历史和保存计划。", + "0.1.55": "assistant/session 与 assistant/sessions 新增 compact=true 低 token 回执,减少外部智能体查看会话状态时的嵌套负载。", + "0.1.54": "新增 assistant/toolbox 与 agent_resource_officer_toolbox 轻量工具清单,便于外部智能体低 token 获取端点、工具、工作流和命令示例。", + "0.1.53": "新增 assistant/pulse 与 agent_resource_officer_pulse 轻量启动探针,返回版本、关键服务状态、警告和最佳恢复建议。", + "0.1.52": "assistant/recover 新增 compact=true 低 token 回执,agent_resource_officer_recover 默认使用精简恢复信息,适合外部智能体高频轮询。", + "0.1.51": "新增 assistant/recover 与 agent_resource_officer_recover 单入口恢复能力,可自动选择最值得恢复的会话或计划,并支持 execute=true 直接续跑。", + "0.1.50": "assistant/session 与 assistant/sessions 统一到标准回执包裹字段,同时保留兼容摘要字段,降低外部智能体分支判断。", + "0.1.49": "新增统一 recovery 字段,并让 assistant/action 支持 execute_session_latest_plan,外部智能体可按恢复协议直接续跑。", + "0.1.48": "assistant/sessions 现在也会显示只有 dry_run 计划、尚未生成会话缓存的 session,便于从会话列表直接恢复。", + "0.1.47": "assistant/sessions 新增待执行计划摘要与 execute_session_latest_plan 模板,外部智能体可从会话列表直接恢复计划。", + "0.1.46": "assistant/action 新增 execute_latest_plan 与 execute_plan 动作,action_templates.action_body 可原样回传执行计划。", + "0.1.45": "session_state 与 readiness 新增计划恢复动作模板,外部智能体可直接复用 execute_latest_plan 执行待处理计划。", + "0.1.44": "assistant/plan/execute 现可按 session/session_id 自动恢复并执行最近计划,进一步减少外部智能体对 plan_id 的依赖。", + "0.1.43": "新增 assistant/plans 与 assistant/plans/clear 计划管理入口,外部智能体可查询、恢复和清理 dry_run 保存计划。", + "0.1.42": "dry_run 工作流计划新增 plan_id 持久化与 assistant/plan/execute 执行入口,外部智能体可先生成计划再按 plan_id 执行。", + "0.1.41": "预设工作流新增 dry_run 计划模式,外部智能体可先生成步骤计划和可执行请求体,确认后再实际执行,降低误操作风险。", + "0.1.40": "新增 assistant/history 与 history Tool,记录最近批量动作和预设工作流执行摘要,便于外部智能体判断进度、排障和恢复上下文。", + "0.1.39": "新增 assistant/readiness 与 readiness Tool,外部智能体可先检查版本、服务状态、活跃会话、推荐入口和启动提示,再决定是否开始执行。", + "0.1.38": "新增 assistant/workflow 与 run_workflow Tool,外部智能体可用预设工作流短参数完成盘搜、影巢、直链和 115 状态等常见任务。", + "0.1.37": "新增 assistant/actions 与 execute_actions Tool,外部智能体可一次提交多个 action_body 顺序执行,默认仅返回精简执行摘要,进一步减少往返和 token 消耗。", + "0.1.36": "新增 assistant/action 与 execute_action Tool,外部智能体可直接执行 action_templates 返回的动作模板名,不必自己做动作到接口的映射。", + "0.1.35": "统一回执与 session_state 新增 protocol_version 和 action_templates,外部智能体可直接按返回模板继续调用,不再自己拼下一步参数。", + "0.1.34": "新增 session_id 精准恢复与 assistant 会话批量清理能力,外部智能体可按 session_id 继续,也可按过滤条件回收旧会话。", + "0.1.33": "新增活跃会话列表 API 与原生 Tool,并将 assistant 会话整体纳入持久化恢复,便于外部智能体在断线、重启和多会话场景下继续执行。", + "0.1.32": "统一智能入口与继续选择回执新增 session/session_state/next_actions 结构化工作流字段,外部智能体可直接按回执继续编排,进一步减少文本解析。", + "0.1.31": "统一智能入口新增结构化参数模式与能力探测接口,外部智能体可直接传 mode/keyword/url/action 等字段,不必再拼自然语言命令。", + "0.1.30": "新增统一智能入口会话状态/清理 API 与原生 Tool,便于外部智能体先查当前阶段、建议动作和待继续 115 任务,再决定下一步调用。", + "0.1.29": "新增 Agent影视助手 帮助 Tool,并让统一智能入口在空输入或帮助语义下直接返回推荐用法,降低 MP 智能助手首次调用门槛。", + "0.1.28": "新增 Agent影视助手 统一智能入口原生 Tool:smart_entry / smart_pick,MP 智能助手可直接复用飞书同款处理/选择主链。", + "0.1.27": "更新 Agent影视助手 页面与表单文案,明确已接入 115 扫码、统一智能入口与 MP 原生 Agent Tool,避免仍显示骨架态提示。", + "0.1.26": "补充 P115StrmHelper 插件目录自动入 path 的兜底导入逻辑,降低 115 执行层对运行态模块路径的敏感度。", + "0.1.25": "新增 115 待处理任务标准 API:查看、继续、取消,便于飞书、CLI 与外部脚本直接调用。", + "0.1.24": "新增 115 待处理任务原生 Agent Tool:查看、继续、取消,MP 智能助手可直接调用待处理任务能力。", + "0.1.23": "待继续的 115 任务新增时间、重试次数与最近错误摘要,并自动清理过旧会话,避免持久化状态长期堆积。", + "0.1.22": "待继续的 115 任务现在会持久化保存,重启后仍可用;并新增 115任务 指令可单独查看当前待处理任务。", + "0.1.21": "新增待继续 115 任务摘要、继续115任务 与 取消115任务 指令;没有扫码会话时也可直接尝试续跑待处理任务。", + "0.1.20": "115 转存失败时会记住当前任务;扫码成功后回复 检查115登录,可自动继续上次未完成的 115 操作。", + "0.1.19": "115帮助 与 115状态 现在会返回可直接照抄的发送示例,登录前后分别给出更明确的下一步动作。", + "0.1.18": "115 转存失败时新增统一状态诊断与下一步引导,影巢解锁、直链转存和智能入口都复用同一套失败提示。", + "0.1.17": "115 状态与登录相关回执新增下一步建议,并补充 115帮助 智能入口语义。", + "0.1.16": "新增 115状态 原生 Agent Tool 与智能入口语义,未处于登录轮询时也可直接查看当前 115 状态。", + "0.1.15": "115 扫码成功后新增运行状态摘要,直接返回默认目录、会话来源与当前可用状态。", + "0.1.14": "智能入口新增 115登录 / 检查115登录 语义,可直接服务飞书桥接与 MP 智能助手。", + "0.1.13": "新增 115 扫码登录原生 Agent Tool,智能助手可直接发起二维码并轮询登录状态。", + "0.1.12": "115 直转层新增 p115client 同款扫码登录接口与会话校验,默认不再推荐网页版 Cookie。", + "0.1.11": "新增 115 独立直转执行层,可优先使用独立 Cookie 或已加载客户端直接转存分享链接,失败时再回退 P115StrmHelper。", + "0.1.10": "补齐 P115StrmHelper 新版 MoviePilot 兼容补丁说明与复现脚本,115 健康检查已验证可用。", + "0.1.9": "影巢候选会话支持分页和详情/审查按需补主演,原生 Agent Tool 与飞书 auto 后端可复用同一能力。", + "0.1.8": "非 Premium 用户现在也可回退复用 HDHiveDailySign 的网页 Cookie 与用户快照,补齐签到和账号信息兜底。", + "0.1.7": "补齐影巢账号、签到、配额、今日用量与每周免费额度 API,让 Agent影视助手 开始承接用户态能力。", + "0.1.6": "新增 Agent影视助手 自己的智能入口 API,支持盘搜搜索、影巢搜索、直链路由和按编号继续执行。", + "0.1.5": "补齐会话搜索/选择接口的统一文本输出,并在健康接口中返回插件版本,便于桥接与智能体复用。", + "0.1.4": "夸克执行层补充缺少 Cookie 时的自动刷新尝试,原生工具与 API 路由更稳。", + "0.1.3": "修复原生 Agent Tool 夸克分享路由参数错误,补齐 115 主链路兼容恢复。", + "0.1.2": "新增原生 Agent Tool:影巢会话搜索、会话继续选择、通用分享链接路由。", + "0.1.1": "打通运行时配置加载,补充候选计数,并兼容 index/choice/selection/number 选片字段。", + "0.1.0": "首个可用版本,已接入夸克转存、115 转存、影巢搜索/解锁,以及解锁后自动路由到对应网盘执行层。" + } + }, + "FeishuCommandBridgeLong": { + "name": "飞书命令桥接", + "description": "旧飞书长连接兼容/备份入口;新用户建议优先使用 Agent影视助手 内置飞书入口。", + "labels": "飞书,长连接,115,影巢,夸克,智能体,命令", + "version": "0.5.26", + "icon": "feishucommandbridgelong.png", + "author": "liuyuexi1987", + "level": 1, + "history": { + "0.5.26": "更新插件市场描述,明确本插件定位为旧飞书长连接兼容/备份入口,新用户优先使用 Agent影视助手 内置飞书入口。", + "0.5.25": "飞书里的 115 登录、待任务与直链转存现在统一走 Agent影视助手 主线,保证失败留单、扫码续跑、取消任务都落在同一会话链里。", + "0.5.24": "同步飞书桥接运行态版本,配合 115任务 新别名与持久化待处理任务能力发布。", + "0.5.23": "飞书桥接新增 115任务 别名和快捷示例,方便查看当前待继续的 115 任务。", + "0.5.22": "飞书桥接补充 继续115任务 与 取消115任务 别名和快捷示例,便于直接控制待处理 115 任务。", + "0.5.21": "飞书快捷示例补充 115帮助 与带 path 的直链转存写法,方便直接照抄使用。", + "0.5.20": "飞书桥接现在会直接透传 Agent影视助手 返回的 115 失败诊断,不再重复包裹错误前缀。", + "0.5.19": "飞书桥接新增 115帮助 别名,并复用 Agent影视助手 返回的引导式 115 状态/登录回执。", + "0.5.18": "飞书现在可直接发起 115 扫码登录并回传二维码图片,也支持回复检查115登录继续轮询 Agent影视助手 会话。", + "0.5.17": "切到 Agent影视助手 后端时,详情/审查和 n 下一页会透传给新主线,不再退回 unsupported。", + "0.5.16": "当切到 Agent影视助手 后端时,飞书桥接的智能入口与继续选择可整条委托给 Agent影视助手 处理,桥接层进一步变薄。", + "0.5.15": "当切到 Agent影视助手 后端时,飞书桥接的影巢搜索/选片/解锁会话也可直接走新主线,不再只接最后一跳转存。", + "0.5.14": "新增执行后端开关,旧桥接可继续直连快路径,也可按需切换到 Agent影视助手 新主线。", + "0.5.13": "飞书桥接保留旧入口,但执行层优先委托 Agent影视助手,影巢/115/夸克开始走新主干。", + "0.5.12": "详情/审查 现在只补当前页主演,并改为并发补查,减少候选较多时的等待时间。", + "0.5.11": "影巢候选影片默认不再预查主演,首屏更快;如需补充当前候选页全部主演,可直接回复详情或审查。", + "0.5.10": "影巢候选影片列表支持按每页 10 条分页展示,并可直接回复 n 下一页继续翻页;候选请求上限同步提高,适合蜘蛛侠这类多版本片名。", + "0.5.9": "飞书桥接新增本地 TMDB API Key 配置,影巢候选影片现在可稳定补充 1 到 2 个主演名,且不会把密钥写进仓库。", + "0.5.8": "影巢候选影片列表补充 1 到 2 个主演名,帮助快速区分同名作品;继续保留先选影片再看资源的两段式流程。", + "0.5.7": "影巢搜索改为先选影片再看资源;资源列表按 115 前 6 条与夸克前 6 条分区展示,交互与盘搜保持一致。", + "0.5.6": "精简夸克转存回执,仅保留关键结果;盘搜列表增加 115/夸克分区提示,便于快速选择。", + "0.5.5": "盘搜搜索增加相关性过滤,并将 115 / 夸克各自展示数调整为前 6 条,减少无关结果干扰。", + "0.5.4": "盘搜搜索改为固定展示 115 前 10 条与夸克前 10 条,统一连续编号,方便直接按序号转存。", + "0.5.3": "新增盘搜搜索结果缓存与按编号直转 115 / 夸克,和影巢搜索保持同样的选择式落地体验。", + "0.5.2": "支持飞书直接发送 115 / 夸克裸链接,自动识别并转存,不再需要处理前缀。", + "0.5.1": "新增 MP搜索 / 影巢搜索 / 盘搜搜索 三种前缀入口,默认搜索保持 MP 原生搜索。", + "0.5.0": "新增处理/选择双命令与智能体 API,统一分流夸克链接、115 链接与影巢搜索解锁流程。", + "0.4.0": "新增夸克分享转存命令,可直接桥接 QuarkShareSaver 完成落盘。", + "0.3.0": "新增飞书内建媒体工作流:搜索 PT 资源、按序号下载、添加订阅、订阅后立即搜索。", + "0.2.3": "统一插件身份为 FeishuCommandBridgeLong,修复插件市场安装状态匹配。", + "0.2.2": "支持飞书长连接、事件去重、115 手动整理回执、增量与全量 STRM 命令桥接。" + } + }, + "HdhiveOpenApi": { + "name": "影巢 OpenAPI", + "description": "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。", + "labels": "影巢,HDHive,OpenAPI,TMDB,115,解锁,签到", + "version": "0.3.0", + "icon": "hdhive.ico", + "author": "liuyuexi1987", + "level": 1, + "history": { + "0.3.0": "支持关键词搜索、TMDB 候选解析、115 自动转存、分享管理、签到与配额查询。" + } + }, + "QuarkShareSaver": { + "name": "夸克分享转存", + "description": "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。", + "labels": "夸克,Quark,分享,转存,网盘,智能体,飞书", + "version": "0.1.0", + "icon": "quark.ico", + "author": "liuyuexi1987", + "level": 1, + "history": { + "0.1.0": "首个轻量版本,支持夸克分享解析、目录自动创建、转存执行,以及智能体和飞书调用。" + } } } diff --git a/plugins.v2/agentresourceofficer/ARCHITECTURE.md b/plugins.v2/agentresourceofficer/ARCHITECTURE.md new file mode 100644 index 0000000..8734421 --- /dev/null +++ b/plugins.v2/agentresourceofficer/ARCHITECTURE.md @@ -0,0 +1,223 @@ +# Agent影视助手架构草案 + +`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。 + +## 设计目标 + +- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口” +- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务 +- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层 + +## 模块分层 + +### 1. adapters + +负责不同外部入口和外部平台接入: + +- `feishu` +- `hdhive` +- `quark` +- `pansou` +- 后续 `agent_tool` + +原则: + +- 只负责协议和输入输出转换 +- 不负责复杂业务编排 + +### 2. services + +负责核心业务能力: + +- `search_service` +- `unlock_service` +- `transfer_service` +- `signin_service` +- `user_service` + +原则: + +- 统一返回结构 +- 尽量不感知飞书、页面、CLI 等具体入口 + +### 3. session + +负责交互上下文: + +- 搜索候选缓存 +- 翻页状态 +- 选择上下文 +- 详情/审查补充信息(已支持候选页按需补主演) + +原则: + +- 入口层共享同一套会话数据 +- 后续优先支持内存 + 轻量持久化 + +### 4. models + +负责统一数据模型: + +- 搜索候选 +- 资源条目 +- 解锁结果 +- 转存结果 +- 用户信息 + +目标: + +- 减少旧插件之间字段名不一致的问题 + +## 首期配置模型 + +### 基础 + +- `enabled` +- `notify` +- `debug` + +### 影巢 + +- `hdhive_base_url` +- `hdhive_api_key` +- `hdhive_default_path` +- `hdhive_candidate_page_size` + +### 夸克 + +- `quark_cookie` +- `quark_default_path` +- `quark_timeout` +- `quark_auto_import_cookiecloud` + +### 飞书 + +- `feishu_enabled` +- `feishu_app_id` +- `feishu_app_secret` +- `feishu_verification_token` +- `feishu_allow_all` +- `feishu_allowed_chat_ids` +- `feishu_allowed_user_ids` + +### 智能体 / 工具层预留 + +- `agent_tools_enabled` +- `tool_debug` + +## 迁移映射 + +### 从 `QuarkShareSaver` + +优先迁入: + +- 分享链接解析 +- 目录创建 +- 转存执行 +- CookieCloud 自动导入 + +当前已开始拆出: + +- `services/quark_transfer.py` + +### 从 `P115StrmHelper` 协同层 + +当前已开始拆出: + +- `services/p115_transfer.py` + +### 从 `HdhiveOpenApi` + +随后迁入: + +- 搜索 +- 候选解析 +- 解锁 +- 用户信息 +- 配额 +- 分享管理 + +当前已开始拆出: + +- `services/hdhive_openapi.py` + +### 从 `HDHiveDailySign` + +补入: + +- 普通签到 +- 赌狗签到 +- 自动登录与状态记录 + +### 从 `FeishuCommandBridgeLong` + +最后收口: + +- 飞书长连接入口 +- 自然语言别名解析 +- 搜索/选择会话衔接 + +## 暂不迁入的内容 + +- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手` + +> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。 + +## P115StrmHelper 兼容补丁 + +新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。 + +仓库提供了幂等补丁脚本: + +```bash +MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh +``` + +补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。 + +## 115 轻量直转层 + +`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录: + +- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置 +- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive` +- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper` +- 直转失败时回退 `P115StrmHelper` 的分享转存主流程 + +这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。 +这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。 + +## 首个里程碑 + +第一个可用版本只追求三件事: + +1. 夸克分享链接直接转存 +2. 影巢搜索并解锁 +3. 飞书调用同一套执行服务 + +当前进度: + +- 已拆出夸克执行服务 +- 已拆出影巢基础 OpenAPI 服务 +- 已拆出 115 转存执行服务 +- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick) +- 主插件已具备: + - 夸克健康检查 + - 夸克转存 + - 影巢健康检查 + - 影巢搜索 + - 影巢关键词候选搜索 + - 影巢解锁 + - 115 依赖健康检查 + - 115 分享转存 + - 影巢解锁后自动路由到夸克执行层 + - 影巢解锁后自动路由到 115 执行层 + - 影巢会话搜索与按编号继续选择 + - 盘搜搜索与按编号继续执行 +- 统一智能入口对直链、盘搜、影巢三类输入的会话分流 +- 原生 Agent Tool 直接发起和轮询 115 扫码登录 +- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录` +- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行 +- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话 +- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API +- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令 diff --git a/plugins.v2/agentresourceofficer/README.md b/plugins.v2/agentresourceofficer/README.md new file mode 100644 index 0000000..59122a0 --- /dev/null +++ b/plugins.v2/agentresourceofficer/README.md @@ -0,0 +1,212 @@ +# Agent影视助手 + +`Agent影视助手` 是这个仓库的主线插件,重点解决一件事: + +把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。 + +当前版本:`0.2.68` + +当前 helper 版本:`0.1.46` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +如果你是第一次用这个仓库,先把这个插件跑通就够了。 + +--- + +## 适合谁 + +- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。 +- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。 +- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。 +- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。 +- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。 + +--- + +## 两种主要用法 + +### 1. 不使用外部智能体,只用飞书命令入口 + +如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。 + +配好后,直接在飞书里发: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。 + +### 2. 使用外部智能体 + +如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。 + +外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。 + +重点文档: + +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` + +### MCP 和 Skill 怎么分工 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。 + +- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。 +- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情和 Cookie 修复。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +--- + +## 核心命令 + +### 搜索 + +| 命令 | 作用 | +|---|---| +| `搜索 <片名>` | 默认走盘搜 | +| `盘搜搜索 <片名>` | 只看盘搜 | +| `影巢搜索 <片名>` | 只看影巢 | +| `云盘搜索 <片名>` | 盘搜 + 影巢 | +| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 | + +### 转存 / 下载 + +| 命令 | 作用 | +|---|---| +| `转存 <片名>` | 默认等同 `115转存 <片名>` | +| `115转存 <片名>` | 搜索后优先转存到 115 | +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先生成下载计划 | + +注意: + +- `转存 <片名>` 默认是 115,不会自动改成夸克。 +- 只有明确说 `夸克转存 <片名>` 才走夸克。 +- `下载 <片名>` 是 PT 下载,不是云盘转存。 +- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。 +- 真正下载、转存、解锁、清空目录这类写入动作,都应先经过明确确认。 + +### 选择 / 翻页 + +```text +1 +1详情 +下载1 +n +``` + +- `1`:继续处理当前第 1 条结果。 +- `1详情`:查看第 1 条详情。 +- `下载1`:给第 1 条 PT 结果生成下载计划。 +- `n`:下一页。 + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 主要能力 + +### 云盘资源 + +- 盘搜搜索 +- 影巢搜索 / 解锁 +- 115 转存 +- 夸克转存 +- 云盘更新检查 +- 编号选择、详情、翻页 +- 智能建议与候选推荐 + +### MoviePilot 原生能力 + +- MP / PT 搜索 +- PT 下载计划 +- 订阅 +- 下载任务 +- 下载历史 +- 入库历史 +- 站点状态 / 下载器状态 +- 热门探索 / 推荐 + +### 账号与修复 + +- 115 扫码登录 / 状态检查 +- 影巢签到 / 签到日志 +- 影巢 Cookie 修复 +- 夸克 Cookie 修复 + +Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。 + +--- + +## 和旧插件的关系 + +`Agent影视助手` 是把旧的分散能力收成一条主线。 + +| 旧插件 | 主要用途 | 现在建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 | +| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 | + +旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。 + +--- + +## 新手最容易踩的坑 + +### 外部智能体乱改命令 + +常见错误: + +- 把 `云盘搜索` 偷换成 `盘搜搜索` +- 把 `下载` 当成云盘转存 +- 把 `15详情` 当成 `选择 15` +- 重排插件返回的编号 + +解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说: + +```text +校准影视技能 +``` + +### 跨机器地址填错 + +如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址: + +```text +ARO_BASE_URL=http://你的NAS地址:3000 +``` + +不要填 `127.0.0.1`,那只代表智能体自己这台机器。 + +### 夸克失败不一定是 Cookie 失效 + +分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。 + +--- + +## 进一步阅读 + +- [插件安装说明](../docs/PLUGIN_INSTALL.md) +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` diff --git a/plugins.v2/agentresourceofficer/__init__.py b/plugins.v2/agentresourceofficer/__init__.py new file mode 100644 index 0000000..53ffc7d --- /dev/null +++ b/plugins.v2/agentresourceofficer/__init__.py @@ -0,0 +1,26967 @@ +import asyncio +import concurrent.futures +import copy +import hmac +import json +import os +import re +import threading +import time +import uuid +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen + +from fastapi import Request +try: + from apscheduler.triggers.cron import CronTrigger +except Exception: + CronTrigger = None +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.log import logger +except Exception: + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None +try: + from app.agent.tools.manager import moviepilot_tool_manager +except Exception: + moviepilot_tool_manager = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None +try: + from app import schemas as app_schemas +except Exception: + app_schemas = None +from app.plugins import _PluginBase + +from .services.hdhive_openapi import HDHiveOpenApiService +from .services.p115_transfer import P115TransferService +from .services.quark_transfer import QuarkTransferService +from .feishu_channel import FeishuChannel +from .agenttool import ( + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantMaintainTool, + AssistantPickTool, + AssistantPlansClearTool, + AssistantPlansTool, + AssistantPulseTool, + AssistantReadinessTool, + AssistantRecoverTool, + AssistantRequestTemplatesTool, + AssistantRouteTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSelfcheckTool, + AssistantStartupTool, + AssistantToolboxTool, + AssistantWorkflowTool, + FeishuChannelHealthTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + P115CancelPendingTool, + P115PendingTool, + P115QRCodeCheckTool, + P115QRCodeStartTool, + P115ResumePendingTool, + P115StatusTool, + ShareRouteTool, +) + + +class _JsonRequestShim: + def __init__(self, request: Request, body: Dict[str, Any], method: str = "POST") -> None: + self.method = str(method or "POST").upper() + self.headers = request.headers + self.query_params = request.query_params + self._body = body + + async def json(self) -> Dict[str, Any]: + return self._body + + +class _RequestContextShim: + def __init__(self, headers: Optional[Dict[str, Any]] = None, query_params: Optional[Dict[str, Any]] = None) -> None: + default_headers = dict(headers or {}) + if settings is not None and not default_headers.get("Authorization"): + token = str(getattr(settings, "API_TOKEN", "") or "").strip() + if token: + default_headers["Authorization"] = f"Bearer {token}" + self.headers = default_headers + self.query_params = query_params or {} + + +class AgentResourceOfficer(_PluginBase): + plugin_name = "Agent影视助手" + plugin_desc = "统一承接影巢搜索/解锁、115 转存、夸克转存、飞书入口与智能体接口的资源工作流主插件。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png" + plugin_version = "0.2.68" + request_templates_schema_version = "request_templates.v1" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "agentresourceofficer_" + plugin_order = 40 + auth_level = 1 + + _enabled = False + _notify = True + _debug = False + _quark_cookie = "" + _quark_default_path = "/飞书" + _quark_timeout = 30 + _quark_auto_import_cookiecloud = True + _pansou_base_url = "http://127.0.0.1:805" + _pansou_timeout = 20 + _hdhive_api_key = "" + _hdhive_base_url = "https://hdhive.com" + _hdhive_timeout = 30 + _hdhive_default_path = "/待整理" + _assistant_result_page_size = 10 + _assistant_cloud_result_page_size = 20 + _hdhive_candidate_page_size = 20 + _hdhive_resource_enabled = True + _hdhive_max_unlock_points = 20 + _hdhive_checkin_enabled = False + _hdhive_checkin_gambler_mode = False + _hdhive_checkin_once = False + _hdhive_checkin_cron = "0 8 * * *" + _hdhive_checkin_cookie = "" + _hdhive_checkin_auto_login = True + _hdhive_checkin_username = "" + _hdhive_checkin_password = "" + _p115_default_path = "/待整理" + _p115_client_type = "alipaymini" + _p115_cookie = "" + _p115_prefer_direct = True + _mp_download_save_path = "" + _assistant_default_pt_min_seeders = 3 + _assistant_default_auto_ingest_enabled = False + _assistant_default_auto_ingest_score_threshold = 90 + _assistant_default_confirm_score_threshold = 70 + _feishu_enabled = False + _feishu_allow_all = False + _feishu_reply_enabled = True + _feishu_reply_receive_id_type = "chat_id" + _feishu_app_id = "" + _feishu_app_secret = "" + _feishu_verification_token = "" + _feishu_allowed_chat_ids: List[str] = [] + _feishu_allowed_user_ids: List[str] = [] + _feishu_command_whitelist: List[str] = [] + _feishu_command_aliases = "" + _feishu_command_mode = "resource_officer" + + _quark_service: Optional[QuarkTransferService] = None + _hdhive_service: Optional[HDHiveOpenApiService] = None + _p115_service: Optional[P115TransferService] = None + _feishu_channel: Optional[FeishuChannel] = None + _session_cache: Dict[str, Dict[str, Any]] = {} + _session_lock = threading.RLock() + _agent_tools_reloaded = False + _candidate_actor_cache: Dict[str, List[str]] = {} + _candidate_actor_cache_lock = threading.Lock() + _session_store_key = "assistant_session_cache" + _session_retention_seconds = 7 * 24 * 60 * 60 + _execution_history_store_key = "assistant_execution_history" + _execution_history_limit = 100 + _execution_history: List[Dict[str, Any]] = [] + _workflow_plan_store_key = "assistant_workflow_plans" + _workflow_plan_limit = 50 + _workflow_plans: Dict[str, Dict[str, Any]] = {} + _assistant_preferences_store_key = "assistant_preferences" + _assistant_preferences_limit = 100 + _assistant_preferences: Dict[str, Dict[str, Any]] = {} + _hdhive_checkin_history_store_key = "hdhive_checkin_history" + _hdhive_checkin_history_limit = 60 + _hdhive_checkin_history: List[Dict[str, Any]] = [] + _agent_tools_reload_lock = threading.Lock() + _agent_tools_reload_version = "" + _agent_tools_reload_at = 0.0 + + @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 _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_display_datetime(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.startswith("🕒"): + return text + match = re.match(r"^\d{4}[/-](\d{2}[/-]\d{2})(?:\b|$)", text) + if match: + text = match.group(1).replace("-", "/") + return f"🕒{text}" + + @staticmethod + def _normalize_search_prefix(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) + raw = re.sub(r"\s+", " ", raw).strip() + for pattern in ( + r"^(?:mp|pt)\s*搜索\s*(.*)$", + r"^原生\s*搜索\s*(.*)$", + ): + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return "mp", match.group(1).strip() + mappings = [ + ("更新检查", "update"), + ("更新搜索", "update"), + ("查更新", "update"), + ("更新", "update"), + ("资源决策", "smart_decision"), + ("智能决策", "smart_decision"), + ("智能执行", "smart_execute"), + ("智能搜执行", "smart_execute"), + ("智能计划", "smart_plan"), + ("智能搜计划", "smart_plan"), + ("云盘搜索", "cloud"), + ("云盘搜", "cloud"), + ("智能搜索", "smart"), + ("智能搜", "smart"), + ("MP搜索", "mp"), + ("MP 搜索", "mp"), + ("PT搜索", "mp"), + ("PT 搜索", "mp"), + ("pt搜索", "mp"), + ("pt 搜索", "mp"), + ("原生搜索", "mp"), + ("原生 搜索", "mp"), + ("搜索资源", "pansou"), + ("找资源", "pansou"), + ("搜索", "pansou"), + ("找", "pansou"), + ("1搜索", "pansou"), + ("2搜索", "hdhive"), + ("影巢搜索", "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 + if raw.startswith("检查 "): + remain = raw[len("检查"):].strip() + if remain: + return "update", remain + return "", raw + + @staticmethod + def _extract_smart_decision_intent(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + patterns = [ + ("execute_now", [ + r"(?:直接|立即|马上|立刻)?确认执行$", + r"(?:直接|立即|马上|立刻)?确认$", + r"(?:直接|立即|马上|立刻)?执行$", + r"(?:直接|立即|马上|立刻)?下载$", + r"(?:直接|立即|马上|立刻)?转存$", + r"(?:直接|立即|马上|立刻)?解锁$", + r"(?:直接|立即|马上|立刻)?处理$", + ]), + ("make_plan", [ + r"(?:先)?生成计划$", + r"(?:先)?做计划$", + r"(?:先)?出计划$", + r"(?:先)?计划$", + r"(?:先)?待确认计划$", + ]), + ("show_detail", [ + r"详情$", + r"(?:先)?看详情$", + r"(?:先)?看一下$", + r"(?:先)?看看$", + r"(?:只)?看推荐$", + r"(?:只)?看结果$", + r"(?:先)?推荐一下$", + ]), + ] + compact = re.sub(r"\s+", "", raw) + for intent, tokens in patterns: + for token in tokens: + if re.search(token, compact, flags=re.IGNORECASE): + cleaned = re.sub(token, "", compact, flags=re.IGNORECASE).strip() + return cleaned or raw, intent + return raw, "" + + @classmethod + def _extract_mp_result_filter_intent(cls, text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + episode_patterns = [ + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*s\d{1,2}\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*(\d{1,3})\s*集\s*(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*([零〇一二两三四五六七八九十]{1,4})\s*集\s*(?:资源|结果)?", + ] + for pattern in episode_patterns: + match = re.search(pattern, raw, flags=re.IGNORECASE) + if not match: + continue + raw_episode = match.group(1) + if str(raw_episode).isdigit(): + episode = int(raw_episode) + else: + episode = cls._parse_simple_cjk_number(str(raw_episode)) or 0 + if episode <= 0: + continue + cleaned = (raw[:match.start()] + " " + raw[match.end():]).strip(" ::,,。") + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned or raw, f"episode:{episode}" + patterns = [ + r"(?:给我|帮我|只看|看看|看一下)?最新(?:一)?集(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下)?最新(?:两|2)集(?:资源|结果)?", + r"只(?:要|看)(?:当前)?最新", + ] + cleaned = raw + matched = False + for pattern in patterns: + new_value = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE) + if new_value != cleaned: + matched = True + cleaned = new_value + cleaned = re.sub(r"\s+", " ", cleaned).strip(" ::,,。") + return (cleaned or raw, "latest_episode") if matched else (raw, "") + + async def _assistant_smart_decision_followup_detail( + self, + request, + *, + session: str, + cache_key: str, + keyword: str, + compact: bool = False, + apikey: str = "", + media_type: str = "", + year: str = "", + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + ): + decision_result = await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + ) + state = self._load_session(cache_key) + if not isinstance(state, dict) or self._clean_text(state.get("kind")) != "assistant_smart_search": + return decision_result + return await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": apikey, + }) + ) + + @staticmethod + def _match_command_prefix(raw: str, prefixes: List[str]) -> Optional[Tuple[str, str]]: + text = str(raw or "").strip() + for prefix in prefixes: + if not text.startswith(prefix): + continue + remain = text[len(prefix):] + if not remain: + return prefix, "" + return prefix, remain.lstrip(" ::").strip() + return None + + @staticmethod + def _normalize_mp_recommend_request(value: Any, default_source: str = "tmdb_trending") -> Tuple[str, str]: + raw = str(value or "").strip() + compact = re.sub(r"[\s,。?!!?,、::]+", "", raw).lower() + allowed_sources = { + "tmdb_trending", + "tmdb_movies", + "tmdb_tvs", + "douban_hot", + "douban_movie_hot", + "douban_tv_hot", + "douban_showing", + "douban_movie_showing", + "douban_movie_top250", + "douban_tv_animation", + "bangumi_calendar", + } + if compact in allowed_sources: + if compact == "douban_showing": + return "douban_movie_showing", "movie" + return compact, "all" + source_aliases = { + "trending": ("tmdb_trending", "all"), + "tmdb": ("tmdb_trending", "all"), + "tmdb热门": ("tmdb_trending", "all"), + "tmdb电影": ("tmdb_movies", "movie"), + "tmdb剧集": ("tmdb_tvs", "tv"), + "tmdb电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "豆瓣电影": ("douban_movie_hot", "movie"), + "豆瓣热门电影": ("douban_movie_hot", "movie"), + "豆瓣热映": ("douban_movie_showing", "movie"), + "豆瓣电视剧": ("douban_tv_hot", "tv"), + "豆瓣剧集": ("douban_tv_hot", "tv"), + "豆瓣top250": ("douban_movie_top250", "movie"), + "top250": ("douban_movie_top250", "movie"), + "douban_showing": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "热映": ("douban_movie_showing", "movie"), + "院线": ("douban_movie_showing", "movie"), + "bangumi": ("bangumi_calendar", "tv"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "每日放送": ("bangumi_calendar", "tv"), + "动画番剧": ("bangumi_calendar", "tv"), + } + if compact in source_aliases: + return source_aliases[compact] + if "top250" in compact: + return "douban_movie_top250", "movie" + if any(token in compact for token in ["bangumi", "番剧", "每日放送", "今日放送", "今日动画"]): + return "bangumi_calendar", "tv" + if any(token in compact for token in ["正在热映", "热映", "院线"]): + return "douban_movie_showing", "movie" + if "豆瓣" in compact: + if "动画" in compact: + return "douban_tv_animation", "tv" + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "douban_tv_hot", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "douban_movie_hot", "movie" + return "douban_hot", "all" + if "tmdb" in compact: + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "tmdb_movies", "movie" + return "tmdb_trending", "all" + if any(token in compact for token in ["电视剧", "剧集", "剧集推荐", "热门剧", "热门电视剧"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "影视电影", "热门电影"]): + return "tmdb_movies", "movie" + return default_source or "tmdb_trending", "all" + + @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_path(mapped) + + @staticmethod + def _normalize_pick_action(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {"继续决策", "继续资源决策", "decision_continue"}: + return "decision_continue" + if text in {"换影巢", "切换影巢", "走影巢", "用影巢", "decision_hdhive"}: + return "decision_hdhive" + if text in {"换盘搜", "切换盘搜", "走盘搜", "用盘搜", "decision_pansou"}: + return "decision_pansou" + if text in {"换pt", "换原生", "换mp", "切换pt", "切换原生", "切换mp", "走pt", "走原生", "走mp", "decision_mp_pt"}: + return "decision_mp_pt" + if text in {"保守一点", "更保守", "保守模式", "decision_conservative"}: + return "decision_conservative" + if text in {"激进一点", "更激进", "激进模式", "decision_aggressive"}: + return "decision_aggressive" + if text in {"只用夸克", "只有夸克", "仅夸克", "只要夸克", "decision_only_quark"}: + return "decision_only_quark" + if text in {"只用115", "只有115", "仅115", "只要115", "decision_only_115"}: + return "decision_only_115" + if text in {"两者都可", "云盘都可", "115和夸克都可", "115夸克都可", "decision_cloud_both"}: + return "decision_cloud_both" + if text in {"只走pt", "只走原生", "只用pt", "只用原生", "只走mp", "只用mp", "decision_only_mp_pt"}: + return "decision_only_mp_pt" + if text in {"只走盘搜", "只用盘搜", "decision_only_pansou"}: + return "decision_only_pansou" + if text in {"只走影巢", "只用影巢", "decision_only_hdhive"}: + return "decision_only_hdhive" + if text in {"不用盘搜", "关闭盘搜", "禁用盘搜", "decision_disable_pansou"}: + return "decision_disable_pansou" + if text in {"不用影巢", "关闭影巢", "禁用影巢", "decision_disable_hdhive"}: + return "decision_disable_hdhive" + if text in {"不用pt", "不用原生", "关闭pt", "关闭原生", "关闭mp", "decision_disable_mp_pt"}: + return "decision_disable_mp_pt" + if text in {"按保存偏好", "恢复偏好", "恢复默认偏好", "清除会话偏好", "decision_reset_preferences"}: + return "decision_reset_preferences" + if text in {"确认执行", "确认执行吧", "就执行吧", "直接来", "执行它", "确认处理", "确认转存", "确认解锁"}: + return "best_execute" + if text in {"先计划", "先做计划", "先生成计划", "先出计划", "还是先计划"}: + return "best_plan" + if text in {"先看详情", "先看推荐", "先看结果", "看最佳", "看看最佳", "先看一下"}: + return "best" + if text in {"best_execute", "execute_best", "执行最佳", "最佳执行", "立即执行最佳", "直接执行最佳"}: + return "best_execute" + if text in {"best_plan", "plan_best", "计划最佳", "最佳计划", "计划推荐", "推荐计划", "最优计划"}: + return "best_plan" + if text in {"detail", "details", "review", "详情", "审查"}: + return "detail" + if text in {"best", "best_result", "recommend_best", "最佳", "最佳片源", "推荐片源", "推荐下载", "最优"}: + return "best" + if text in {"plan", "dry_run", "make_plan", "计划", "生成计划", "计划选择", "计划处理", "转存计划", "解锁计划"}: + return "plan" + if text in {"n", "next", "next_page", "下一页", "下页"} or text.startswith("n "): + return "next_page" + return "" + + @staticmethod + def _normalize_smart_search_short_action(value: Any, *, state_kind: str = "") -> str: + if str(state_kind or "").strip() != "assistant_smart_search": + return "" + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return "best" + if compact in {"计划", "做计划", "plan"}: + return "best_plan" + if compact in {"确认", "执行", "确认吧", "执行吧", "确定执行", "execute", "run"}: + return "best_execute" + return "" + + @staticmethod + def _is_pending_plan_confirmation_text(value: Any) -> bool: + raw = str(value or "").strip() + if not raw: + return False + compact = re.sub(r"\s+", "", raw).lower() + if re.search(r"\d", compact): + return False + return compact in { + "确认", + "确认吧", + "确认执行", + "确认执行吧", + "执行", + "执行吧", + "execute", + "run", + "确定", + "确定执行", + "执行下载", + "确认下载", + "开始下载", + "下载吧", + } + + @classmethod + def _parse_pending_plan_numeric_confirmation(cls, value: Any) -> int: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + if not raw: + return 0 + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + # Only a bare number or explicit execution wording may confirm a saved plan. + # Keep "下载1" for generating/reviewing the PT download plan in the current + # search result; otherwise it can accidentally execute an older pending plan. + match = re.fullmatch(r"(?:执行|确认|执行计划|确认计划)?(\d+)", compact, flags=re.IGNORECASE) + if not match: + return 0 + return cls._safe_int(match.group(1), 0) + + def _is_pending_plan_numeric_confirmation(self, value: Any, plan: Optional[Dict[str, Any]]) -> bool: + index = self._parse_pending_plan_numeric_confirmation(value) + if index <= 0 or not isinstance(plan, dict): + return False + expected_choices: List[int] = [] + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + expected_choices.append(self._safe_int(execute_body.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(execute_body.get("choice") or execute_body.get("index"), 0)) + for action in plan.get("actions") or []: + if isinstance(action, dict): + expected_choices.append(self._safe_int(action.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(action.get("choice") or action.get("index"), 0)) + return index in {choice for choice in expected_choices if choice > 0} + + @classmethod + def _normalize_ai_reingest_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + result: Dict[str, Any] = {} + ai_session_kinds = { + "assistant_ai_failed_samples", + "assistant_ai_sample_worklist", + "assistant_ai_sample_insights", + "assistant_ai_replay", + } + saved_plan = dict((current_state.get("saved_plan") or {}).get("latest") or {}) + saved_workflow = cls._clean_text(saved_plan.get("workflow")) + has_pending_ai_replay_plan = ( + bool((current_state.get("saved_plan") or {}).get("has_pending")) + and saved_workflow == "ai_replay_failed_sample" + ) + if has_pending_ai_replay_plan and compact in {"确认", "确认吧", "执行", "执行吧", "确定", "确定执行", "run", "execute"}: + return {"action": "execute_plan"} + if kind not in ai_session_kinds: + return {} + keyword = cls._clean_text(current_state.get("keyword")) + if keyword and compact in {"诊断", "本地诊断", "重新诊断", "看诊断"}: + return {"action": "mp_local_diagnose", "keyword": keyword} + if keyword and compact in {"入库状态", "看入库状态", "状态"}: + return {"action": "mp_ingest_status", "keyword": keyword} + if compact in {"工作清单", "返回工作清单", "回工作清单"}: + return {"action": "ai_sample_worklist", "keyword": keyword} + if compact in {"失败样本", "返回失败样本", "回失败样本"}: + return {"action": "ai_failed_samples", "keyword": keyword} + if compact in {"样本洞察", "洞察", "返回样本洞察", "回样本洞察"}: + return {"action": "ai_sample_insights", "keyword": keyword} + raw = cls._clean_text(value) + replay_match = re.match(r"^\s*(重放|重识别|重跑)\s*(\d+)(?:\s+(.*))?$", raw) + if replay_match: + result = { + "action": "ai_replay_failed_sample", + "sample_index": replay_match.group(2), + "keyword": "", + "mode": "", + "remove_if_resolved": "true", + } + remain_text = cls._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + result["remove_if_resolved"] = "false" + return result + return {} + + @classmethod + def _normalize_mp_recommend_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:决策|资源决策|智能决策)\s*(\d+)\s*$", {"mode": "smart_decision"}), + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + (r"^\s*(?:确认|执行|直接执行)\s*(\d+)\s*$", {"mode": "smart_execute"}), + (r"^\s*(?:盘搜|ps)\s*(\d+)\s*$", {"mode": "pansou"}), + (r"^\s*(?:影巢|yc)\s*(\d+)\s*$", {"mode": "hdhive"}), + (r"^\s*(?:原生|mp|pt)\s*(\d+)\s*$", {"mode": "mp"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + selected_index = cls._safe_int(current_state.get("selected_index"), 0) + if selected_index <= 0: + items = current_state.get("items") if isinstance(current_state.get("items"), list) else [] + if items: + selected_index = 1 + if selected_index > 0: + no_index_aliases = { + "详情": {"action": "detail"}, + "查看详情": {"action": "detail"}, + "看详情": {"action": "detail"}, + "决策": {"mode": "smart_decision"}, + "资源决策": {"mode": "smart_decision"}, + "智能决策": {"mode": "smart_decision"}, + "计划": {"action": "plan"}, + "生成计划": {"action": "plan"}, + "先计划": {"action": "plan"}, + "确认": {"mode": "smart_execute"}, + "执行": {"mode": "smart_execute"}, + "直接执行": {"mode": "smart_execute"}, + "盘搜": {"mode": "pansou"}, + "ps": {"mode": "pansou"}, + "影巢": {"mode": "hdhive"}, + "yc": {"mode": "hdhive"}, + "原生": {"mode": "mp"}, + "mp": {"mode": "mp"}, + "pt": {"mode": "mp"}, + } + shortcut = no_index_aliases.get(raw) + if shortcut: + return {"index": selected_index, **shortcut} + return {} + + @classmethod + def _normalize_mp_recommend_followup( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + followup_aliases = { + "电影": ("tmdb_movies", "movie"), + "热门电影": ("tmdb_movies", "movie"), + "电视剧": ("tmdb_tvs", "tv"), + "热门电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "热映": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "tmdb": ("tmdb_trending", "all"), + "热门": ("tmdb_trending", "all"), + } + source_pair = followup_aliases.get(compact) + if not source_pair: + return {} + source_name, media_type = source_pair + return {"action": "mp_recommendations", "keyword": source_name, "type": media_type} + + @classmethod + def _normalize_recommend_handoff_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact not in {"回推荐", "回榜单", "返回推荐", "返回榜单", "推荐", "榜单"}: + switch_mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp", + "mp": "mp", + } + switch_mode = switch_mode_aliases.get(compact) + if not switch_mode: + return {} + return {"action": "switch_recommend_handoff_source", "mode": switch_mode} + return {"action": "return_to_recommend"} + + @classmethod + def _normalize_recommend_handoff_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact in {"决策", "资源决策", "智能决策"}: + return {"action": "return_to_smart_decision"} + if kind in {"assistant_pansou", "assistant_mp"}: + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return {"route_action": "best"} + if compact in {"计划", "做计划", "plan"}: + return {"route_action": "best_plan"} + if compact in {"确认", "确认吧", "确认执行", "执行", "执行吧", "确定执行", "run", "execute"}: + return {"action": "confirm_recommend_handoff"} + return {} + + @classmethod + def _normalize_recommend_source_compound_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_mp_recommend", "assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + source_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "原生": "mp", + "mp": "mp", + } + action_aliases = { + "详情": "best", + "看详情": "best", + "计划": "best_plan", + "确认": "best_execute", + "执行": "best_execute", + } + for alias, mode in source_aliases.items(): + if not compact.startswith(alias): + continue + remain = compact[len(alias):] + followup_action = action_aliases.get(remain) + if not followup_action: + continue + return { + "action": "recommend_source_compound", + "mode": mode, + "followup_action": followup_action, + } + return {} + + @classmethod + def _normalize_mp_recommend_direct_intent(cls, value: Any) -> Dict[str, Any]: + raw = cls._clean_text(value) + if not raw: + return {} + prefix_match = cls._match_command_prefix(raw, ["智能发现", "热门发现", "热门推荐", "推荐"]) + if not prefix_match: + return {} + _, remain = prefix_match + remain = cls._clean_text(remain) + if not remain: + return {} + suffix_aliases: List[Tuple[str, Dict[str, str]]] = [ + ("资源决策", {"mode": "smart_decision"}), + ("智能决策", {"mode": "smart_decision"}), + ("查看详情", {"action": "detail"}), + ("看详情", {"action": "detail"}), + ("确认执行", {"mode": "smart_execute"}), + ("直接执行", {"mode": "smart_execute"}), + ("生成计划", {"action": "plan"}), + ("先计划", {"action": "plan"}), + ("详情", {"action": "detail"}), + ("决策", {"mode": "smart_decision"}), + ("计划", {"action": "plan"}), + ("确认", {"mode": "smart_execute"}), + ("执行", {"mode": "smart_execute"}), + ("盘搜", {"mode": "pansou"}), + ("影巢", {"mode": "hdhive"}), + ("原生", {"mode": "mp"}), + ] + for suffix, action_payload in suffix_aliases: + separator = f" {suffix}" + if remain.endswith(separator): + keyword = cls._clean_text(remain[: -len(separator)]) + elif remain == suffix: + keyword = "" + else: + continue + if not keyword: + return {} + return {"keyword": keyword, "index": 1, **action_payload} + return {} + + @staticmethod + def _normalize_pick_mode(value: Any) -> str: + text = str(value or "").strip().lower() + compact = re.sub(r"\s+", "", text) + if not compact: + return "" + if any(token in compact for token in ["smart_execute", "smartexecute", "直接执行", "确认执行", "立即执行", "确认", "执行"]): + return "smart_execute" + if any(token in compact for token in ["smart_plan", "smartplan", "生成计划", "先计划", "计划"]): + return "smart_plan" + if any(token in compact for token in ["smart_decision", "smartdecision", "智能决策", "资源决策", "决策"]): + return "smart_decision" + if re.search(r"(^|[^a-z])smart($|[^a-z])", text): + return "smart_decision" + if any(token in compact for token in ["hdhive", "影巢", "影潮", "走影巢", "用影巢"]): + return "hdhive" + if re.search(r"(^|[^a-z])yc($|[^a-z])", text): + return "hdhive" + if any(token in compact for token in ["pansou", "盘搜", "走盘搜", "用盘搜"]): + return "pansou" + if re.search(r"(^|[^a-z])ps($|[^a-z])", text): + return "pansou" + if any(token in compact for token in ["mp", "原生", "moviepilot", "站点", "pt"]): + return "mp" + return "" + + @staticmethod + def _normalize_fullwidth_digits(value: Any) -> str: + return str(value or "").translate(str.maketrans("0123456789", "0123456789")) + + @staticmethod + def _parse_chinese_pick_number(value: Any) -> int: + text = str(value or "").strip() + if not text: + return 0 + text = text.replace("两", "二").replace("〇", "零") + digits = { + "零": 0, + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + if text.isdigit(): + return int(text) + if any(ch not in digits and ch not in {"十", "百"} for ch in text): + return 0 + if len(text) == 1: + return digits.get(text, 0) + total = 0 + remainder = text + if "百" in remainder: + left, _, remainder = remainder.partition("百") + hundreds = 1 if not left else digits.get(left, 0) + if hundreds <= 0: + return 0 + total += hundreds * 100 + if "十" in remainder: + left, _, right = remainder.partition("十") + tens = 1 if not left else digits.get(left, 0) + ones = 0 if not right else digits.get(right, 0) + if tens <= 0 or (right and ones <= 0): + return 0 + total += tens * 10 + ones + return total + if total and len(remainder) == 1: + return total + digits.get(remainder, 0) + return total + + @classmethod + def _normalize_pick_action_fragment(cls, value: Any) -> str: + compact = re.sub(r"[\s的地得一下]+", "", str(value or "").strip().lower()) + if not compact: + return "" + action = cls._normalize_pick_action(compact) + if action: + return action + if any(token in compact for token in ["详情", "明细", "查看", "看看", "看下", "看一下"]): + return "detail" + if "审查" in compact: + return "detail" + if "下载" in compact: + return "plan" + if any(token in compact for token in ["计划", "dryrun", "makeplan"]): + return "plan" + return "" + + @classmethod + def _parse_compact_pick_text(cls, value: Any) -> Tuple[int, str]: + raw = cls._normalize_fullwidth_digits(value) + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + if not compact: + return 0, "" + chinese_number = r"[零〇一二两三四五六七八九十百]+" + number_token = rf"(?:\d+|{chinese_number})" + + def number_value(token: str) -> int: + return cls._safe_int(token, 0) if token.isdigit() else cls._parse_chinese_pick_number(token) + + # Short forms: "16详情", "十六详情", "16", "十六". + match = re.fullmatch(rf"({number_token})(.*)", compact) + if match: + index = number_value(match.group(1)) + suffix = match.group(2) + action = cls._normalize_pick_action_fragment(suffix) + if index > 0 and (not suffix or action): + return index, action + + # Reversed short forms: "详情16", "详情十六", "计划16". + match = re.fullmatch(rf"(.+?)({number_token})", compact) + if match: + action = cls._normalize_pick_action_fragment(match.group(1)) + index = number_value(match.group(2)) + if index > 0 and action: + return index, action + + # Natural phrases: "我要看看 16 的详情", "帮我看第十六个详情". + natural_action = cls._normalize_pick_action_fragment(compact) + if natural_action: + digit_match = re.search(r"\d+", compact) + if digit_match: + return cls._safe_int(digit_match.group(0), 0), natural_action + for cn_match in re.finditer(chinese_number, compact): + token = cn_match.group(0) + # Avoid treating "看一下详情" as "pick 1 detail". + if len(token) == 1 and token in {"一", "二", "三"}: + continue + index = cls._parse_chinese_pick_number(token) + if index > 0: + return index, natural_action + return 0, "" + + @classmethod + def _parse_pick_text(cls, value: Any) -> Tuple[int, str, str, str]: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + action = cls._normalize_pick_action(raw) + if action: + return 0, "", action, "" + alias_pattern = r"^(?:/smart_pick|smart_pick|计划选择|计划处理|生成计划|转存计划|解锁计划|计划(?=\s*\d)|选择|选|继续|pick|plan|dry_run|make_plan)\s*" + alias_match = re.match(alias_pattern, raw, flags=re.IGNORECASE) + digit_match = re.match(r"^(\d+)(.*)$", raw) + compact_index, compact_action = cls._parse_compact_pick_text(raw) + if compact_index > 0 and not alias_match: + return compact_index, "", compact_action, "" + if not alias_match and not digit_match: + return 0, "", "", "" + if digit_match and not alias_match: + suffix = cls._clean_text(digit_match.group(2)) + if suffix and not ( + suffix.startswith("/") + or "=" in suffix + or cls._normalize_pick_mode(suffix) + or cls._normalize_pick_action(suffix) + or suffix.lower() in {"n", "next"} + ): + return 0, "", "", "" + text = re.sub(alias_pattern, "", raw, flags=re.IGNORECASE).strip() + if alias_match and cls._normalize_pick_action(alias_match.group(0).strip()) == "plan": + action = "plan" + index = 0 + path = "" + mode = "" + pick_action = action or "" + match = re.search(r"\d+", text) + if match: + index = cls._safe_int(match.group(0), 0) + elif text: + compact_index, compact_action = cls._parse_compact_pick_text(text) + if compact_index > 0: + index = compact_index + if compact_action: + pick_action = compact_action + for token in text.split(): + if "=" not in token: + if not pick_action: + pick_action = cls._normalize_pick_action(token) + continue + key, token_value = token.split("=", 1) + key = key.strip().lower() + token_value = token_value.strip() + if key in {"path", "dir", "目录", "位置"} and token_value: + path = cls._resolve_pan_path_value(token_value) + elif key in {"mode", "search_mode", "target", "方式", "来源", "渠道"} and token_value: + mode = cls._normalize_pick_mode(token_value) + if not mode: + mode = cls._normalize_pick_mode(text) + if not pick_action: + suffix = re.sub(r"\d+", " ", text, count=1).strip() + pick_action = cls._normalize_pick_action(suffix) + return index, path, pick_action, mode + + def init_plugin(self, config: Optional[Dict[str, Any]] = None): + config = config or {} + self._enabled = bool(config.get("enabled", False)) + self._notify = bool(config.get("notify", True)) + self._debug = bool(config.get("debug", False)) + self._quark_cookie = self._clean_text(config.get("quark_cookie")) + self._quark_default_path = self._normalize_path(config.get("quark_default_path") or "/飞书") + self._quark_timeout = self._safe_int(config.get("quark_timeout"), 30) + self._quark_auto_import_cookiecloud = bool(config.get("quark_auto_import_cookiecloud", True)) + self._pansou_base_url = self._clean_text(config.get("pansou_base_url") or "http://127.0.0.1:805").rstrip("/") + self._pansou_timeout = max(3, min(120, self._safe_int(config.get("pansou_timeout"), 20))) + self._hdhive_api_key = self._clean_text(config.get("hdhive_api_key")) + self._hdhive_base_url = self._clean_text(config.get("hdhive_base_url") or "https://hdhive.com").rstrip("/") + self._hdhive_timeout = self._safe_int(config.get("hdhive_timeout"), 30) + self._hdhive_default_path = self._normalize_path(config.get("hdhive_default_path") or "/待整理") + self._hdhive_candidate_page_size = max(5, min(20, self._safe_int(config.get("hdhive_candidate_page_size"), type(self)._hdhive_candidate_page_size))) + self._hdhive_resource_enabled = bool(config.get("hdhive_resource_enabled", True)) + self._hdhive_max_unlock_points = max(0, self._safe_int(config.get("hdhive_max_unlock_points"), 20)) + self._hdhive_checkin_enabled = bool(config.get("hdhive_checkin_enabled", False)) + self._hdhive_checkin_gambler_mode = bool(config.get("hdhive_checkin_gambler_mode", False)) + self._hdhive_checkin_once = bool(config.get("hdhive_checkin_once", False)) + self._hdhive_checkin_cron = self._clean_text(config.get("hdhive_checkin_cron") or "0 8 * * *") + self._hdhive_checkin_cookie = self._clean_text(config.get("hdhive_checkin_cookie")) + self._hdhive_checkin_auto_login = bool(config.get("hdhive_checkin_auto_login", True)) + self._hdhive_checkin_username = self._clean_text(config.get("hdhive_checkin_username")) + self._hdhive_checkin_password = self._clean_text(config.get("hdhive_checkin_password")) + self._p115_default_path = self._normalize_path(config.get("p115_default_path") or "/待整理") + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(config.get("p115_client_type")) + self._p115_cookie = self._clean_text(config.get("p115_cookie")) + self._p115_prefer_direct = bool(config.get("p115_prefer_direct", True)) + self._mp_download_save_path = self._clean_text(config.get("mp_download_save_path")) + self._assistant_default_pt_min_seeders = max(0, self._safe_int(config.get("assistant_default_pt_min_seeders"), 3)) + self._assistant_default_auto_ingest_enabled = bool(config.get("assistant_default_auto_ingest_enabled", False)) + self._assistant_default_auto_ingest_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_auto_ingest_score_threshold"), 90))) + self._assistant_default_confirm_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_confirm_score_threshold"), 70))) + self._feishu_enabled = bool(config.get("feishu_enabled", False)) + self._feishu_allow_all = bool(config.get("feishu_allow_all", False)) + self._feishu_reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self._feishu_reply_receive_id_type = self._clean_text(config.get("feishu_reply_receive_id_type") or "chat_id") + self._feishu_app_id = self._clean_text(config.get("feishu_app_id")) + self._feishu_app_secret = self._clean_text(config.get("feishu_app_secret")) + self._feishu_verification_token = self._clean_text(config.get("feishu_verification_token")) + self._feishu_allowed_chat_ids = FeishuChannel.split_lines(config.get("feishu_allowed_chat_ids")) + self._feishu_allowed_user_ids = FeishuChannel.split_lines(config.get("feishu_allowed_user_ids")) + self._feishu_command_whitelist = FeishuChannel.merge_command_whitelist( + FeishuChannel.split_commands(config.get("feishu_command_whitelist")) + ) + self._feishu_command_aliases = FeishuChannel.merge_command_aliases( + self._clean_text(config.get("feishu_command_aliases")) + ) + self._feishu_command_mode = self._clean_text(config.get("feishu_command_mode") or "resource_officer") + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + self._restore_persisted_sessions() + self._restore_execution_history() + self._restore_hdhive_checkin_history() + self._restore_workflow_plans() + self._restore_assistant_preferences() + self._agent_tools_reloaded = False + self._ensure_feishu_channel().configure(self._build_config()) + if self._enabled and self._feishu_enabled: + self._feishu_channel.start() + elif self._feishu_channel is not None: + self._feishu_channel.stop() + self._maybe_run_hdhive_checkin_once() + + def get_state(self) -> bool: + if self._enabled: + self._maybe_reload_agent_tools_once() + return self._enabled + + def get_agent_tools(self) -> List[type]: + return [ + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantPlansTool, + AssistantPlansClearTool, + AssistantRecoverTool, + AssistantPulseTool, + AssistantStartupTool, + AssistantMaintainTool, + AssistantToolboxTool, + AssistantRequestTemplatesTool, + AssistantSelfcheckTool, + AssistantReadinessTool, + FeishuChannelHealthTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantRouteTool, + AssistantPickTool, + AssistantWorkflowTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + ShareRouteTool, + P115QRCodeStartTool, + P115QRCodeCheckTool, + P115StatusTool, + P115PendingTool, + P115ResumePendingTool, + P115CancelPendingTool, + ] + + @staticmethod + def _reload_agent_tools() -> None: + if moviepilot_tool_manager is None: + return + try: + moviepilot_tool_manager._load_tools() + except Exception: + return + + def _maybe_reload_agent_tools_once(self) -> None: + if moviepilot_tool_manager is None: + return + now = time.time() + with self.__class__._agent_tools_reload_lock: + if ( + self.__class__._agent_tools_reload_version == self.plugin_version + and now - self.__class__._agent_tools_reload_at < 600 + ): + return + # Mark before reloading, because tool loading can query plugin states recursively. + self.__class__._agent_tools_reload_version = self.plugin_version + self.__class__._agent_tools_reload_at = now + self._reload_agent_tools() + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _parse_optional_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return None + + @classmethod + def _parse_bool_value(cls, value: Any, default: bool = False) -> bool: + parsed = cls._parse_optional_bool(value) + return bool(default) if parsed is None else bool(parsed) + + @staticmethod + def _normalize_path(value: Any) -> str: + return QuarkTransferService.normalize_path(value) + + @staticmethod + def _friendly_hdhive_error(message: str, capability: str) -> str: + text = str(message or "").strip() + lowered = text.lower() + if "premium" in lowered or "仅对 premium 用户开放" in text: + if capability == "checkin": + return "影巢 OpenAPI 签到当前需要 Premium 用户;普通用户可配置网页 Cookie 或账号密码启用网页签到兜底。" + return f"影巢 OpenAPI 的{capability}接口当前需要 Premium 用户。" + return text or f"影巢 {capability} 接口调用失败" + + @staticmethod + def _is_hdhive_premium_limited(message: str) -> bool: + text = str(message or "").strip() + lowered = text.lower() + return "premium" in lowered or "仅对 premium 用户开放" in text + + @staticmethod + def _read_json_file(path: Path) -> Optional[Dict[str, Any]]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + @classmethod + def _hdhive_daily_sign_config_paths(cls) -> List[Path]: + return [ + Path("/config/plugins/hdhivedailysign.json"), + Path("/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json"), + ] + + @classmethod + def _hdhive_daily_sign_user_info_paths(cls) -> List[Path]: + return [ + Path("/config/logs/plugins/hdhivedailysign_user_info.json"), + Path("/Applications/Dockge/moviepilotv2/config/logs/plugins/hdhivedailysign_user_info.json"), + ] + + @classmethod + def _load_hdhive_daily_sign_config(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_config_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _load_hdhive_daily_sign_user_info(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_user_info_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _build_hdhive_account_snapshot(cls, snapshot: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(snapshot, dict) or not snapshot: + return {} + return { + "id": snapshot.get("id"), + "nickname": snapshot.get("nickname"), + "username": snapshot.get("nickname"), + "avatar_url": snapshot.get("avatar_url"), + "created_at": snapshot.get("created_at"), + "is_vip": False, + "source": "hdhivedailysign_snapshot", + "user_meta": { + "points": snapshot.get("points"), + "signin_days_total": snapshot.get("signin_days_total"), + }, + "warnings_nums": snapshot.get("warnings_nums"), + } + + @classmethod + def _extract_hdhive_account_fields(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload if isinstance(payload, dict) else {} + meta = data.get("user_meta") if isinstance(data.get("user_meta"), dict) else {} + return { + "nickname": data.get("nickname") or data.get("username") or "—", + "points": meta.get("points", data.get("points", "—")), + "signin_days_total": meta.get("signin_days_total", data.get("signin_days_total", "—")), + "is_vip": bool(data.get("is_vip")), + } + + def _get_hdhive_fallback_cookie(self) -> str: + own_cookie = self._clean_text(self._hdhive_checkin_cookie) + if own_cookie: + return own_cookie + config = self._load_hdhive_daily_sign_config() + return self._clean_text(config.get("cookie")) + + def _refresh_hdhive_checkin_cookie(self) -> Tuple[bool, str, str]: + if not self._hdhive_checkin_auto_login: + return False, "", "未启用影巢自动登录刷新 Cookie" + service = self._ensure_hdhive_service() + # Playwright sync API cannot run inside MoviePilot's asyncio loop; keep login isolated. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + service.login_for_cookie, + username=self._hdhive_checkin_username, + password=self._hdhive_checkin_password, + ) + try: + login_ok, cookie_string, login_message = future.result(timeout=max(60, self._hdhive_timeout * 4)) + except Exception as exc: + return False, "", f"影巢自动登录超时或异常: {exc}" + if not login_ok or not cookie_string: + return False, "", login_message or "影巢自动登录失败" + self._hdhive_checkin_cookie = cookie_string + try: + self.update_config(self._build_config()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢自动登录已获取 Cookie,但保存配置失败:{exc}") + return True, cookie_string, login_message or "影巢自动登录成功" + + def _run_hdhive_checkin(self, *, is_gambler: Optional[bool] = None, trigger: str = "Agent影视助手") -> Dict[str, Any]: + if not self._hdhive_checkin_enabled: + return self._hdhive_checkin_disabled_response() + service = self._ensure_hdhive_service() + final_gambler_mode = self._hdhive_checkin_gambler_mode if is_gambler is None else bool(is_gambler) + checkin_ok, result, checkin_message = service.perform_checkin( + is_gambler=final_gambler_mode, + trigger=trigger, + ) + if checkin_ok: + final_result = {"success": True, "message": result.get("message") or "success", "data": result} + self._record_hdhive_checkin_history(trigger=trigger, is_gambler=final_gambler_mode, result=final_result) + return final_result + + raw_message = result.get("message") or checkin_message + checkin_status_code = self._safe_int(result.get("status_code"), 0) if isinstance(result, dict) else 0 + should_try_web_fallback = ( + self._is_hdhive_premium_limited(raw_message) + or checkin_status_code in (404, 405) + or "405 not allowed" in self._clean_text(raw_message).lower() + or " None: + if not self._enabled or not self._hdhive_checkin_once: + return + self._hdhive_checkin_once = False + try: + self.update_config(self._build_config({"hdhive_checkin_once": False})) + except Exception as exc: + logger.warning(f"[Agent影视助手] 重置“立即影巢签到”开关失败:{exc}") + + def _run_once() -> None: + try: + result = self._run_hdhive_checkin(trigger="Agent影视助手 插件页立即签到") + status = "成功" if result.get("success") else "失败" + logger.info(f"[Agent影视助手] 插件页立即影巢签到{status}: {result.get('message')}") + except Exception as exc: + logger.error(f"[Agent影视助手] 插件页立即影巢签到异常: {exc}") + + threading.Thread(target=_run_once, name="aro-hdhive-checkin-once", daemon=True).start() + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled or not self._hdhive_checkin_enabled or not self._hdhive_checkin_cron: + return [] + if CronTrigger is None: + logger.warning("[Agent影视助手] apscheduler 不可用,无法注册影巢定时签到") + return [] + try: + trigger = CronTrigger.from_crontab(self._hdhive_checkin_cron) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢签到 Cron 配置无效:{self._hdhive_checkin_cron} {exc}") + return [] + return [{ + "id": "agentresourceofficer_hdhive_checkin", + "name": "Agent影视助手影巢签到", + "trigger": trigger, + "func": self._scheduled_hdhive_checkin, + "kwargs": {}, + }] + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "debug": self._debug, + "quark_cookie": self._quark_cookie, + "quark_default_path": self._quark_default_path, + "quark_timeout": self._quark_timeout, + "quark_auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + "pansou_base_url": self._pansou_base_url, + "pansou_timeout": self._pansou_timeout, + "hdhive_api_key": self._hdhive_api_key, + "hdhive_base_url": self._hdhive_base_url, + "hdhive_timeout": self._hdhive_timeout, + "hdhive_default_path": self._hdhive_default_path, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "hdhive_checkin_once": self._hdhive_checkin_once, + "hdhive_checkin_cron": self._hdhive_checkin_cron, + "hdhive_checkin_cookie": self._hdhive_checkin_cookie, + "hdhive_checkin_auto_login": self._hdhive_checkin_auto_login, + "hdhive_checkin_username": self._hdhive_checkin_username, + "hdhive_checkin_password": self._hdhive_checkin_password, + "p115_default_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie": self._p115_cookie, + "p115_prefer_direct": self._p115_prefer_direct, + "mp_download_save_path": self._mp_download_save_path, + "assistant_default_pt_min_seeders": self._assistant_default_pt_min_seeders, + "assistant_default_auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "assistant_default_auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "assistant_default_confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "feishu_enabled": self._feishu_enabled, + "feishu_allow_all": self._feishu_allow_all, + "feishu_reply_enabled": self._feishu_reply_enabled, + "feishu_reply_receive_id_type": self._feishu_reply_receive_id_type, + "feishu_app_id": self._feishu_app_id, + "feishu_app_secret": self._feishu_app_secret, + "feishu_verification_token": self._feishu_verification_token, + "feishu_allowed_chat_ids": "\n".join(self._feishu_allowed_chat_ids), + "feishu_allowed_user_ids": "\n".join(self._feishu_allowed_user_ids), + "feishu_command_whitelist": ",".join(self._feishu_command_whitelist), + "feishu_command_aliases": self._feishu_command_aliases, + "feishu_command_mode": self._feishu_command_mode, + } + if overrides: + config.update(overrides) + return config + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + async def _request_payload(self, request: Request) -> Dict[str, Any]: + if str(getattr(request, "method", "") or "").upper() == "GET": + return {} + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _ensure_quark_service(self) -> QuarkTransferService: + if self._quark_service is None: + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + else: + self._quark_service.set_cookie(self._quark_cookie) + self._quark_service.timeout = max(10, self._safe_int(self._quark_timeout, 30)) + self._quark_service.default_target_path = self._quark_default_path + self._quark_service.auto_import_cookiecloud = self._quark_auto_import_cookiecloud + self._quark_service.cookie_refresh_callback = self._refresh_quark_cookie_from_cookiecloud + return self._quark_service + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _refresh_quark_cookie_from_cookiecloud(self) -> str: + cookie, _message = self._load_cookiecloud_quark_cookie() + if cookie: + self._quark_cookie = cookie + return cookie + + def _ensure_hdhive_service(self) -> HDHiveOpenApiService: + if self._hdhive_service is None: + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + else: + self._hdhive_service.api_key = self._hdhive_api_key + self._hdhive_service.base_url = self._hdhive_base_url + self._hdhive_service.timeout = self._hdhive_timeout + return self._hdhive_service + + def _ensure_p115_service(self) -> P115TransferService: + if self._p115_service is None: + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + else: + self._p115_service.default_target_path = self._p115_default_path + self._p115_service.set_cookie(self._p115_cookie) + self._p115_service.prefer_direct = self._p115_prefer_direct + return self._p115_service + + def _apply_runtime_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = self._build_config(overrides) + self.update_config(config) + self.init_plugin(config) + return config + + @staticmethod + def _p115_client_type_items() -> List[Dict[str, str]]: + return [ + {"title": "支付宝小程序(推荐)", "value": "alipaymini"}, + {"title": "115 Android", "value": "115android"}, + {"title": "115 iOS", "value": "115ios"}, + {"title": "115 iPad", "value": "115ipad"}, + {"title": "115 TV", "value": "tv"}, + {"title": "微信小程序", "value": "wechatmini"}, + {"title": "Web", "value": "web"}, + ] + + @classmethod + def _p115_client_type_title(cls, value: str) -> str: + final_value = P115TransferService.normalize_qrcode_client_type(value) + for item in cls._p115_client_type_items(): + if item.get("value") == final_value: + return str(item.get("title") or final_value) + return final_value + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + if self._feishu_channel is not None: + self._feishu_channel.stop() + return + + def _ensure_feishu_channel(self) -> FeishuChannel: + if self._feishu_channel is None: + self._feishu_channel = FeishuChannel(self) + return self._feishu_channel + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/quark/health", + "endpoint": self.api_quark_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的夸克配置", + }, + { + "path": "/quark/transfer", + "endpoint": self.api_quark_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行夸克分享转存", + }, + { + "path": "/hdhive/health", + "endpoint": self.api_hdhive_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的影巢配置", + }, + { + "path": "/hdhive/account", + "endpoint": self.api_hdhive_account, + "methods": ["GET"], + "summary": "获取影巢当前账号信息", + }, + { + "path": "/hdhive/checkin", + "endpoint": self.api_hdhive_checkin, + "methods": ["POST"], + "summary": "执行影巢普通签到或赌狗签到", + }, + { + "path": "/hdhive/checkin/history", + "endpoint": self.api_hdhive_checkin_history, + "methods": ["GET"], + "summary": "查看插件保存的影巢签到日志", + }, + { + "path": "/hdhive/quota", + "endpoint": self.api_hdhive_quota, + "methods": ["GET"], + "summary": "获取影巢当前配额信息", + }, + { + "path": "/hdhive/usage_today", + "endpoint": self.api_hdhive_usage_today, + "methods": ["GET"], + "summary": "获取影巢今日用量统计", + }, + { + "path": "/hdhive/weekly_free_quota", + "endpoint": self.api_hdhive_weekly_free_quota, + "methods": ["GET"], + "summary": "获取影巢每周免费解锁额度", + }, + { + "path": "/hdhive/search", + "endpoint": self.api_hdhive_search, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源搜索", + }, + { + "path": "/hdhive/search_by_keyword", + "endpoint": self.api_hdhive_search_by_keyword, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢关键词候选搜索", + }, + { + "path": "/hdhive/unlock", + "endpoint": self.api_hdhive_unlock, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源解锁", + }, + { + "path": "/hdhive/unlock_and_route", + "endpoint": self.api_hdhive_unlock_and_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 解锁影巢资源并尝试自动路由到对应网盘执行层", + }, + { + "path": "/p115/health", + "endpoint": self.api_p115_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 转存依赖状态", + }, + { + "path": "/p115/qrcode", + "endpoint": self.api_p115_qrcode, + "methods": ["GET"], + "summary": "获取 Agent影视助手 的 115 扫码登录二维码", + }, + { + "path": "/p115/qrcode/check", + "endpoint": self.api_p115_qrcode_check, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 扫码登录状态", + }, + { + "path": "/p115/transfer", + "endpoint": self.api_p115_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行 115 分享转存", + }, + { + "path": "/p115/pending", + "endpoint": self.api_p115_pending, + "methods": ["GET", "POST"], + "summary": "查看指定会话中待继续的 115 任务", + }, + { + "path": "/p115/pending/resume", + "endpoint": self.api_p115_pending_resume, + "methods": ["POST"], + "summary": "继续执行指定会话中待处理的 115 任务", + }, + { + "path": "/p115/pending/cancel", + "endpoint": self.api_p115_pending_cancel, + "methods": ["POST"], + "summary": "取消指定会话中待处理的 115 任务", + }, + { + "path": "/share/route", + "endpoint": self.api_share_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 自动识别 115 / 夸克分享链接并执行对应转存", + }, + { + "path": "/feishu/health", + "endpoint": self.api_feishu_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 内置飞书入口状态", + }, + { + "path": "/assistant/route", + "endpoint": self.api_assistant_route, + "methods": ["POST"], + "summary": "统一智能入口:盘搜 / 影巢 / 直链分享", + }, + { + "path": "/assistant/pick", + "endpoint": self.api_assistant_pick, + "methods": ["POST"], + "summary": "统一智能入口的按编号继续执行", + }, + { + "path": "/assistant/capabilities", + "endpoint": self.api_assistant_capabilities, + "methods": ["GET"], + "summary": "查看统一智能入口支持的结构化参数、默认值与推荐调用方式", + }, + { + "path": "/assistant/readiness", + "endpoint": self.api_assistant_readiness, + "methods": ["GET"], + "summary": "检查 Agent影视助手 是否已准备好给外部智能体调用", + }, + { + "path": "/assistant/pulse", + "endpoint": self.api_assistant_pulse, + "methods": ["GET"], + "summary": "轻量启动探针:返回版本、关键服务状态、警告和最佳恢复建议", + }, + { + "path": "/assistant/startup", + "endpoint": self.api_assistant_startup, + "methods": ["GET"], + "summary": "启动聚合包:一次返回 pulse、自检、核心工具、端点、默认目录和恢复建议", + }, + { + "path": "/assistant/maintain", + "endpoint": self.api_assistant_maintain, + "methods": ["GET", "POST"], + "summary": "低风险维护:查看或执行过期会话、已执行计划清理", + }, + { + "path": "/assistant/toolbox", + "endpoint": self.api_assistant_toolbox, + "methods": ["GET"], + "summary": "轻量工具清单:返回推荐工具、端点、工作流、动作名、默认目录和命令示例", + }, + { + "path": "/assistant/request_templates", + "endpoint": self.api_assistant_request_templates, + "methods": ["GET", "POST"], + "summary": "轻量请求模板:返回外部智能体常用 assistant 请求模板", + }, + { + "path": "/assistant/selfcheck", + "endpoint": self.api_assistant_selfcheck, + "methods": ["GET"], + "summary": "轻量自检:确认 compact 协议、模板默认 compact 和布尔字符串解析是否正常", + }, + { + "path": "/assistant/history", + "endpoint": self.api_assistant_history, + "methods": ["GET"], + "summary": "查看最近执行历史,便于外部智能体判断上一步是否完成", + }, + { + "path": "/assistant/action", + "endpoint": self.api_assistant_action, + "methods": ["POST"], + "summary": "直接执行统一智能入口返回的动作模板名,适合外部智能体无映射继续执行", + }, + { + "path": "/assistant/actions", + "endpoint": self.api_assistant_actions, + "methods": ["POST"], + "summary": "批量执行多个动作模板,适合外部智能体一次请求串起多步工作流,减少往返", + }, + { + "path": "/assistant/workflow", + "endpoint": self.api_assistant_workflow, + "methods": ["GET", "POST"], + "summary": "运行预设工作流,适合外部智能体用更短参数完成常见资源任务", + }, + { + "path": "/assistant/preferences", + "endpoint": self.api_assistant_preferences, + "methods": ["GET", "POST", "DELETE"], + "summary": "读取、保存或重置智能体片源偏好画像,用于云盘和 PT 分源评分", + }, + { + "path": "/assistant/plan/execute", + "endpoint": self.api_assistant_plan_execute, + "methods": ["POST"], + "summary": "执行 dry_run 保存的工作流计划,避免外部智能体重复携带大 JSON", + }, + { + "path": "/assistant/plans", + "endpoint": self.api_assistant_plans, + "methods": ["GET"], + "summary": "查看 dry_run 保存的工作流计划,便于断线恢复和选择 plan_id", + }, + { + "path": "/assistant/plans/clear", + "endpoint": self.api_assistant_plans_clear, + "methods": ["POST"], + "summary": "清理 dry_run 保存的工作流计划", + }, + { + "path": "/assistant/recover", + "endpoint": self.api_assistant_recover, + "methods": ["GET", "POST"], + "summary": "查看或直接执行当前最推荐的恢复动作,给外部智能体提供单入口续跑能力", + }, + { + "path": "/assistant/session", + "endpoint": self.api_assistant_session_state, + "methods": ["GET", "POST"], + "summary": "查看统一智能入口当前会话状态与建议动作", + }, + { + "path": "/assistant/session/clear", + "endpoint": self.api_assistant_session_clear, + "methods": ["POST"], + "summary": "清理统一智能入口当前会话缓存", + }, + { + "path": "/assistant/sessions", + "endpoint": self.api_assistant_sessions, + "methods": ["GET"], + "summary": "列出当前活跃的统一智能入口会话,便于外部智能体恢复和接续", + }, + { + "path": "/assistant/sessions/clear", + "endpoint": self.api_assistant_sessions_clear, + "methods": ["POST"], + "summary": "按 session_id、类型或过滤条件批量清理统一智能入口会话", + }, + { + "path": "/session/hdhive/search", + "endpoint": self.api_session_hdhive_search, + "methods": ["POST"], + "summary": "创建影巢搜索会话并返回候选影片列表", + }, + { + "path": "/session/hdhive/pick", + "endpoint": self.api_session_hdhive_pick, + "methods": ["POST"], + "summary": "按编号继续影巢会话:候选选片或资源解锁落盘", + }, + ] + + def _build_hdhive_page_summary(self) -> str: + if not self._enabled: + return "插件未启用" + if not self._hdhive_api_key: + return "影巢 API Key 未配置" + service = self._ensure_hdhive_service() + account_ok, account_result, account_message = service.fetch_me() + quota_ok, quota_result, _quota_message = service.fetch_quota() + usage_ok, usage_result, _usage_message = service.fetch_usage_today() + + account = account_result.get("data") or {} + account_source = "hdhive_openapi" + if not account_ok and self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + account = fallback_account + account_ok = True + account_source = "hdhivedailysign_snapshot" + account_fields = self._extract_hdhive_account_fields(account) + quota = quota_result.get("data") or {} + usage = usage_result.get("data") or {} + + return ( + f"影巢账号:{'可用' if account_ok else '异常'}" + f"\n资源入口:{'开启' if self._hdhive_resource_enabled else '关闭'}" + f"\n单资源积分上限:{self._hdhive_max_unlock_points if self._hdhive_max_unlock_points > 0 else '不限制'}" + f"\n签到入口:{'开启' if self._hdhive_checkin_enabled else '关闭'}" + f"\n昵称:{account_fields.get('nickname', '—')}" + f"\n积分:{account_fields.get('points', '—')}" + f"\nVIP:{'是' if account_fields.get('is_vip') else '否'}" + f"\n累计签到:{account_fields.get('signin_days_total', '—')}" + f"\n今日剩余配额:{quota.get('endpoint_remaining', '—')}" + f"\n今日总调用:{usage.get('total_calls', '—')}" + f"\n账号来源:{'网页快照' if account_source == 'hdhivedailysign_snapshot' else 'OpenAPI'}" + ) + + def get_page(self) -> List[dict]: + quark_ready = "已配置" if self._quark_cookie else "未配置" + hdhive_ready = "已配置" if self._hdhive_api_key else "未配置" + p115_health_ok, p115_health, _p115_health_message = self._ensure_p115_service().health() + cookie_state = p115_health.get("cookie_state") or {} + if cookie_state.get("valid"): + p115_ready = "已配置扫码会话" + elif cookie_state.get("configured"): + p115_ready = "已配置但不是扫码会话" + else: + p115_ready = "复用 115 助手客户端" + hdhive_summary = self._build_hdhive_page_summary() + feishu_health = self._ensure_feishu_channel().health() + feishu_state = "已启用" if feishu_health.get("enabled") else "未启用" + feishu_running = "运行中" if feishu_health.get("running") else "未运行" + hdhive_lines = [line.strip() for line in str(hdhive_summary or "").splitlines() if line.strip()] + hdhive_compact_lines = hdhive_lines[:4] + if len(hdhive_lines) >= 6: + hdhive_compact_lines.append(f"{hdhive_lines[4]} / {hdhive_lines[5]}") + p115_cookie_message = cookie_state.get("message") or "当前会话可直接用于 115 直转" + + def text_line(text: str, css_class: str = "text-body-2 py-1") -> Dict[str, Any]: + return { + "component": "div", + "props": {"class": css_class}, + "text": text, + } + + def status_card(title: str, subtitle: str, lines: List[str], color: str = "primary") -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"variant": "tonal", "color": color, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1"}, + "text": title, + }, + { + "component": "VCardSubtitle", + "props": {"class": "text-body-2"}, + "text": subtitle, + }, + { + "component": "VCardText", + "props": {"class": "py-2"}, + "content": [text_line(line, "text-body-2 py-0") for line in lines], + }, + ], + } + + def section_card(title: str, lines: List[str], compact: bool = False) -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"flat": True, "border": True, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1" if compact else "text-subtitle-1 font-weight-bold"}, + "text": title, + }, + { + "component": "VCardText", + "props": {"class": "py-2"} if compact else {}, + "content": [text_line(line, "text-body-2 py-0") for line in lines] if compact else [text_line(line) for line in lines], + }, + ], + } + + return [ + { + "component": "VContainer", + "props": {"fluid": True, "class": "pa-0"}, + "content": [ + { + "component": "VRow", + "props": {"dense": True}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "影巢", + hdhive_ready, + [ + f"默认目录:{self._hdhive_default_path}", + "能力:搜索 / 解锁 / 签到", + "API:/hdhive/account /checkin /quota", + ], + "success" if self._hdhive_api_key else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "115", + "可用" if p115_health_ok else "待修复", + [ + f"默认目录:{self._p115_default_path}", + f"登录方式:{p115_ready}", + f"扫码客户端:{self._p115_client_type_title(self._p115_client_type)}", + ], + "success" if p115_health_ok else "error", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "夸克", + quark_ready, + [ + f"默认目录:{self._quark_default_path}", + "能力:分享链接转存", + "入口:通用分享路由", + ], + "success" if self._quark_cookie else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "飞书", + f"{feishu_state},长连接:{feishu_running}", + [ + "模式:内置 Channel", + "健康检查:/feishu/health", + "建议:只保留一个飞书入口监听", + ], + "success" if feishu_health.get("running") else "secondary", + ) + ], + }, + ], + }, + { + "component": "VRow", + "props": {"dense": True, "class": "mt-3"}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "智能体入口", + [ + "统一路由:/assistant/route", + "继续选择:/assistant/pick", + "工作流:/assistant/workflow", + "计划执行:/assistant/plan/execute", + "Agent Tool:搜索/选择、115 扫码、待任务查看/继续/取消、通用分享路由", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "账号与签到", + hdhive_compact_lines + + [ + f"115 Cookie:{p115_cookie_message}", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "盘搜服务", + [ + f"API 地址:{self._pansou_base_url}", + f"请求超时:{self._pansou_timeout} 秒", + "用法:发送“盘搜搜索 片名”“ps片名”或“1片名”。", + "说明:插件只负责调用 PanSou API,本机需要先运行 PanSou 服务。", + ], + compact=True, + ) + ], + } + ], + }, + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "class": "mt-4 mb-1", + "title": "统一资源入口", + }, + "content": [ + text_line( + "Agent影视助手支持三种接入模式:飞书直接发命令、外部智能体调用 skill/helper、MP 内置智能体调用 Agent Tool。", + "text-body-2 mb-3", + ), + text_line( + "不接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2 mb-3", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "如果你只想直接用插件或飞书入口,不需要额外安装 skill。\n" + "直接使用这些命令即可:搜索 片名 / 云盘搜索 片名 / 转存 片名 / 下载 片名 / 更新检查 片名。" + ), + }, + text_line( + "接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "插件页不再直接放大段接入提示词,避免复制到旧配置。\n" + "请按快速开始主页和外部智能体接入文档配置:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins\n\n" + "外部智能体接入文档:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md\n\n" + "如果客户端支持 MoviePilot 官方 MCP,也请按文档里的分工接入;资源流仍优先使用 agent-resource-officer skill/helper。\n" + "长会话跑偏时,可以直接对智能体说:校准影视技能。" + ), + }, + ], + }, + ], + } + ] + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + return "vuetify", None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + form = [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "插件把资源搜索、链接转存、扫码登录、飞书消息和智能体调用集中到一个入口。首次使用先配置默认目录、影巢 OpenAPI、夸克会话,以及需要的飞书机器人信息。调试模式仅排查问题时打开。", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "debug", + "label": "调试模式", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下面这组是智能体默认评分策略,只影响还没有保存个人偏好的新会话。高分不代表一定执行;遇到影巢高积分、PT 低做种这类硬风险时,插件仍会拦截。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "mp_download_save_path", + "label": "PT 下载保存路径(可选)", + "placeholder": "MP 和 qB 在同一台机器可留空;不在同一台机器时填 qB 默认下载路径,如 /media/downloads/qb", + "hint": "只影响“下载 / MP搜索 / PT搜索”。MP 与 qB 分离时,填 qB WebUI 里的默认保存路径;同机一般不用填。", + "persistentHint": True, + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_pt_min_seeders", + "label": "PT 最低做种数", + "type": "number", + "placeholder": "3", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_confirm_score_threshold", + "label": "建议确认分数线", + "type": "number", + "placeholder": "70", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_auto_ingest_score_threshold", + "label": "自动入库分数线", + "type": "number", + "placeholder": "90", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "assistant_default_auto_ingest_enabled", + "label": "默认允许高分自动入库", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢用于资源搜索、解锁、配额查询和签到。资源入口关闭后,智能体和飞书都不会执行影巢搜索、解锁或转存;单资源积分上限默认 20 分,超过就拦截提醒,填 0 表示不限制。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_api_key", + "label": "影巢 API Key", + "rows": 2, + "placeholder": "填写影巢 OpenAPI 的 API Key", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_resource_enabled", + "label": "启用影巢资源搜索/解锁", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_max_unlock_points", + "label": "单资源积分上限", + "type": "number", + "placeholder": "20;填 0 不限制", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "text": "建议保留积分上限,避免智能体一步到位时误选高积分资源。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_base_url", + "label": "影巢 Base URL", + "placeholder": "https://hdhive.com", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_timeout", + "label": "影巢超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_default_path", + "label": "影巢默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_candidate_page_size", + "label": "候选页大小", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢签到支持 OpenAPI 与网页兜底两种方式。OpenAPI 签到需要 Premium;普通用户建议优先使用本机“影巢Cookie导出.command”自动写回完整网页登录 Cookie。手工复制 Cookie 容易漏字段,导致看起来已填写但签到仍失败。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_enabled", + "label": "启用影巢签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_gambler_mode", + "label": "默认赌狗签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_once", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_cron", + "label": "影巢签到 Cron", + "placeholder": "0 8 * * *", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_checkin_cookie", + "label": "影巢网页 Cookie(非 Premium 兜底)", + "rows": 3, + "placeholder": "不建议手工填写;优先在 Edge 登录 hdhive.com 后运行“影巢Cookie导出.command”自动写回", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_auto_login", + "label": "自动刷新 Cookie", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_username", + "label": "影巢用户名/邮箱", + "placeholder": "用于 Cookie 失效时自动登录", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_password", + "label": "影巢密码", + "type": "password", + "placeholder": "仅保存在 MoviePilot 本机配置中", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "盘搜用于聚合公开网盘分享结果。请填写 MoviePilot 容器视角下可访问的 API 地址,不要按外部智能体机器的视角填写。普通“搜索/找片”默认先盘搜;“云盘搜索”固定比较盘搜 + 影巢。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_base_url", + "label": "盘搜 API 地址", + "placeholder": "http://host.docker.internal:805", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_timeout", + "label": "盘搜超时(秒)", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "夸克用于转存 pan.quark.cn 分享链接。优先使用 CookieCloud 或有效网页登录 Cookie;只有明确出现“require login [guest]”“夸克登录态已过期”“当前夸克登录态不足”时,才建议走 Cookie 修复。分享受限、41031、分享者封禁不属于 Cookie 失效。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "quark_cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "可手工填写,但更推荐 CookieCloud 或本机夸克Cookie导出工具自动写回", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_default_path", + "label": "夸克默认目录", + "placeholder": "/飞书", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_timeout", + "label": "夸克超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "quark_auto_import_cookiecloud", + "label": "允许自动刷新 Cookie", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "115 建议走扫码会话,不建议填网页版 Cookie。插件支持 /p115/qrcode 和 /p115/qrcode/check 两步扫码登录;手填 Cookie 仅作为高级兜底。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "p115_default_path", + "label": "115 默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "p115_client_type", + "label": "115 扫码客户端类型", + "items": self._p115_client_type_items(), + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "p115_prefer_direct", + "label": "115 优先直转", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "p115_cookie", + "label": "115 扫码会话 Cookie(高级,可选)", + "rows": 3, + "placeholder": "仅支持 UID/CID/SEID/KID 这类扫码客户端 Cookie;普通网页版 Cookie 不建议粘贴到这里", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "飞书入口默认关闭。开启后可以在飞书里发送搜索、云盘搜索、转存、夸克转存、下载、更新检查、115 登录和影巢签到等命令;同一个飞书机器人建议只配置一个接收入口。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_enabled", + "label": "启用内置飞书入口", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_allow_all", + "label": "允许所有飞书会话", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_reply_enabled", + "label": "发送飞书回复", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_id", + "label": "飞书 App ID", + "placeholder": "cli_xxxxxxxxx", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_secret", + "label": "飞书 App Secret", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_verification_token", + "label": "Verification Token", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "feishu_reply_receive_id_type", + "label": "回复 ID 类型", + "items": [ + {"title": "群聊 chat_id", "value": "chat_id"}, + {"title": "用户 open_id", "value": "open_id"}, + {"title": "用户 union_id", "value": "union_id"}, + {"title": "用户 user_id", "value": "user_id"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_chat_ids", + "label": "允许的群聊 Chat ID", + "rows": 3, + "placeholder": "一个一行;allow_all 关闭时生效", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_user_ids", + "label": "允许的用户 Open ID", + "rows": 3, + "placeholder": "一个一行", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_whitelist", + "label": "飞书命令白名单", + "rows": 3, + "placeholder": "逗号或换行分隔;留空时会自动合并当前主线命令。旧 STRM/刮削命令不再默认暴露,如需兼容旧环境可手动加入。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_aliases", + "label": "飞书命令别名", + "rows": 5, + "placeholder": FeishuChannel.default_command_aliases(), + "hint": "默认别名已统一走 Agent影视助手 route/pick:转存默认 115,夸克转存需显式发送;旧 STRM/刮削别名如需保留请手动添加。", + }, + } + ], + }, + ], + }, + ], + } + ] + return form, self._build_config() + + async def api_feishu_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + channel = self._ensure_feishu_channel() + return { + "success": True, + "message": "Agent影视助手 内置飞书入口状态", + "data": { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + }, + } + + async def api_quark_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_quark_service() + cookie_ok, cookie_message = service.check_cookie() + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "quark_cookie_configured": bool(self._quark_cookie), + "quark_cookie_valid": cookie_ok, + "default_target_path": self._quark_default_path, + "message": "" if cookie_ok else cookie_message, + }, + } + + async def api_quark_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_text = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_quark_service() + transfer_ok, result, transfer_message = service.transfer_share( + share_text, + access_code=access_code, + target_path=target_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_hdhive_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_hdhive_service() + ping_ok, result, ping_message, _status_code = service.request("GET", "/api/open/ping") + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "hdhive_api_key_configured": bool(self._hdhive_api_key), + "hdhive_ping_ok": ping_ok, + "base_url": self._hdhive_base_url, + "default_target_path": self._hdhive_default_path, + "resource_enabled": self._hdhive_resource_enabled, + "max_unlock_points": self._hdhive_max_unlock_points, + "checkin_enabled": self._hdhive_checkin_enabled, + "checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "checkin_cron": self._hdhive_checkin_cron, + "checkin_web_cookie_configured": bool(self._hdhive_checkin_cookie), + "checkin_auto_login_enabled": self._hdhive_checkin_auto_login, + "checkin_username_configured": bool(self._hdhive_checkin_username), + "message": "" if ping_ok else ping_message, + "raw": result, + }, + } + + async def api_hdhive_account(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + account_ok, result, account_message = service.fetch_me() + if not account_ok: + if self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + return { + "success": True, + "message": "当前返回的是网页用户快照", + "data": fallback_account, + } + return {"success": False, "message": self._friendly_hdhive_error(account_message, "账号"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_checkin(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + is_gambler = self._parse_bool_value(body.get("is_gambler"), self._hdhive_checkin_gambler_mode) + return self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 API") + + async def api_hdhive_checkin_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._hdhive_checkin_history_public_data(limit=limit) + return { + "success": True, + "message": self._format_hdhive_checkin_history_text(limit=limit), + "data": data, + } + + async def api_hdhive_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + quota_ok, result, quota_message = service.fetch_quota() + if not quota_ok: + return {"success": False, "message": self._friendly_hdhive_error(quota_message, "配额"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_usage_today(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + usage_ok, result, usage_message = service.fetch_usage_today() + if not usage_ok: + return {"success": False, "message": self._friendly_hdhive_error(usage_message, "今日用量"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_weekly_free_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + weekly_ok, result, weekly_message = service.fetch_weekly_free_quota() + if not weekly_ok: + return {"success": False, "message": self._friendly_hdhive_error(weekly_message, "每周免费额度"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + media_type = self._clean_text(body.get("media_type") or body.get("type") or "movie").lower() + tmdb_id = self._clean_text(body.get("tmdb_id")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = service.search_resources(media_type=media_type, tmdb_id=tmdb_id) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_search_by_keyword(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + candidate_limit = self._safe_int(body.get("candidate_limit"), self._hdhive_candidate_page_size) + result_limit = self._safe_int(body.get("limit"), 12) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + result_limit=result_limit, + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_unlock(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(body) + if not points_ok: + return {"success": False, "message": points_message, "data": {"resource_guard": points_data}} + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return {"success": False, "message": unlock_message, "data": result} + return {"success": True, "message": "success", "data": result} + + @staticmethod + def _new_session_id(prefix: str = "aro") -> str: + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + def _save_session(self, session_id: str, payload: Dict[str, Any]) -> None: + payload = dict(payload) + payload["updated_at"] = int(time.time()) + with self._session_lock: + self._session_cache[session_id] = payload + self._persist_relevant_sessions() + + def _load_session(self, session_id: str) -> Optional[Dict[str, Any]]: + with self._session_lock: + session = self._session_cache.get(session_id) + if not session: + return None + return dict(session) + + def _persist_relevant_sessions(self) -> None: + try: + data: Dict[str, Dict[str, Any]] = {} + with self._session_lock: + for session_id, payload in (self._session_cache or {}).items(): + session = dict(payload or {}) + if self._is_session_expired(session): + continue + if str(session_id).startswith("assistant::") or session.get("pending_p115") or str(session.get("kind") or "").strip() == "assistant_p115_login": + data[session_id] = session + self.save_data(key=self._session_store_key, value=data) + except Exception: + pass + + def _restore_persisted_sessions(self) -> None: + try: + restored = self.get_data(self._session_store_key) or {} + if isinstance(restored, dict): + with self._session_lock: + for session_id, payload in restored.items(): + if isinstance(payload, dict) and not self._is_session_expired(payload): + self._session_cache[str(session_id)] = dict(payload) + except Exception: + pass + + def _persist_execution_history(self) -> None: + try: + history = list(self._execution_history or [])[-self._execution_history_limit:] + self._execution_history = history + self.save_data(key=self._execution_history_store_key, value=history) + except Exception: + pass + + def _restore_execution_history(self) -> None: + try: + restored = self.get_data(self._execution_history_store_key) or [] + if isinstance(restored, list): + self._execution_history = [ + dict(item) + for item in restored[-self._execution_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._execution_history = [] + + def _persist_hdhive_checkin_history(self) -> None: + try: + history = list(self._hdhive_checkin_history or [])[-self._hdhive_checkin_history_limit:] + self._hdhive_checkin_history = history + self.save_data(key=self._hdhive_checkin_history_store_key, value=history) + except Exception: + pass + + def _restore_hdhive_checkin_history(self) -> None: + try: + restored = self.get_data(self._hdhive_checkin_history_store_key) or [] + if isinstance(restored, list): + self._hdhive_checkin_history = [ + dict(item) + for item in restored[-self._hdhive_checkin_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._hdhive_checkin_history = [] + + def _record_hdhive_checkin_history( + self, + *, + trigger: str, + is_gambler: bool, + result: Dict[str, Any], + ) -> None: + timestamp = int(time.time()) + payload = dict(result or {}) + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + entry = { + "id": self._new_session_id("hdhive-sign"), + "time": timestamp, + "time_text": self._format_unix_time(timestamp), + "trigger": self._clean_text(trigger), + "mode": "赌狗签到" if is_gambler else "普通签到", + "is_gambler": bool(is_gambler), + "success": bool(payload.get("success")), + "message_head": self._assistant_result_message_head(payload.get("message")), + "status": self._clean_text(data.get("status")) or ("成功" if payload.get("success") else "失败"), + "source": self._clean_text(data.get("source")), + "status_code": data.get("status_code"), + "login_retry": bool(((data.get("login") or {}) if isinstance(data.get("login"), dict) else {}).get("ok")), + "web_fallback": bool(data.get("web_fallback")) if isinstance(data, dict) else False, + } + self._hdhive_checkin_history.append(entry) + self._hdhive_checkin_history = self._hdhive_checkin_history[-self._hdhive_checkin_history_limit:] + self._persist_hdhive_checkin_history() + + def _hdhive_checkin_history_public_data(self, *, limit: int = 20) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), self._hdhive_checkin_history_limit) + items = list(reversed(self._hdhive_checkin_history or []))[:max_limit] + return { + "total": len(self._hdhive_checkin_history or []), + "limit": max_limit, + "items": items, + } + + def _format_hdhive_checkin_history_text(self, *, limit: int = 10) -> str: + data = self._hdhive_checkin_history_public_data(limit=limit) + items = data.get("items") or [] + if not items: + return "暂无影巢签到日志。" + lines = [f"影巢签到日志:最近 {len(items)} 条"] + for idx, item in enumerate(items, start=1): + ok_text = "成功" if item.get("success") else "失败" + parts = [ + f"{idx}. {item.get('time_text') or ''}", + f"{item.get('mode') or ''}", + ok_text, + ] + if item.get("trigger"): + parts.append(f"来源:{item.get('trigger')}") + if item.get("login_retry"): + parts.append("已自动刷新Cookie") + message = self._clean_text(item.get("message_head")) + if message: + parts.append(message) + lines.append(" | ".join(part for part in parts if part)) + return "\n".join(lines) + + def _hdhive_resource_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢资源入口已关闭:当前不会执行影巢搜索、解锁或转存。可在插件设置中开启“影巢资源搜索/解锁”。", + "data": { + "provider": "hdhive", + "resource_enabled": False, + "error_code": "hdhive_resource_disabled", + }, + } + + def _ensure_hdhive_resource_enabled(self) -> Tuple[bool, Dict[str, Any]]: + if self._hdhive_resource_enabled: + return True, {} + return False, self._hdhive_resource_disabled_response() + + def _hdhive_checkin_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢签到入口已关闭:如需执行签到,请先在插件设置中开启“影巢签到”。", + "data": { + "provider": "hdhive", + "checkin_enabled": False, + "error_code": "hdhive_checkin_disabled", + }, + } + + @staticmethod + def _resource_has_free_marker(item: Optional[Dict[str, Any]]) -> bool: + if not isinstance(item, dict) or not item: + return False + for key in ["unlock_points", "cost", "points", "price", "point_text"]: + text = str(item.get(key) or "").strip().lower() + if text in {"0", "free", "免费", "0分"}: + return True + blob = " ".join( + str(item.get(key) or "") + for key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "note", + "source", + "source_name", + ] + ).lower() + return "免费" in blob or " free " in f" {blob} " + + @staticmethod + def _resource_points_value(item: Optional[Dict[str, Any]]) -> Optional[int]: + if not item: + return None + raw = item.get("unlock_points") + if raw is None: + raw = item.get("cost") + if raw is None: + raw = item.get("points") + text = str(raw or "").strip() + if not text: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + if text.lower() == "free" or text == "免费": + return 0 + match = re.search(r"-?\d+", text) + if not match: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + try: + return int(match.group(0)) + except Exception: + return None + + def _check_hdhive_unlock_points_limit(self, resource: Optional[Dict[str, Any]]) -> Tuple[bool, str, Dict[str, Any]]: + limit = max(0, self._safe_int(self._hdhive_max_unlock_points, 20)) + if limit <= 0: + return True, "", {"limit": limit, "points": None, "limited": False} + points = self._resource_points_value(resource) + title = self._clean_text((resource or {}).get("title") or (resource or {}).get("matched_title") or "该资源") + if points is None: + return True, "", {"limit": limit, "points": None, "limited": False, "reason": "unknown_points", "title": title} + if points > limit: + return False, ( + f"已阻止影巢解锁:{title} 需要 {points} 分,超过当前单资源积分上限 {limit} 分。" + "如确认要解锁,请提高上限或临时设为 0。" + ), {"limit": limit, "points": points, "limited": True, "reason": "points_over_limit"} + return True, "", {"limit": limit, "points": points, "limited": False} + + def _default_assistant_preferences(self) -> Dict[str, Any]: + return { + "schema_version": "preferences.v1", + "initialized": False, + "prefer_resolution": "4K", + "prefer_dolby_vision": True, + "prefer_hdr": True, + "prefer_chinese_subtitle": True, + "prefer_complete_series": True, + "prefer_cloud_provider": "", + "enable_pansou": True, + "enable_hdhive": True, + "enable_mp_pt": True, + "has_quark": True, + "has_115": True, + "cloud_default_path": self._hdhive_default_path, + "quark_default_path": self._quark_default_path, + "p115_default_path": self._p115_default_path, + "pt_require_free": False, + "pt_min_seeders": self._assistant_default_pt_min_seeders, + "pt_prefer_free": True, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "updated_at": 0, + } + + def _normalize_preference_key(self, session: Any = None, user_key: Any = None) -> str: + key = self._clean_text(user_key) + if key: + return key + session_name = self._clean_text(session) or "default" + return f"session:{session_name}" + + def _normalize_assistant_preferences(self, value: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + defaults = self._default_assistant_preferences() + payload = dict(value or {}) + if "resolution_priority" in payload and "prefer_resolution" not in payload: + choices = payload.get("resolution_priority") + if isinstance(choices, (list, tuple)) and choices: + payload["prefer_resolution"] = choices[0] + else: + payload["prefer_resolution"] = choices + if "subtitle_priority" in payload and "prefer_chinese_subtitle" not in payload: + subtitle_text = " ".join(str(item) for item in payload.get("subtitle_priority") or []) if isinstance(payload.get("subtitle_priority"), (list, tuple)) else str(payload.get("subtitle_priority") or "") + payload["prefer_chinese_subtitle"] = bool(re.search(r"中文|中字|简中|繁中|双语|chinese", subtitle_text, flags=re.IGNORECASE)) + if "pt_free_only" in payload and "pt_require_free" not in payload: + payload["pt_require_free"] = payload.get("pt_free_only") + if "preferred_cloud_drive" in payload and "prefer_cloud_provider" not in payload: + providers = payload.get("preferred_cloud_drive") + payload["prefer_cloud_provider"] = providers[0] if isinstance(providers, (list, tuple)) and providers else providers + if "available_sources" in payload: + sources = payload.get("available_sources") + if isinstance(sources, (list, tuple, set)): + source_list = [self._clean_text(item).lower() for item in sources] + if "enable_pansou" not in payload: + payload["enable_pansou"] = "pansou" in source_list or "盘搜" in source_list + if "enable_hdhive" not in payload: + payload["enable_hdhive"] = any(item in source_list for item in ["hdhive", "影巢", "影潮"]) + if "enable_mp_pt" not in payload: + payload["enable_mp_pt"] = any(item in source_list for item in ["mp_pt", "mp", "pt", "原生", "moviepilot"]) + if "available_providers" in payload: + providers = payload.get("available_providers") + if isinstance(providers, (list, tuple, set)): + provider_list = [self._clean_text(item).lower() for item in providers] + if "has_115" not in payload: + payload["has_115"] = "115" in provider_list + if "has_quark" not in payload: + payload["has_quark"] = "quark" in provider_list or "夸克" in provider_list + if "auto_ingest" in payload and "auto_ingest_enabled" not in payload: + payload["auto_ingest_enabled"] = payload.get("auto_ingest") + if "auto_execute_score_threshold" in payload and "auto_ingest_score_threshold" not in payload: + payload["auto_ingest_score_threshold"] = payload.get("auto_execute_score_threshold") + normalized = {**defaults, **payload} + normalized["schema_version"] = "preferences.v1" + normalized["initialized"] = bool(normalized.get("initialized")) + normalized["prefer_resolution"] = self._clean_text(normalized.get("prefer_resolution") or defaults["prefer_resolution"]).upper() + normalized["prefer_dolby_vision"] = self._parse_bool_value(normalized.get("prefer_dolby_vision"), True) + normalized["prefer_hdr"] = self._parse_bool_value(normalized.get("prefer_hdr"), True) + normalized["prefer_chinese_subtitle"] = self._parse_bool_value(normalized.get("prefer_chinese_subtitle"), True) + normalized["prefer_complete_series"] = self._parse_bool_value(normalized.get("prefer_complete_series"), True) + normalized["prefer_cloud_provider"] = self._clean_text(normalized.get("prefer_cloud_provider")).lower() + normalized["enable_pansou"] = self._parse_bool_value(normalized.get("enable_pansou"), True) + normalized["enable_hdhive"] = self._parse_bool_value(normalized.get("enable_hdhive"), True) + normalized["enable_mp_pt"] = self._parse_bool_value(normalized.get("enable_mp_pt"), True) + normalized["has_quark"] = self._parse_bool_value(normalized.get("has_quark"), True) + normalized["has_115"] = self._parse_bool_value(normalized.get("has_115"), True) + normalized["cloud_default_path"] = self._normalize_path(normalized.get("cloud_default_path") or self._hdhive_default_path) + normalized["quark_default_path"] = self._normalize_path(normalized.get("quark_default_path") or self._quark_default_path) + normalized["p115_default_path"] = self._normalize_path(normalized.get("p115_default_path") or self._p115_default_path) + normalized["pt_require_free"] = self._parse_bool_value(normalized.get("pt_require_free"), False) + normalized["pt_min_seeders"] = max(0, self._safe_int(normalized.get("pt_min_seeders"), 3)) + normalized["pt_prefer_free"] = self._parse_bool_value(normalized.get("pt_prefer_free"), True) + normalized["hdhive_max_unlock_points"] = max(0, self._safe_int(normalized.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + normalized["auto_ingest_enabled"] = self._parse_bool_value(normalized.get("auto_ingest_enabled"), False) + normalized["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("auto_ingest_score_threshold"), 90))) + normalized["confirm_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("confirm_score_threshold"), 70))) + normalized["updated_at"] = self._safe_int(normalized.get("updated_at"), 0) + return normalized + + def _restore_assistant_preferences(self) -> None: + try: + restored = self.get_data(self._assistant_preferences_store_key) or {} + if isinstance(restored, dict): + self._assistant_preferences = { + self._clean_text(key): self._normalize_assistant_preferences(value) + for key, value in restored.items() + if self._clean_text(key) and isinstance(value, dict) + } + except Exception: + self._assistant_preferences = {} + + def _persist_assistant_preferences(self) -> None: + try: + items = list((self._assistant_preferences or {}).items())[-self._assistant_preferences_limit:] + self._assistant_preferences = {key: value for key, value in items if key} + self.save_data(key=self._assistant_preferences_store_key, value=self._assistant_preferences) + except Exception: + pass + + def _assistant_preferences_public_data(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + questions = [ + "你更偏好 4K 还是 1080P?", + "是否优先杜比视界 / HDR?", + "是否必须中文字幕?", + "电视剧是否优先全集或完整季?", + "你可用哪些源?例如:只用盘搜、只用影巢、只用 MP/PT,或者关闭其中某些源。", + "你可用哪些云盘?例如:只有夸克、只有 115、两者都可。", + "PT 资源最低做种数是多少?默认 3。", + "影巢单资源最多愿意消耗多少积分?默认 20。", + "是否允许 90 分以上资源自动入库?默认关闭。", + ] + return { + "schema_version": "preferences.v1", + "key": key, + "initialized": bool(preferences.get("initialized")), + "preferences": preferences, + "needs_onboarding": not bool(preferences.get("initialized")), + "onboarding_questions": questions if not bool(preferences.get("initialized")) else [], + "defaults": self._default_assistant_preferences(), + } + + def _save_assistant_preferences( + self, + *, + session: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + current = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + incoming = dict(preferences or {}) + merged = {**current, **incoming} + merged["initialized"] = self._parse_bool_value(incoming.get("initialized"), True) if "initialized" in incoming else True + merged["updated_at"] = int(time.time()) + normalized = self._normalize_assistant_preferences(merged) + self._assistant_preferences[key] = normalized + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _reset_assistant_preferences(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + self._assistant_preferences.pop(key, None) + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _assistant_preferences_status_brief(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + data = self._assistant_preferences_public_data(session=session, user_key=user_key) + prefs = dict(data.get("preferences") or {}) + brief = { + "key": data.get("key"), + "initialized": bool(data.get("initialized")), + "needs_onboarding": bool(data.get("needs_onboarding")), + "summary": { + "prefer_resolution": self._clean_text(prefs.get("prefer_resolution")), + "prefer_dolby_vision": bool(prefs.get("prefer_dolby_vision")), + "prefer_hdr": bool(prefs.get("prefer_hdr")), + "prefer_chinese_subtitle": bool(prefs.get("prefer_chinese_subtitle")), + "prefer_complete_series": bool(prefs.get("prefer_complete_series")), + "prefer_cloud_provider": self._clean_text(prefs.get("prefer_cloud_provider")), + "enable_pansou": bool(prefs.get("enable_pansou")), + "enable_hdhive": bool(prefs.get("enable_hdhive")), + "enable_mp_pt": bool(prefs.get("enable_mp_pt")), + "has_quark": bool(prefs.get("has_quark")), + "has_115": bool(prefs.get("has_115")), + "pt_min_seeders": self._safe_int(prefs.get("pt_min_seeders"), 3), + "pt_require_free": bool(prefs.get("pt_require_free")), + "hdhive_max_unlock_points": self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points), + "auto_ingest_enabled": bool(prefs.get("auto_ingest_enabled")), + "auto_ingest_score_threshold": self._safe_int(prefs.get("auto_ingest_score_threshold"), 90), + }, + } + if brief["needs_onboarding"]: + brief["onboarding_questions"] = data.get("onboarding_questions") or [] + brief["recommended_action"] = "ask_user_preferences_then_save" + return brief + + def _assistant_default_preferences_template(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "prefer_resolution": prefs.get("prefer_resolution"), + "prefer_dolby_vision": prefs.get("prefer_dolby_vision"), + "prefer_hdr": prefs.get("prefer_hdr"), + "prefer_chinese_subtitle": prefs.get("prefer_chinese_subtitle"), + "prefer_complete_series": prefs.get("prefer_complete_series"), + "prefer_cloud_provider": prefs.get("prefer_cloud_provider"), + "enable_pansou": prefs.get("enable_pansou"), + "enable_hdhive": prefs.get("enable_hdhive"), + "enable_mp_pt": prefs.get("enable_mp_pt"), + "has_quark": prefs.get("has_quark"), + "has_115": prefs.get("has_115"), + "pt_require_free": prefs.get("pt_require_free"), + "pt_min_seeders": prefs.get("pt_min_seeders"), + "hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "p115_default_path": prefs.get("p115_default_path"), + "quark_default_path": prefs.get("quark_default_path"), + "auto_ingest_enabled": prefs.get("auto_ingest_enabled"), + "auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + } + + def _assistant_preferences_for_session(self, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + return self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + + def _assistant_source_enabled(self, preferences: Optional[Dict[str, Any]], source_type: str) -> bool: + prefs = self._normalize_assistant_preferences(preferences) + source = self._clean_text(source_type).lower() + if source == "pansou": + return bool(prefs.get("enable_pansou")) + if source == "hdhive": + return bool(prefs.get("enable_hdhive")) + if source in {"mp", "mp_pt", "pt"}: + return bool(prefs.get("enable_mp_pt")) + return True + + def _assistant_available_cloud_providers(self, preferences: Optional[Dict[str, Any]]) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + providers: List[str] = [] + if self._parse_bool_value(prefs.get("has_115"), True): + providers.append("115") + if self._parse_bool_value(prefs.get("has_quark"), True): + providers.append("quark") + return providers + + def _assistant_filter_cloud_items_by_preferences( + self, + items: Optional[List[Dict[str, Any]]], + preferences: Optional[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + available = set(self._assistant_available_cloud_providers(preferences)) + source_items = [dict(item or {}) for item in (items or []) if isinstance(item, dict)] + if not available: + return [] + if available == {"115", "quark"}: + return source_items + + filtered: List[Dict[str, Any]] = [] + for item in source_items: + provider = self._clean_text( + item.get("pan_type") + or item.get("provider") + or item.get("channel") + or item.get("network") + or item.get("netdisk") + or item.get("cloud") + ).lower() + if provider in {"quark", "夸克", "quarkcloud"}: + provider = "quark" + elif provider in {"115", "115cloud", "115网盘"}: + provider = "115" + if provider in available: + filtered.append(item) + return filtered + + def _assistant_smart_search_source_order( + self, + preferences: Optional[Dict[str, Any]], + source_order: Optional[List[str]] = None, + ) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + requested = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + order = requested or ["pansou", "hdhive", "mp_pt"] + normalized: List[str] = [] + for item in order: + if item in {"mp", "pt"}: + item = "mp_pt" + if item not in {"pansou", "hdhive", "mp_pt"} or item in normalized: + continue + if self._assistant_source_enabled(prefs, item): + normalized.append(item) + return normalized + + def _assistant_smart_source_availability( + self, + preferences: Optional[Dict[str, Any]], + *, + source_order: Optional[List[str]] = None, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + prefs = self._normalize_assistant_preferences(preferences) + available_cloud = self._assistant_available_cloud_providers(prefs) + order = self._assistant_smart_search_source_order(prefs, source_order=source_order) + source_labels = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + available: List[Dict[str, Any]] = [] + blocked: List[Dict[str, Any]] = [] + for source in ["pansou", "hdhive", "mp_pt"]: + entry = { + "source_type": source, + "label": source_labels[source], + } + if source not in order: + if not self._assistant_source_enabled(prefs, source): + blocked.append({**entry, "reason": f"当前偏好已关闭{source_labels[source]}"}) + else: + blocked.append({**entry, "reason": "当前决策顺序未包含该源"}) + continue + if source in {"pansou", "hdhive"} and not available_cloud: + blocked.append({**entry, "reason": "当前偏好未启用任何云盘"}) + continue + available.append({ + **entry, + "provider_scope": list(available_cloud) if source in {"pansou", "hdhive"} else ["pt"], + }) + if "115" not in available_cloud: + blocked.append({"source_type": "115", "label": "115", "reason": "当前偏好未启用 115"}) + if "quark" not in available_cloud: + blocked.append({"source_type": "quark", "label": "夸克", "reason": "当前偏好未启用夸克"}) + return available, blocked + + def _assistant_smart_decision_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + profile = self._clean_text(decision_profile).lower() + if not profile: + return prefs + updated = dict(prefs) + confirm_threshold = self._safe_int( + updated.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + if profile == "conservative": + updated["confirm_score_threshold"] = max(confirm_threshold, min(95, confirm_threshold + 10)) + elif profile == "aggressive": + updated["confirm_score_threshold"] = max(40, min(100, confirm_threshold - 10)) + return self._normalize_assistant_preferences(updated) + + def _assistant_smart_session_overrides_summary(self, overrides: Optional[Dict[str, Any]]) -> str: + raw = dict(overrides or {}) + if not raw: + return "" + payload = self._normalize_assistant_preferences(raw) + parts: List[str] = [] + if "has_quark" in raw or "has_115" in raw: + if payload.get("has_quark") and not payload.get("has_115"): + parts.append("只用夸克") + elif payload.get("has_115") and not payload.get("has_quark"): + parts.append("只用115") + elif payload.get("has_115") and payload.get("has_quark"): + parts.append("115 / 夸克都可") + if "enable_pansou" in raw or "enable_hdhive" in raw or "enable_mp_pt" in raw: + enabled_sources: List[str] = [] + if payload.get("enable_pansou"): + enabled_sources.append("盘搜") + if payload.get("enable_hdhive"): + enabled_sources.append("影巢") + if payload.get("enable_mp_pt"): + enabled_sources.append("MP/PT") + if enabled_sources: + parts.append("仅会话源:" + " / ".join(enabled_sources)) + return ";".join(parts[:2]) + + def _assistant_smart_merge_session_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + session_overrides: Optional[Dict[str, Any]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + merged = { + **self._normalize_assistant_preferences(preferences), + **dict(session_overrides or {}), + } + return self._assistant_smart_decision_preferences( + merged, + decision_profile=decision_profile, + ) + + def _assistant_smart_apply_session_override( + self, + session_overrides: Optional[Dict[str, Any]], + *, + adjust_action: str, + source_order: Optional[List[str]] = None, + ) -> Tuple[Dict[str, Any], List[str]]: + overrides = dict(session_overrides or {}) + order = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + normalized_action = self._clean_text(adjust_action).lower() + if normalized_action == "decision_only_quark": + overrides["has_quark"] = True + overrides["has_115"] = False + overrides["prefer_cloud_provider"] = "quark" + elif normalized_action == "decision_only_115": + overrides["has_quark"] = False + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "115" + elif normalized_action == "decision_cloud_both": + overrides["has_quark"] = True + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "" + elif normalized_action == "decision_only_mp_pt": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = True + order = ["mp_pt"] + elif normalized_action == "decision_only_pansou": + overrides["enable_pansou"] = True + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = False + order = ["pansou"] + elif normalized_action == "decision_only_hdhive": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = True + overrides["enable_mp_pt"] = False + order = ["hdhive"] + elif normalized_action == "decision_disable_pansou": + overrides["enable_pansou"] = False + elif normalized_action == "decision_disable_hdhive": + overrides["enable_hdhive"] = False + elif normalized_action == "decision_disable_mp_pt": + overrides["enable_mp_pt"] = False + elif normalized_action == "decision_reset_preferences": + overrides = {} + order = [] + return overrides, order + + def _parse_assistant_preferences_text(self, text: str) -> Dict[str, Any]: + raw = self._clean_text(text) + compact = re.sub(r"\s+", "", raw).lower() + payload: Dict[str, Any] = {} + if re.search(r"1080|fhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "1080P" + elif re.search(r"4k|2160|uhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "4K" + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}(杜比|dv)", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = False + elif re.search(r"杜比|dv|dolby", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = False + elif re.search(r"hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,6}(中字|中文|字幕)", raw, flags=re.IGNORECASE) or re.search(r"无字幕也可|字幕无所谓", raw): + payload["prefer_chinese_subtitle"] = False + elif re.search(r"中字|中文|简中|繁中|双语字幕|字幕", raw, flags=re.IGNORECASE): + payload["prefer_chinese_subtitle"] = True + + if re.search(r"不强求全集|不要全集|单集也可|更新也可", raw): + payload["prefer_complete_series"] = False + elif re.search(r"全集|完整季|整季|完结", raw): + payload["prefer_complete_series"] = True + + if re.search(r"夸克优先|优先夸克|quark", raw, flags=re.IGNORECASE): + payload["prefer_cloud_provider"] = "quark" + elif re.search(r"115优先|优先115", raw): + payload["prefer_cloud_provider"] = "115" + + if re.search(r"只用盘搜|仅用盘搜|只搜盘搜", raw): + payload["enable_pansou"] = True + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = False + elif re.search(r"只用影巢|仅用影巢|只搜影巢|只用影潮", raw): + payload["enable_pansou"] = False + payload["enable_hdhive"] = True + payload["enable_mp_pt"] = False + elif re.search(r"只用原生|只用pt|仅用pt|只搜pt|只搜原生|只要原生", raw, flags=re.IGNORECASE): + payload["enable_pansou"] = False + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}盘搜", raw): + payload["enable_pansou"] = False + elif re.search(r"(启用|使用|打开).{0,4}盘搜", raw): + payload["enable_pansou"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = False + elif re.search(r"(启用|使用|打开).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = False + elif re.search(r"(启用|使用|打开).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = True + + if re.search(r"只有夸克|仅有夸克|只用夸克|没有115|不用115", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + payload["has_115"] = False + elif re.search(r"只有115|仅有115|只用115|没有夸克|不用夸克", raw, flags=re.IGNORECASE): + payload["has_115"] = True + payload["has_quark"] = False + if re.search(r"(不要|不用|关闭|禁用).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = False + elif re.search(r"(启用|使用|打开).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}115", raw): + payload["has_115"] = False + elif re.search(r"(启用|使用|打开).{0,4}115", raw): + payload["has_115"] = True + + if re.search(r"pt.{0,4}(只要|必须).{0,4}免费|只下免费|只要免费", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = True + elif re.search(r"pt.{0,4}(不限|不强求).{0,4}免费|不只要免费|免费不强求", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = False + + seed_match = re.search(r"(?:做种|种子|seeders?|seeder)[^\d]{0,8}(\d+)", raw, flags=re.IGNORECASE) + if seed_match: + payload["pt_min_seeders"] = self._safe_int(seed_match.group(1), 3) + + points_match = re.search(r"(?:影巢|积分|解锁)[^\d]{0,10}(\d+)", raw) + if points_match: + payload["hdhive_max_unlock_points"] = self._safe_int(points_match.group(1), self._hdhive_max_unlock_points) + + if re.search(r"(不|不要|关闭|禁用).{0,4}自动入库", raw): + payload["auto_ingest_enabled"] = False + elif re.search(r"自动入库|自动下载|自动转存", raw): + payload["auto_ingest_enabled"] = True + + threshold_match = re.search(r"(?:自动入库(?:评分|分数|阈值)|评分阈值|分数阈值|阈值)[^\d]{0,10}(\d{2,3})", raw) + if threshold_match: + payload["auto_ingest_score_threshold"] = self._safe_int(threshold_match.group(1), 90) + + for key, target in [ + ("115目录", "p115_default_path"), + ("p115目录", "p115_default_path"), + ("夸克目录", "quark_default_path"), + ("quark目录", "quark_default_path"), + ("云盘目录", "cloud_default_path"), + ]: + match = re.search(rf"{re.escape(key)}\s*=\s*([^\s,,]+)", raw, flags=re.IGNORECASE) + if match: + payload[target] = self._normalize_path(match.group(1)) + + if "initialized" not in payload and payload: + payload["initialized"] = True + if "auto_ingest_score_threshold" in payload: + payload["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(payload["auto_ingest_score_threshold"], 90))) + if "pt_min_seeders" in payload: + payload["pt_min_seeders"] = max(0, self._safe_int(payload["pt_min_seeders"], 3)) + if "hdhive_max_unlock_points" in payload: + payload["hdhive_max_unlock_points"] = max(0, self._safe_int(payload["hdhive_max_unlock_points"], self._hdhive_max_unlock_points)) + return payload + + @staticmethod + def _score_text_blob(item: Any) -> str: + if isinstance(item, dict): + parts: List[str] = [] + for value in item.values(): + if isinstance(value, (dict, list, tuple)): + parts.append(AgentResourceOfficer._score_text_blob(value)) + elif value is not None: + parts.append(str(value)) + return " ".join(parts).lower() + if isinstance(item, (list, tuple)): + return " ".join(AgentResourceOfficer._score_text_blob(value) for value in item).lower() + return str(item or "").lower() + + @staticmethod + def _score_has_any(text: str, keywords: List[str]) -> bool: + return any(keyword.lower() in text for keyword in keywords) + + @classmethod + def _score_quality_rank(cls, item: Any) -> int: + text = cls._score_text_blob(item) + rank = 0 + if cls._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + rank += 60 + if cls._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比", "臻彩"]): + rank += 45 + if cls._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + rank += 35 + if cls._score_has_any(text, ["4k", "2160", "uhd"]): + rank += 25 + if cls._score_has_any(text, ["高码率", "remux", "原盘", "web-dl", "webrip"]): + rank += 15 + if cls._score_has_any(text, ["e01", "s01", "全集", "全季", "更新至", "更至"]): + rank += 10 + if cls._score_has_any(text, ["中字", "简中", "繁中", "内封", "内嵌", "字幕"]): + rank += 8 + return rank + + @staticmethod + def _extract_series_progress(text: str) -> Dict[str, int]: + blob = str(text or "").lower() + result = {"max_episode": 0, "episode_count": 0} + episodes: List[int] = [] + for pattern in ( + r"\be(\d{1,3})\b", + r"第\s*(\d{1,3})\s*集", + r"更新(?:至)?\s*(?:ep|e)?\s*0*(\d{1,3})", + r"更(?:至)?\s*0*(\d{1,3})\s*集", + ): + for match in re.finditer(pattern, blob, flags=re.IGNORECASE): + try: + episodes.append(int(match.group(1))) + except Exception: + continue + for pattern in ( + r"e0*(\d{1,3})\s*[-~—]\s*e?0*(\d{1,3})", + r"第?\s*0*(\d{1,3})\s*[-~—]\s*0*(\d{1,3})\s*集", + ): + match = re.search(pattern, blob, flags=re.IGNORECASE) + if not match: + continue + try: + start_ep = int(match.group(1)) + end_ep = int(match.group(2)) + except Exception: + continue + if end_ep >= start_ep > 0: + result["episode_count"] = max(result["episode_count"], end_ep - start_ep + 1) + episodes.extend([start_ep, end_ep]) + if episodes: + result["max_episode"] = max(episodes) + if result["episode_count"] <= 0 and len(set(episodes)) >= 2: + sorted_eps = sorted(set(episodes)) + if sorted_eps == list(range(sorted_eps[0], sorted_eps[-1] + 1)): + result["episode_count"] = len(sorted_eps) + return result + + @staticmethod + def _score_level(score: int) -> str: + if score >= 90: + return "excellent" + if score >= 70: + return "confirm" + return "low" + + def _score_decision( + self, + *, + score: int, + risk_reasons: List[str], + hard_risk_reasons: Optional[List[str]] = None, + preferences: Dict[str, Any], + default_action: str, + ) -> Dict[str, Any]: + threshold = max(1, min(100, self._safe_int(preferences.get("auto_ingest_score_threshold"), 90))) + confirm_threshold = max(1, min(100, self._safe_int(preferences.get("confirm_score_threshold"), 70))) + auto_enabled = self._parse_bool_value(preferences.get("auto_ingest_enabled"), False) + hard_risks = [self._clean_text(item) for item in (hard_risk_reasons or []) if self._clean_text(item)] + hard_risk = bool(hard_risks) + can_auto = bool(auto_enabled and not hard_risk and score >= threshold) + if can_auto: + recommended = default_action + elif score >= confirm_threshold and not hard_risk: + recommended = "ask_confirm" + else: + recommended = "do_not_auto" + return { + "score": score, + "score_level": self._score_level(score), + "risk_reasons": risk_reasons, + "hard_risk_reasons": hard_risks, + "can_auto_execute": can_auto, + "recommended_action": recommended, + "auto_ingest_enabled": auto_enabled, + "auto_ingest_score_threshold": threshold, + "confirm_score_threshold": confirm_threshold, + } + + def _score_cloud_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + provider = self._clean_text(item.get("pan_type") or item.get("channel")).lower() + media_type = self._clean_text(item.get("media_type") or item.get("type")).lower() + series_like = ( + media_type in {"tv", "series", "电视剧", "剧集", "番剧"} + or bool(re.search(r"\bs\d{1,2}\b|\be\d{1,3}\b|season|第\s*\d+\s*集|[全整]季|全集|完结|更至|更新至|短剧|剧集", text, flags=re.IGNORECASE)) + ) + series_progress = self._extract_series_progress(text) + + if "2160" in text or "4k" in text or "uhd" in text: + score += 25 + reasons.append("4K/UHD +25") + elif "1080" in text: + score += 16 + reasons.append("1080P +16") + elif "720" in text: + score += 6 + reasons.append("720P +6") + if resolution_pref and resolution_pref in text: + score += 5 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +5") + + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + score += 18 + reasons.append("杜比视界 +18") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + score += 12 + reasons.append("HDR +12") + if self._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + score += 4 + reasons.append("高帧率 +4") + + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "内封简繁", "双语", "官中"]): + score += 14 + reasons.append("中文字幕 +14") + elif self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True): + score -= 5 + risks.append("未识别到中文字幕") + + completeness_detected = self._score_has_any(text, ["全集", "全季", "完结", "complete", "全 ", "更至", "更0", "更新至"]) + if not completeness_detected and series_like and series_progress.get("episode_count", 0) >= 3: + completeness_detected = True + + if completeness_detected: + score += 12 + reasons.append("完整度信息 +12") + elif series_like and self._parse_bool_value(prefs.get("prefer_complete_series"), True): + score -= 6 + risks.append("未识别到全集/更新完整度") + + if series_like and series_progress.get("max_episode", 0) > 0: + progress_bonus = min(10, series_progress["max_episode"]) + if progress_bonus > 0: + score += progress_bonus + reasons.append(f"更新到 E{series_progress['max_episode']:02d} +{progress_bonus}") + if series_progress["max_episode"] >= 7: + latest_bonus = min(4, series_progress["max_episode"] - 6) + if latest_bonus > 0: + score += latest_bonus + reasons.append(f"更新进度领先 +{latest_bonus}") + if series_like and series_progress.get("episode_count", 0) >= 3: + pack_bonus = min(12, series_progress["episode_count"] + 2) + score += pack_bonus + reasons.append(f"连续剧集包 +{pack_bonus}") + if series_progress["episode_count"] >= series_progress.get("max_episode", 0) >= 3: + score += 4 + reasons.append("从 E01 连续覆盖到当前 +4") + + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + + prefer_provider = self._clean_text(prefs.get("prefer_cloud_provider")).lower() + if prefer_provider and provider and prefer_provider == provider: + score += 5 + reasons.append(f"匹配网盘偏好 {provider} +5") + if provider == "115" and not self._parse_bool_value(prefs.get("has_115"), True): + message = "当前偏好未启用 115" + risks.append(message) + hard_risks.append(message) + score -= 30 + if provider == "quark" and not self._parse_bool_value(prefs.get("has_quark"), True): + message = "当前偏好未启用夸克" + risks.append(message) + hard_risks.append(message) + score -= 30 + if target_path and target_path in { + self._clean_text(prefs.get("cloud_default_path")), + self._clean_text(prefs.get("p115_default_path")), + self._clean_text(prefs.get("quark_default_path")), + }: + score += 3 + reasons.append("匹配默认目录 +3") + + if source_type == "hdhive": + limit = max(0, self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + points = self._resource_points_value(item) + if points == 0: + score += 10 + reasons.append("影巢免费 +10") + elif points is None and limit > 0: + reasons.append("影巢未标积分") + elif limit > 0 and points is not None and points > limit: + score -= 30 + message = f"影巢积分 {points} 超过上限 {limit},禁止自动解锁" + risks.append(message) + hard_risks.append(message) + elif points is not None: + score += max(0, 10 - points) + reasons.append(f"影巢积分 {points}") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_ingest_cloud", + ) + return { + **decision, + "source_type": source_type, + "score_reasons": reasons, + } + + def _score_pt_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else item + meta = item.get("meta_info") if isinstance(item.get("meta_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + min_seeders = max(0, self._safe_int(prefs.get("pt_min_seeders"), 3)) + seeders = self._safe_int(torrent.get("seeders"), 0) + peers = self._safe_int(torrent.get("peers"), 0) + volume = self._clean_text(torrent.get("volume_factor") or item.get("volume_factor")).lower() + title = self._clean_text(torrent.get("title")) + site_name = self._clean_text(torrent.get("site_name")) + group_name = self._clean_text(meta.get("resource_team")) + media_title = self._clean_text(media.get("title")).lower() + media_year = self._clean_text(media.get("year")) + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + prefer_dovi = self._parse_bool_value(prefs.get("prefer_dolby_vision"), True) + prefer_hdr = self._parse_bool_value(prefs.get("prefer_hdr"), True) + prefer_subtitle = self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True) + prefer_complete = self._parse_bool_value(prefs.get("prefer_complete_series"), True) + + if seeders <= 0: + message = "做种数 0,禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 35 + elif seeders < min_seeders: + message = f"做种数 {seeders},低于阈值 {min_seeders},禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 25 + elif seeders >= 20: + score += 22 + reasons.append(f"做种数 {seeders} +22") + elif seeders >= 10: + score += 16 + reasons.append(f"做种数 {seeders} +16") + else: + score += 10 + reasons.append(f"做种数 {seeders} +10") + + if peers >= seeders * 5 and seeders < 5: + score -= 8 + risks.append("下载需求高但做种偏低") + elif peers >= max(8, seeders): + score += 6 + reasons.append(f"下载热度 {peers} +6") + elif peers >= 3: + score += 3 + reasons.append(f"下载热度 {peers} +3") + + if self._score_has_any(volume, ["free", "免费", "2xfree", "2x free", "freeleech"]): + score += 18 + reasons.append("免费/促销 +18") + elif self._parse_bool_value(prefs.get("pt_require_free"), False): + score -= 20 + message = "用户要求 PT 免费资源" + risks.append(message) + hard_risks.append(message) + elif self._parse_bool_value(prefs.get("pt_prefer_free"), True): + reasons.append("普通 PT 资源,未额外扣分") + + if "2160" in text or "4k" in text or "uhd" in text: + score += 20 + reasons.append("4K/UHD +20") + elif "1080" in text: + score += 12 + reasons.append("1080P +12") + elif "720" in text: + score += 4 + reasons.append("720P +4") + if resolution_pref and resolution_pref in text: + score += 6 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +6") + has_dovi = self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]) + has_hdr = self._score_has_any(text, ["hdr10", "hdr", "hlg"]) + if has_dovi: + score += 14 + reasons.append("杜比视界 +14") + elif prefer_dovi: + score -= 4 + risks.append("未识别到杜比视界") + if has_hdr: + score += 9 + reasons.append("HDR +9") + elif prefer_hdr: + score -= 2 + risks.append("未识别到 HDR") + if self._score_has_any(text, ["中字", "简中", "繁中", "双语", "内封", "字幕"]): + score += 10 + reasons.append("字幕信息 +10") + elif prefer_subtitle: + score -= 5 + risks.append("未识别到中文字幕") + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + if self._score_has_any(text, ["s01", "season", "全集", "全季", "complete", "完结", "更新至", "更至"]): + score += 6 + reasons.append("季/全集标识 +6") + elif prefer_complete and self._score_has_any(text, ["s01", "s02", "e01", "第1集", "season"]): + score -= 4 + risks.append("未识别到全集/完整季") + + if media_title: + if media_title in title.lower(): + score += 8 + reasons.append("标题匹配媒体名 +8") + else: + score -= 4 + risks.append("标题与识别媒体名匹配一般") + if media_year and media_year in title: + score += 4 + reasons.append(f"年份匹配 {media_year} +4") + if site_name: + score += 2 + reasons.append(f"站点标识 {site_name} +2") + if group_name: + score += 2 + reasons.append(f"发布组 {group_name} +2") + + size_text = self._clean_text(torrent.get("size") or item.get("size")) + size_value = 0.0 + size_match = re.search(r"(\d+(?:\.\d+)?)\s*(tb|gb|mb)", size_text, flags=re.IGNORECASE) + if size_match: + size_value = float(size_match.group(1)) + unit = size_match.group(2).lower() + if unit == "tb": + size_value *= 1024 + elif unit == "mb": + size_value /= 1024 + if size_value > 0: + if self._score_has_any(text, ["2160", "4k", "uhd", "remux", "原盘"]) and size_value < 8: + score -= 6 + risks.append(f"体积 {size_text} 偏小,需留意是否压制过度") + elif self._score_has_any(text, ["1080", "web-dl", "bluray"]) and size_value >= 4: + score += 3 + reasons.append(f"体积 {size_text} 较充足 +3") + if peers >= 30 and seeders >= min_seeders: + score += 4 + reasons.append("热度稳定 +4") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_download_pt", + ) + return { + **decision, + "source_type": "pt", + "score_reasons": reasons, + "seeders": seeders, + "peers": peers, + "min_seeders": min_seeders, + "volume_factor": volume, + "site_name": site_name, + "resource_team": group_name, + } + + def _attach_cloud_scores( + self, + items: List[Dict[str, Any]], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> List[Dict[str, Any]]: + return [ + { + **dict(item or {}), + "score": self._score_cloud_resource( + dict(item or {}), + preferences=preferences, + source_type=source_type, + target_path=target_path, + ), + } + for item in items + if isinstance(item, dict) + ] + + def _rank_pansou_items( + self, + items: List[Dict[str, Any]], + *, + channel_order: Optional[List[str]] = None, + limit_per_channel: int = 20, + ) -> List[Dict[str, Any]]: + grouped: Dict[str, List[Dict[str, Any]]] = {} + for item in items: + if not isinstance(item, dict): + continue + channel = self._normalize_pansou_channel_name(item.get("channel")) + grouped.setdefault(channel, []).append(dict(item)) + + def sort_key(entry: Dict[str, Any]) -> Tuple[Any, ...]: + score = entry.get("score") if isinstance(entry.get("score"), dict) else {} + score_value = self._safe_int(score.get("score"), 0) + hard_risk_count = len(score.get("hard_risk_reasons") or []) + risk_count = len(score.get("risk_reasons") or []) + dt = self._clean_text(entry.get("datetime")) + note = self._clean_text(entry.get("note")) + return ( + score_value, + self._score_quality_rank(entry), + -hard_risk_count, + -risk_count, + dt, + len(note), + ) + + ordered_channels = channel_order or ["115", "quark"] + ranked: List[Dict[str, Any]] = [] + for channel in ordered_channels: + channel_items = sorted(grouped.get(channel) or [], key=sort_key, reverse=True) + ranked.extend(channel_items[:max(1, limit_per_channel)]) + for index, item in enumerate(ranked, start=1): + item["index"] = index + return ranked + + def _public_pansou_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_item = { + "index": self._safe_int(item.get("index"), 0), + "channel": self._normalize_pansou_channel_name(item.get("channel")), + "note": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + "datetime": self._clean_text(item.get("datetime")), + "display_datetime": self._format_pansou_display_datetime(item.get("datetime")), + "password": self._clean_text(item.get("password")), + "url": self._clean_text(item.get("url")), + "brief_summary": self._pansou_item_brief_summary(item), + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + public_item[extra_key] = value + public_items.append(public_item) + return public_items + + @staticmethod + def _public_hdhive_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_items.append({ + "index": AgentResourceOfficer._safe_int(item.get("index"), 0), + "cloud_index": AgentResourceOfficer._safe_int(item.get("cloud_index"), 0), + "title": AgentResourceOfficer._clean_text(item.get("title")), + "pan_type": AgentResourceOfficer._clean_text(item.get("pan_type")), + "share_size": AgentResourceOfficer._list_text(item.get("share_size") or item.get("size")), + "source": AgentResourceOfficer._list_text(item.get("source")), + "subtitle_text": AgentResourceOfficer._resource_subtitle_text(item), + "episode_text": AgentResourceOfficer._resource_episode_text(item), + "remark": AgentResourceOfficer._resource_remark_text(item), + "points_text": AgentResourceOfficer._resource_points_text(item), + "video_resolution": item.get("video_resolution") or [], + "slug": AgentResourceOfficer._clean_text(item.get("slug")), + }) + return public_items + + @staticmethod + def _prepend_search_note(message: str, note: str) -> str: + clean_message = str(message or "").strip() + clean_note = str(note or "").strip() + if not clean_note: + return clean_message + if not clean_message: + return clean_note + return f"{clean_note}\n\n{clean_message}" + + def _assistant_finalize_pansou_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + items: List[Dict[str, Any]], + total: int, + target_path: str, + action_name: str, + search_scope: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_pansou_text(keyword, items, total, page=1, page_size=page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + public_items = self._public_pansou_items(items) + result_data = { + "action": action_name, + "ok": True, + "items": public_items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + "total_pages": max(1, (len(items) + page_size - 1) // page_size) if items else 1, + "decision_summary": self._assistant_pansou_entry_summary(items), + "search_scope": search_scope, + } + if recommend_handoff: + saved_state = self._load_session(cache_key) or {} + result_data.update(self._assistant_recommend_handoff_short_metadata(saved_state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(saved_state) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data=result_data), + } + + @staticmethod + def _pick_cloud_hdhive_candidate( + keyword: str, + candidates: List[Dict[str, Any]], + year: str = "", + ) -> Dict[str, Any]: + clean_year = AgentResourceOfficer._clean_text(year)[:4] + if not candidates: + return {} + if len(candidates) == 1: + return dict(candidates[0] or {}) + if not clean_year: + return {} + matched = [ + dict(item or {}) + for item in candidates + if AgentResourceOfficer._clean_text((item or {}).get("year"))[:4] == clean_year + ] + if len(matched) == 1: + return matched[0] + return {} + + def _format_pansou_body_lines(self, items: List[Dict[str, Any]], total: int) -> List[str]: + count_115 = len([x for x in items if x.get("channel") == "115"]) + count_quark = len([x for x in items if x.get("channel") == "quark"]) + lines = [f"共找到 {total} 条结果,当前展示 115 {count_115} 条、夸克 {count_quark} 条:"] + seen_115 = False + seen_quark = False + for cached in items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + return lines + + @classmethod + def _format_pansou_list_title(cls, item: Dict[str, Any]) -> str: + channel = cls._normalize_pansou_channel_name(item.get("channel")) + emoji = "📺" if channel == "115" else "🗄" if channel == "quark" else "🔗" + title = cls._clean_text(item.get("note") or item.get("title") or "未命名资源") + title = re.sub(r"^\s*\[(?:115|quark|QUARK)\]\s*", "", title).strip() + title = re.sub(r"^\s*#(?:剧集|电影|动漫|资源)\s*", "", title).strip() + title = cls._truncate_text(title, 110) + if title.startswith(("📺", "🗄", "🗃", "📁", "🔗")): + return title + return f"{emoji} {title}".strip() + + def _format_pansou_text( + self, + keyword: str, + items: List[Dict[str, Any]], + total: int, + *, + page: int = 1, + page_size: int = 10, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(items), page=page, page_size=page_size) + page_items = items[start:end] + count_115 = len([x for x in page_items if x.get("channel") == "115"]) + count_quark = len([x for x in page_items if x.get("channel") == "quark"]) + lines = [ + f"盘搜搜索:{keyword}", + f"共找到 {total} 条结果,当前第 {safe_page}/{total_pages} 页(本次缓存 {len(items)} 条候选),本页 115 {count_115} 条、夸克 {count_quark} 条:", + ] + seen_115 = False + seen_quark = False + for cached in page_items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + first_visible_index = self._safe_int((page_items[0] or {}).get("index"), 1) if page_items else 1 + next_quark_hint = next((self._safe_int(item.get("index"), 0) for item in page_items if item.get("channel") == "quark"), 0) + suggestion_lines = self._format_pansou_selection_suggestion(page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + if next_quark_hint > 0 and next_quark_hint != first_visible_index: + lines.append(f"本页夸克结果从 {next_quark_hint} 开始编号;例如直接回复“{next_quark_hint}”即可处理本页第 1 条夸克结果。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _human_resource_signal_text(cls, title: str, reasons: List[str]) -> str: + blob = f"{title} {' '.join(reasons or [])}".lower() + signals: List[str] = [] + if any(token in blob for token in ["4k", "2160", "uhd", "臻彩"]): + signals.append("画质规格更高") + if any(token in blob for token in ["hdr", "dv", "杜比", "dolby", "高码率", "60fps", "60帧"]): + signals.append("视频规格更完整") + if any(token in blob for token in ["e01", "s01", "全集", "全季", "连续", "更新至", "更至", "覆盖到当前"]): + signals.append("集数覆盖更明确") + if any(token in blob for token in ["中字", "简中", "繁中", "字幕", "内封", "内嵌"]): + signals.append("字幕信息更清楚") + if any(token in blob for token in ["免费", "积分 0", "影巢免费"]): + signals.append("成本更低") + if not signals: + return "综合画质、完整度和可转存性更均衡" + return "、".join(signals[:3]) + + @classmethod + def _human_resource_provider_text(cls, provider: str) -> str: + normalized = cls._clean_text(provider).lower() + if normalized == "115" or "115" in normalized: + return "115 资源更适合做主收藏和后续入库" + if normalized == "quark" or "夸克" in normalized: + return "夸克资源更适合走夸克转存链路" + return "这个资源整体更适合优先处理" + + @classmethod + def _human_resource_suggestion_line(cls, best_index: int, best_title: str, provider: str, reasons: List[str]) -> str: + title_part = f":{best_title}" if best_title else "" + provider_text = cls._human_resource_provider_text(provider) + signal_text = cls._human_resource_signal_text(best_title, reasons) + return f"优先选 {best_index}{title_part}。{provider_text},主要优势是{signal_text}。" + + @classmethod + def _human_quark_fallback_line(cls, quark_index: int, quark_title: str, reasons: List[str]) -> str: + title_part = f":{quark_title}" if quark_title else "" + signal_text = cls._human_resource_signal_text(quark_title, reasons) + return f"如果这次明确只走夸克,备选 {quark_index}{title_part}。它在夸克结果里相对更稳,优势是{signal_text}。" + + def _format_pansou_selection_suggestion(self, items: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(items, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:4] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_items = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and self._normalize_pansou_channel_name(item.get("channel")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_hdhive_resource_body_lines( + self, + resources: List[Dict[str, Any]], + *, + candidate: Optional[Dict[str, Any]] = None, + start_index: int = 1, + ) -> List[str]: + lines: List[str] = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {len(resources)} 条") + current_provider = "" + for offset, item in enumerate(resources, start=start_index): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + lines.append(self._format_hdhive_resource_summary_line(item, offset)) + lines.append("") + return [line for line in lines if line is not None] + + @classmethod + def _format_hdhive_resource_summary_line(cls, item: Dict[str, Any], index: int) -> str: + provider = str(item.get("pan_type") or "?").lower() + emoji = "📺" if provider == "115" else "🗄" if provider == "quark" else "🔗" + title = cls._clean_text(item.get("title") or "未命名资源") + points_text = cls._resource_points_text(item) + share_size = cls._list_text(item.get("share_size") or item.get("size")) + episode = cls._resource_episode_text(item) + resolution = "/".join(item.get("video_resolution") or []) or "" + source = cls._list_text(item.get("source")) + subtitle = cls._resource_subtitle_text(item) + remark = cls._resource_remark_text(item) + detail_parts = [ + points_text, + share_size, + episode, + resolution, + source, + subtitle, + remark, + ] + details = " · ".join(part for part in detail_parts if part) + return f"{index}. {emoji} {title}" + (f" · {details}" if details else "") + + def _format_hdhive_selection_suggestion(self, resources: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(resources, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + quark_items = [ + dict(item or {}) + for item in (resources or []) + if isinstance(item, dict) and str(item.get("pan_type") or "").lower() == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("pick_index") or best_quark.get("index"), 0) + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_cloud_selection_suggestion( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> List[str]: + combined: List[Dict[str, Any]] = [] + for item in pansou_items or []: + if isinstance(item, dict): + combined.append(dict(item)) + for item in hdhive_resources or []: + if not isinstance(item, dict): + continue + current = dict(item) + cloud_index = self._safe_int( + current.get("cloud_index") or current.get("pick_index") or current.get("index"), + 0, + ) + current["index"] = cloud_index + current["pick_index"] = cloud_index + combined.append(current) + summary = self._score_summary(combined, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_candidates = [ + dict(item or {}) + for item in combined + if isinstance(item, dict) + and self._normalize_pansou_channel_name(item.get("channel") or item.get("pan_type")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_candidates) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index") or best_quark.get("cloud_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _assistant_cloud_entry_summary( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> Dict[str, Any]: + preferred_index = 0 + if pansou_items: + preferred_index = self._safe_int((pansou_items[0] or {}).get("index"), 0) + elif hdhive_resources: + preferred_index = self._safe_int((hdhive_resources[0] or {}).get("cloud_index") or (hdhive_resources[0] or {}).get("index"), 0) + if preferred_index <= 0: + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(preferred_index), + "fallback_command": f"选择 {preferred_index} 详情", + "compact_commands": [str(preferred_index), f"选择 {preferred_index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _assistant_finalize_cloud_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + target_path: str, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_cloud_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_cloud", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "pansou_items": pansou_items, + "pansou_total": pansou_total, + "hdhive_resources": hdhive_resources, + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidates": hdhive_candidates, + "page": 1, + "page_size": page_size, + }, + ) + text_message = self._format_cloud_text( + keyword=keyword, + pansou_items=pansou_items, + pansou_total=pansou_total, + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=1, + page_size=page_size, + ) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search", + "ok": True, + "items": self._public_pansou_items(pansou_items), + "hdhive_resources": self._public_hdhive_items(hdhive_resources), + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidate_count": len(hdhive_candidates or []), + "page": 1, + "page_size": page_size, + "total_pages": max(1, ((len(pansou_items) + len(hdhive_resources)) + page_size - 1) // page_size) if (pansou_items or hdhive_resources) else 1, + "decision_summary": self._assistant_cloud_entry_summary(pansou_items, hdhive_resources), + "search_scope": "pansou_and_hdhive", + }), + } + + def _format_cloud_text( + self, + *, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + page: int = 1, + page_size: int = 20, + ) -> str: + total_items = len(pansou_items) + len(hdhive_resources) + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(total_items, page=page, page_size=page_size) + start_index = start + 1 + end_index = min(total_items, end) + lines = [f"云盘搜索:{keyword}"] + if total_items > 0: + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {start_index}-{end_index} / 共 {total_items} 条已展开结果:") + pansou_page_items = [item for item in pansou_items if start < self._safe_int(item.get('index'), 0) <= end] + hdhive_page_items = [item for item in hdhive_resources if start < self._safe_int(item.get('cloud_index') or item.get('index'), 0) <= end] + lines.extend(["", "盘搜结果"]) + if pansou_page_items: + lines.extend(self._format_pansou_body_lines(pansou_page_items, pansou_total)) + elif pansou_items: + lines.append("本页无盘搜结果") + else: + lines.append("暂无结果") + lines.extend(["", "影巢结果"]) + if hdhive_page_items: + lines.extend(self._format_hdhive_resource_body_lines( + hdhive_page_items, + candidate=hdhive_candidate, + start_index=self._safe_int((hdhive_page_items[0] or {}).get("cloud_index") or (hdhive_page_items[0] or {}).get("index"), 1), + )) + elif hdhive_resources: + lines.append("本页无影巢结果") + elif hdhive_candidates: + lines.append(f"候选影片 {len(hdhive_candidates)} 个,当前未自动展开。") + lines.append(f"如需细看影巢,请发送:影巢搜索 {keyword}") + else: + lines.append("暂无结果") + suggestion_lines = self._format_cloud_selection_suggestion(pansou_page_items, hdhive_page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + lines.append("如需只看单一来源,可继续发:盘搜搜索 片名 / 影巢搜索 片名。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + def _assistant_finalize_hdhive_candidates( + self, + *, + session: str, + cache_key: str, + keyword: str, + candidates: List[Dict[str, Any]], + media_type: str, + year: str, + target_path: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + self._save_session( + cache_key, + { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or self._hdhive_default_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": True, + "candidates": candidates, + "search_scope": "hdhive", + }), + } + + @staticmethod + def _format_score_label(item: Dict[str, Any]) -> str: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + if not score: + return "" + try: + value = int(float(score.get("score") or 0)) + except Exception: + value = 0 + action = AgentResourceOfficer._clean_text(score.get("recommended_action")) + if score.get("can_auto_execute"): + suffix = "可自动入库" + elif action == "ask_confirm": + suffix = "建议确认" + else: + suffix = "不建议自动" + return f"{value}分 {suffix}" + + @staticmethod + def _score_decision_label(value: Any) -> str: + action = AgentResourceOfficer._clean_text(value) + if action == "ask_confirm": + return "建议确认" + if action == "do_not_auto": + return "不建议自动" + if action == "auto_ingest_cloud": + return "可自动入库" + if action == "auto_download_pt": + return "可自动下载" + return action or "待判断" + + def _score_brief_item(self, item: Dict[str, Any], fallback_index: int = 0) -> Dict[str, Any]: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + if not score: + return {} + torrent = current.get("torrent_info") if isinstance(current.get("torrent_info"), dict) else {} + index = self._safe_int( + current.get("pick_index") or current.get("index") or fallback_index, + fallback_index, + ) + title = ( + self._clean_text(torrent.get("title")) + or self._clean_text(current.get("note")) + or self._clean_text(current.get("title") or current.get("matched_title")) + or "未命名资源" + ) + provider = ( + self._clean_text(current.get("pan_type") or current.get("channel")) + or self._clean_text(torrent.get("site_name")) + or self._clean_text(current.get("site")) + ) + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + brief = { + "index": index, + "title": title[:160], + "provider": provider, + "source_type": self._clean_text(score.get("source_type")), + "score": self._safe_int(score.get("score"), 0), + "quality_rank": self._score_quality_rank(current), + "score_level": self._clean_text(score.get("score_level")), + "recommended_action": self._clean_text(score.get("recommended_action")), + "can_auto_execute": bool(score.get("can_auto_execute")), + "score_reasons": reasons[:3], + "risk_reasons": risks[:2], + "hard_risk_reasons": hard_risks[:2], + } + points_text = self._resource_points_text(current) if current and brief.get("source_type") != "pt" else "" + if points_text and points_text != "积分未知": + brief["points_text"] = points_text + seeders = torrent.get("seeders") if torrent else score.get("seeders") + if seeders is not None: + brief["seeders"] = seeders + volume = self._clean_text(torrent.get("volume_factor") if torrent else score.get("volume_factor")) + if volume: + brief["volume_factor"] = volume + size = self._clean_text(current.get("share_size") or current.get("size") or torrent.get("size")) + if size: + brief["size"] = size + return brief + + def _score_summary(self, items: List[Dict[str, Any]], *, limit: int = 5) -> Dict[str, Any]: + scored: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + brief = self._score_brief_item(item, fallback_index=index) + if brief: + scored.append(brief) + scored.sort(key=lambda value: ( + 1 if value.get("can_auto_execute") else 0, + self._safe_int(value.get("score"), 0), + self._safe_int(value.get("quality_rank"), 0), + ), reverse=True) + auto_count = len([item for item in scored if item.get("can_auto_execute")]) + confirm_count = len([item for item in scored if item.get("recommended_action") == "ask_confirm"]) + blocked_count = len([item for item in scored if item.get("hard_risk_reasons")]) + warning_count = len([item for item in scored if item.get("risk_reasons")]) + summary = { + "total_scored": len(scored), + "auto_count": auto_count, + "confirm_count": confirm_count, + "blocked_count": blocked_count, + "warning_count": warning_count, + "best": scored[0] if scored else None, + "top_recommendations": scored[:max(1, min(10, self._safe_int(limit, 5)))], + } + summary["decision"] = self._score_summary_decision(summary) + return summary + + def _score_summary_decision(self, summary: Optional[Dict[str, Any]]) -> Dict[str, Any]: + data = dict(summary or {}) + best = data.get("best") if isinstance(data.get("best"), dict) else {} + if not best: + return { + "stage": "no_scored_items", + "label": "暂无评分结果", + "requires_confirmation": False, + "prefer_plan_first": True, + "decision_hint": "当前没有可评分条目,先完成搜索或选择后再判断。", + "command_policy": "none", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "", + "fallback_command": "", + "compact_commands": [], + "recommended_commands": [], + } + choice = self._safe_int(best.get("index"), 0) + source_type = self._clean_text(best.get("source_type")).lower() + recommended_action = self._clean_text(best.get("recommended_action")) + title = self._clean_text(best.get("title")) + hard_risks = [self._clean_text(value) for value in (best.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + is_pt = source_type == "pt" + detail_command = f"选择 {choice}" if is_pt else f"选择 {choice} 详情" + plan_command = f"下载{choice}" if is_pt else f"计划选择 {choice}" + commands = [command for command in [detail_command if choice else "", plan_command if choice else "", "执行计划"] if command] + if best.get("can_auto_execute"): + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},已达到自动化阈值,但默认仍建议先生成计划再执行。" + stage = "auto_candidate" + elif hard_risks: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但存在硬风险,不能自动执行。" + stage = "blocked" + elif recommended_action == "ask_confirm": + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},风险可控,建议先看详情再决定。" + stage = "confirm" + else: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但综合评分偏低,不建议直接处理。" + stage = "low_score" + return { + "stage": stage, + "label": self._score_decision_label("auto_download_pt" if best.get("can_auto_execute") and is_pt else "auto_ingest_cloud" if best.get("can_auto_execute") else recommended_action), + "source_type": source_type, + "choice": choice, + "title": title[:160], + "score": self._safe_int(best.get("score"), 0), + "requires_confirmation": not bool(best.get("can_auto_execute")), + "prefer_plan_first": True, + "command_policy": "read_then_confirm_write" if len(commands) > 1 else "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": bool(len(commands) > 1), + "can_auto_run_preferred": bool(commands), + "preferred_command": commands[0] if commands else "", + "fallback_command": commands[1] if len(commands) > 1 else "", + "compact_commands": commands[:2], + "recommended_commands": commands, + "decision_hint": hint, + "top_hard_risk": hard_risks[0] if hard_risks else "", + "top_warning": risks[0] if risks else "", + } + + @staticmethod + def _format_score_summary_decision_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + decision = data.get("decision") if isinstance(data.get("decision"), dict) else {} + if not decision: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(decision.get("label")) + hint = AgentResourceOfficer._clean_text(decision.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (decision.get("compact_commands") or decision.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"决策建议:{label}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("下一步:" + " / ".join(commands)) + return lines + + def _best_scored_source_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not candidates: + return {} + return max( + candidates, + key=lambda item: ( + 1 if (item.get("score") or {}).get("can_auto_execute") else 0, + self._safe_int((item.get("score") or {}).get("score"), 0), + self._score_quality_rank(item), + -self._safe_int(item.get("index") or item.get("pick_index"), 0), + ), + ) + + def _format_cloud_item_detail_text(self, item: Dict[str, Any], *, title: str = "云盘资源详情") -> str: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + score_summary = self._score_summary([current], limit=1) if score else {} + index = self._safe_int(current.get("index") or current.get("pick_index"), 0) + name = ( + self._clean_text(current.get("note")) + or self._clean_text(current.get("title")) + or self._clean_text(current.get("matched_title")) + or "未命名资源" + ) + provider = self._clean_text(current.get("channel") or current.get("pan_type") or current.get("provider")) + size = self._clean_text(current.get("share_size") or current.get("size")) + resolution = self._clean_text(current.get("resolution")) + source = self._clean_text(current.get("source") or current.get("source_name")) + datetime_text = self._format_pansou_display_datetime(current.get("datetime")) + password = self._clean_text(current.get("password")) + url = self._clean_text(current.get("url") or current.get("share_url") or current.get("link")) + brief = self._clean_text(current.get("brief_summary") or self._pansou_item_brief_summary(current)) + points = self._resource_points_text(current) + lines = [title] + if index: + lines.append(f"编号:{index}") + lines.append(f"资源:{name}") + if provider: + lines.append(f"网盘:{provider}") + if points: + lines.append(f"积分:{points}") + if resolution: + lines.append(f"分辨率:{resolution}") + if size: + lines.append(f"大小:{size}") + if source: + lines.append(f"来源:{source}") + if datetime_text: + lines.append(f"日期:{datetime_text}") + if brief: + lines.append(f"摘要:{brief}") + if password: + lines.append(f"提取码:{password}") + if url: + lines.append(f"链接:{url}") + if score: + lines.append("") + lines.append("智能建议") + lines.append(f"评分:{self._safe_int(score.get('score'), 0)} / {self._clean_text(score.get('score_level')) or '-'}") + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + lines.extend(self._format_score_summary_decision_lines(score_summary)) + return "\n".join(lines) + + def _assistant_scoring_policy_public_data(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "schema_version": "scoring_policy.v1", + "owner": "plugin_rules", + "agent_role": "explain_and_confirm_only", + "summary": "评分由插件内置规则执行;外部智能体只读取 score_summary、解释原因、请求确认,不能绕过硬风险。", + "global_decision": { + "auto_execute_requires": [ + "auto_ingest_enabled=true", + "score >= auto_ingest_score_threshold", + "hard_risk_reasons 为空", + ], + "confirm_range": "confirm_score_threshold <= score < auto_ingest_score_threshold 且无硬风险", + "default_source": "plugin_config_then_session_preferences", + "default_confirm_score_threshold": prefs.get("confirm_score_threshold"), + "default_auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + "auto_ingest_default": prefs.get("auto_ingest_enabled"), + }, + "cloud": { + "source_types": ["hdhive", "pansou", "115", "quark"], + "positive_signals": [ + "4K/UHD", + "杜比视界/HDR", + "中文字幕", + "全集/完整季/更新完整度", + "REMUX/原盘/Web-DL/高码率", + "匹配网盘偏好", + "匹配默认目录", + ], + "hard_gates": [ + "影巢积分超过 hdhive_max_unlock_points 时禁止自动解锁", + "影巢积分未知时禁止自动解锁", + ], + "default_hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "pansou_cost": "无积分成本,主要按质量、完整度、字幕和网盘类型评分", + }, + "pt": { + "source_types": ["moviepilot_native_search", "torrent_search", "subscribe_search"], + "positive_signals": [ + "做种数高", + "免费/促销/FreeLeech", + "4K/UHD", + "杜比视界/HDR", + "字幕信息", + "REMUX/原盘/Web-DL/高码率", + "季/全集标识", + ], + "hard_gates": [ + "做种数 0 禁止自动下载", + "做种数低于 pt_min_seeders 禁止自动下载", + "用户要求免费时,非免费资源禁止自动下载", + ], + "default_pt_min_seeders": prefs.get("pt_min_seeders"), + "volume_factor_note": "免费/促销明显加分;普通资源默认中性,不强行判废", + }, + "score_summary_contract": { + "read_fields": [ + "best", + "top_recommendations", + "decision", + "score", + "score_level", + "recommended_action", + "can_auto_execute", + "score_reasons", + "risk_reasons", + "hard_risk_reasons", + ], + "decision_fields": [ + "stage", + "label", + "choice", + "decision_hint", + "preferred_command", + "fallback_command", + "compact_commands", + "recommended_commands", + ], + "blocked_count": "只统计 hard_risk_reasons,不统计缺字幕等软提醒", + "warning_count": "统计 risk_reasons,用于解释需要用户确认的原因", + "do_not_parse": "不要解析自然语言 message 来判断自动化,优先读取 score_summary", + }, + } + + @staticmethod + def _format_bytes_size(value: Any) -> str: + try: + size = float(value or 0) + except Exception: + return "" + if size <= 0: + return "" + units = ["B", "KB", "MB", "GB", "TB"] + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f}{units[index]}" if index else f"{int(size)}B" + + def _mp_context_preview_item(self, context: Any, index: int, preferences: Dict[str, Any]) -> Dict[str, Any]: + torrent = getattr(context, "torrent_info", None) + meta = getattr(context, "meta_info", None) + media = getattr(context, "media_info", None) + item = { + "index": index, + "cache_index": index, + "torrent_info": { + "title": self._clean_text(getattr(torrent, "title", "")), + "size": self._format_bytes_size(getattr(torrent, "size", None)), + "seeders": getattr(torrent, "seeders", None), + "peers": getattr(torrent, "peers", None), + "site_name": self._clean_text(getattr(torrent, "site_name", "")), + "volume_factor": self._clean_text(getattr(torrent, "volume_factor", "")), + "page_url": self._clean_text(getattr(torrent, "page_url", "")), + }, + "meta_info": { + "season_episode": self._clean_text(getattr(meta, "season_episode", "")), + "resource_team": self._clean_text(getattr(meta, "resource_team", "")), + "video_encode": self._clean_text(getattr(meta, "video_encode", "")), + "edition": self._clean_text(getattr(meta, "edition", "")), + "resource_pix": self._clean_text(getattr(meta, "resource_pix", "")), + }, + "media_info": { + "title": self._clean_text(getattr(media, "title", "")), + "year": self._clean_text(getattr(media, "year", "")), + "tmdb_id": getattr(media, "tmdb_id", None), + "douban_id": getattr(media, "douban_id", None), + }, + } + item["score"] = self._score_pt_resource(item, preferences=preferences) + return item + + @classmethod + def _display_pt_title(cls, title: Any) -> str: + text = cls._clean_text(title or "未命名资源") + # Some chat frontends auto-link dotted release names such as + # Spider-Man.2002.1080p. Insert zero-width breaks for display only. + return re.sub(r"(?<=[A-Za-z0-9])\.(?=[A-Za-z0-9])", ".\u200b", text) + + @classmethod + def _pt_title_badges(cls, title: Any) -> str: + text = cls._clean_text(title).lower() + badges: List[str] = [] + if cls._score_has_any(text, ["2160", "4k", "uhd"]): + badges.append("✨4K") + elif cls._score_has_any(text, ["1080"]): + badges.append("🎞️1080p") + if cls._score_has_any(text, ["dolby vision", "dovi", " dv ", ".dv.", "杜比视界"]): + badges.append("⚡DV") + if cls._score_has_any(text, ["hdr10", "hdr", "hlg"]): + badges.append("🔶HDR") + if cls._score_has_any(text, ["remux", "原盘"]): + badges.append("💿REMUX") + return " ".join(badges) + + @staticmethod + def _pt_promotion_text(value: Any) -> str: + text = str(value or "").strip() or "普通" + lowered = text.lower() + if "免费" in text or "free" in lowered: + return f"🆓 {text}" + if "%" in text or "x" in lowered: + return f"🎁 {text}" + return f"🎫 {text}" + + def _sort_mp_preview_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + ranked = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) + ] + ranked.sort( + key=lambda item: ( + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + self._score_quality_rank(item), + self._safe_int((item.get("score") or {}).get("score"), 0), + -self._safe_int(item.get("index"), 0), + ), + reverse=True, + ) + for index, item in enumerate(ranked, start=1): + item["index"] = index + item["display_index"] = index + return ranked + + def _renumber_mp_display_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + renumbered: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], start=1): + if not isinstance(item, dict): + continue + current = dict(item) + current["source_index"] = self._safe_int( + current.get("source_index") or current.get("index") or current.get("display_index"), + index, + ) + current["index"] = index + current["display_index"] = index + renumbered.append(current) + return renumbered + + def _assistant_mp_selection_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + state = self._load_session(cache_key) or {} + state_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if state_items: + return [ + dict(item or {}) + for item in state_items + if isinstance(item, dict) + ] + return self._mp_search_all_preview_items(cache_key, preferences=preferences) + + def _assistant_mp_item_by_display_index( + self, + *, + choice: int, + cache_key: str, + preferences: Dict[str, Any], + ) -> Tuple[Dict[str, Any], List[int]]: + items = self._assistant_mp_selection_items(cache_key, preferences) + selected = next( + ( + dict(item or {}) + for item in items + if self._safe_int((item or {}).get("index"), 0) == choice + ), + {}, + ) + available = [ + self._safe_int((item or {}).get("index"), 0) + for item in items + if isinstance(item, dict) and self._safe_int(item.get("index"), 0) > 0 + ] + return selected, available + + @staticmethod + def _page_bounds(total_items: int, page: int = 1, page_size: int = 20) -> Tuple[int, int, int, int]: + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (max(0, total_items) + safe_page_size - 1) // safe_page_size) if total_items else 1 + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + end = start + safe_page_size + return safe_page, safe_page_size, total_pages, start, end + + def _mp_search_cache_preview( + self, + cache_key: str, + preferences: Dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + all_items: List[Dict[str, Any]] = [] + for index, context in enumerate(results, 1): + all_items.append(self._mp_context_preview_item(context, index, preferences)) + all_items = self._sort_mp_preview_items(all_items) + effective_page_size = max(1, self._safe_int(limit, page_size)) + return self._slice_mp_preview_items(all_items, page=page, page_size=effective_page_size) + + def _mp_search_all_preview_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + return self._sort_mp_preview_items([ + self._mp_context_preview_item(context, index, preferences) + for index, context in enumerate(results, 1) + ]) + + def _slice_mp_preview_items( + self, + items: List[Dict[str, Any]], + *, + page: int = 1, + page_size: int = 20, + ) -> List[Dict[str, Any]]: + _safe_page, _safe_page_size, _total_pages, start, end = self._page_bounds( + len(items), + page=page, + page_size=page_size, + ) + return [ + dict(item or {}) + for item in items[start:end] + if isinstance(item, dict) + ] + + def _format_mp_search_text( + self, + keyword: str, + message_text: str, + preview: List[Dict[str, Any]], + *, + total: int = 0, + page: int = 1, + page_size: int = 20, + result_filter: str = "", + latest_episode: int = 0, + episode_filter: int = 0, + ) -> str: + header = message_text.strip().splitlines()[0] if message_text else f"MP 原生搜索:{keyword}" + lines = [header] + if preview: + total_results = max(self._safe_int(total, 0), len(preview)) + safe_page_size = max(1, self._safe_int(page_size, self._assistant_result_page_size)) + total_pages = max(1, (total_results + safe_page_size - 1) // safe_page_size) + score_summary = self._score_summary(preview, limit=5) + lines.append("") + if result_filter == "latest_episode" and latest_episode > 0: + lines.append(f"最新集筛选:当前最高 E{latest_episode:02d},仅展示包含该集数的候选。") + elif result_filter.startswith("episode:") and episode_filter > 0: + lines.append(f"集数筛选:仅展示包含 E{episode_filter:02d} 的候选。") + lines.append(f"当前第 {max(1, page)}/{total_pages} 页,共 {total_results} 条结果(按做种数优先排序):") + lines.append("PT 资源:") + for item in preview: + torrent = item.get("torrent_info") or {} + score = item.get("score") or {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + badge_suffix = f" {badges}" if badges else "" + lines.append(f"{item.get('index')}. 🧲【{torrent.get('site_name') or '未知站点'}】 {title}{badge_suffix}") + details = [ + f"🌱 做种:{torrent.get('seeders') if torrent.get('seeders') is not None else '?'}", + self._pt_promotion_text(torrent.get("volume_factor")), + f"💾 {torrent.get('size') or '未知'}", + f"⭐ {self._format_score_label(item)}", + ] + hard_risks = score.get("hard_risk_reasons") or [] + risks = score.get("risk_reasons") or [] + risks = [risk for risk in risks if risk not in hard_risks] + if hard_risks: + details.append("硬风险:" + ";".join(str(item) for item in hard_risks[:2])) + elif risks: + details.append("提醒:" + ";".join(str(item) for item in risks[:2])) + lines.append(" " + " | ".join(details)) + decision_lines = self._format_score_summary_decision_lines(score_summary) + if result_filter == "latest_episode" or result_filter.startswith("episode:"): + normalized_decision_lines: List[str] = [] + for line in decision_lines: + if line.startswith("下一步:"): + continue + if line.startswith("建议:"): + line = line.replace( + "建议先看详情再决定", + "可直接生成下载计划,计划不会立即执行", + ) + normalized_decision_lines.append(line) + decision_lines = normalized_decision_lines + lines.extend(decision_lines) + if page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + best_index = self._safe_int(((score_summary.get("best") or {}) if isinstance(score_summary, dict) else {}).get("index"), 0) + if (result_filter == "latest_episode" or result_filter.startswith("episode:")) and best_index > 0: + lines.append(f"操作提示:建议回复“{best_index}”或“下载{best_index}”生成下载计划,不会立即下载。") + lines.append(f"如需先核对站点详情,可回复“{best_index}详情”。") + else: + lines.append("操作提示:回复编号或“下载N”生成下载计划;回复“N详情”看详情。") + lines.append("计划生成后,再回复“执行计划”或同一个编号确认执行。") + return "\n".join(line for line in lines if line) + + async def _assistant_mp_media_detail( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "", + year: str = "", + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_media_detail( + keyword=keyword, + media_type=media_type, + year=year, + ) + item = result.get("item") if isinstance(result.get("item"), dict) else {} + self._save_session(cache_key, { + "kind": "assistant_mp_media_detail", + "stage": "media_detail", + "keyword": keyword, + "items": [item] if item else [], + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "媒体识别完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": bool(result.get("success")), + "keyword": keyword, + "media_type": media_type, + "year": year, + "item": item, + }), + } + + async def _assistant_mp_media_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + preferences: Dict[str, Any], + page: int = 1, + page_size: int = 10, + result_filter: str = "", + ) -> Dict[str, Any]: + message_text = self._ensure_feishu_channel()._execute_media_search(keyword, cache_key) + failed_prefixes = ("MP 原生搜索失败", "未识别到媒体信息", "搜索资源失败") + route_ok = not any(message_text.startswith(prefix) for prefix in failed_prefixes) + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + total = len((cache or {}).get("results") or []) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + filtered_items = all_items + latest_episode = 0 + episode_filter = 0 + effective_filter = self._clean_text(result_filter) + if effective_filter == "latest_episode": + latest_items, latest_episode = self._latest_episode_mp_items(all_items) + if latest_items: + filtered_items = self._renumber_mp_display_items(latest_items) + total = len(filtered_items) + elif effective_filter.startswith("episode:"): + episode_filter = self._safe_int(effective_filter.split(":", 1)[1], 0) + episode_items = self._episode_filter_mp_items(all_items, episode_filter) + if episode_items: + filtered_items = self._renumber_mp_display_items(episode_items) + total = len(filtered_items) + preview = self._slice_mp_preview_items(filtered_items, page=page, page_size=page_size) if filtered_items else self._mp_search_cache_preview(cache_key, preferences=preferences, page=page, page_size=page_size) + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": keyword, + "items": preview, + "all_items": filtered_items, + "raw_all_items": all_items, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "target_path": "", + }) + return { + "success": route_ok, + "message": self._format_mp_search_text( + keyword, + message_text, + preview, + total=total, + page=page, + page_size=page_size, + result_filter=effective_filter, + latest_episode=latest_episode, + episode_filter=episode_filter, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search", + "ok": route_ok, + "keyword": keyword, + "source_type": "pt", + "items": preview, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "total_pages": max(1, (max(0, total) + max(1, self._safe_int(page_size, self._assistant_result_page_size)) - 1) // max(1, self._safe_int(page_size, self._assistant_result_page_size))) if total else 1, + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "score_summary": self._score_summary(preview, limit=5), + "preferences": preferences, + }), + } + + async def _assistant_mp_candidate_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "auto", + year: str = "", + page: int = 1, + pending_action: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type=self._clean_text(media_type or "auto").lower() or "auto", + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "success": False, + "message": f"MP 候选解析失败:{search_message}", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": False, + "keyword": clean_keyword, + "error_code": "candidate_search_failed", + "result": result, + }), + } + candidates = result.get("candidates") or [] + page_size = self._hdhive_candidate_page_size + self._save_session(cache_key, { + "kind": "assistant_mp_candidate", + "stage": "candidate", + "keyword": clean_keyword, + "media_type": self._clean_text(media_type or "auto").lower() or "auto", + "year": self._clean_text(year), + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + "target_path": target_path or "", + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }) + message = self._format_mp_candidate_lines(candidates, page=page, page_size=page_size) + if isinstance(pending_action, dict) and pending_action: + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名生成待确认下载计划,不会直接下载。", + ) + elif pending_mode == "cloud_transfer_execute": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名继续搜索云盘资源并转存。", + ) + pending_label = self._clean_text(pending_action.get("label")) + if pending_label: + message = f"{message}\n选定影片后将继续:{pending_label}" + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": True, + "keyword": clean_keyword, + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }), + } + + async def _assistant_mp_result_detail( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + item, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not item: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice}。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + torrent = item.get("torrent_info") or {} + meta = item.get("meta_info") or {} + media = item.get("media_info") or {} + score = item.get("score") or {} + lines = [ + f"MP 搜索结果详情 #{choice}", + f"标题:🧲 {self._display_pt_title(torrent.get('title'))} {self._pt_title_badges(torrent.get('title'))}".strip(), + f"站点:【{torrent.get('site_name') or '未知站点'}】", + f"大小:💾 {torrent.get('size') or '未知'}", + f"热度:🌱 做种 {torrent.get('seeders') if torrent.get('seeders') is not None else '?'} | 下载 {torrent.get('peers') if torrent.get('peers') is not None else '?'} | {self._pt_promotion_text(torrent.get('volume_factor'))}", + ] + media_text = " / ".join(str(part) for part in [ + media.get("title"), + media.get("year"), + f"TMDB:{media.get('tmdb_id')}" if media.get("tmdb_id") else "", + f"豆瓣:{media.get('douban_id')}" if media.get("douban_id") else "", + ] if part) + if media_text: + lines.append(f"媒体:{media_text}") + meta_text = " / ".join(str(part) for part in [ + meta.get("season_episode"), + meta.get("resource_pix"), + meta.get("video_encode"), + meta.get("edition"), + meta.get("resource_team"), + ] if part) + if meta_text: + lines.append(f"识别标签:{meta_text}") + score_label = self._format_score_label(item) + if score_label: + lines.append(f"评分:{score_label}") + reasons = [str(value) for value in (score.get("score_reasons") or []) if value] + hard_risks = [str(value) for value in (score.get("hard_risk_reasons") or []) if value] + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + risks = [risk for risk in risks if risk not in hard_risks] + score_summary = self._score_summary([item], limit=1) + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + if torrent.get("page_url"): + lines.append(f"详情页:{torrent.get('page_url')}") + lines.extend(self._format_score_summary_decision_lines(score_summary)) + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": (cache or {}).get("keyword") or current_state.get("keyword") or "", + "items": current_state.get("items") or self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=max(1, self._safe_int(current_state.get("page"), 1)), + page_size=max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + ), + "all_items": current_state.get("all_items") or [], + "raw_all_items": current_state.get("raw_all_items") or [], + "selected_index": choice, + "total": len(results), + "page": max(1, self._safe_int(current_state.get("page"), 1)), + "page_size": max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + "result_filter": current_state.get("result_filter") or "", + "latest_episode": self._safe_int(current_state.get("latest_episode"), 0), + "episode_filter": self._safe_int(current_state.get("episode_filter"), 0), + "target_path": current_state.get("target_path") or "", + **({"recommend_handoff": dict(current_state.get("recommend_handoff") or {})} if current_state.get("recommend_handoff") else {}), + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": True, + "choice": choice, + "item": item, + "score_summary": score_summary, + }), + } + + async def _assistant_mp_best_result_detail( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_best_detail", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = await self._assistant_mp_result_detail( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if result.get("success"): + result["message"] = f"当前评分最高的 PT 候选是 #{choice}\n{result.get('message') or ''}".strip() + data = dict(result.get("data") or {}) + data["action"] = "mp_search_best_detail" + data["best_choice"] = choice + result["data"] = data + return result + + async def _assistant_mp_best_download_plan( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_best_download_plan", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_best_download", + message="最佳片源下载计划已生成", + ) + if result.get("success"): + result["message"] = "\n".join(line for line in [ + f"已选择当前评分最高的 PT 候选:#{choice}", + result.get("message") or "", + ] if line) + data = dict(result.get("data") or {}) + data["choice"] = choice + data["item"] = best + result["data"] = data + return result + + def _assistant_format_download_plan_choice(self, *, rank: int, plan_id: str, item: Dict[str, Any]) -> str: + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + score = item.get("score") if isinstance(item.get("score"), dict) else {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + site = self._clean_text(torrent.get("site_name")) or "未知站点" + seeders = torrent.get("seeders") if torrent.get("seeders") is not None else "?" + size = self._clean_text(torrent.get("size")) or "未知" + score_label = self._format_score_label(item) + risk_text = self._assistant_score_warning_text(score, limit=1).replace("风险提示:", "") + suffix = f" | 提醒:{risk_text}" if risk_text else "" + badge_text = f" {badges}" if badges else "" + return ( + f"{rank}. {plan_id} | 资源 #{self._safe_int(item.get('index'), 0)} | " + f"🧲【{site}】 {title}{badge_text}\n" + f" 🌱 做种:{seeders} | {self._pt_promotion_text(torrent.get('volume_factor'))} | 💾 {size} | ⭐ {score_label}{suffix}" + ) + + async def _assistant_attach_download_plan_choices( + self, + result: Dict[str, Any], + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + limit: int = 3, + ) -> Dict[str, Any]: + if not isinstance(result, dict) or not result.get("success"): + return result + result_data = result.get("data") if isinstance(result.get("data"), dict) else {} + preview = [ + dict(item or {}) + for item in (result_data.get("items") or []) + if isinstance(item, dict) + ] + if not preview: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + scored.sort( + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + reverse=True, + ) + choices = scored[:max(1, min(3, self._safe_int(limit, 3)))] + if not choices: + return result + group_id = self._new_session_id("mpdl") + plan_rows: List[str] = [] + plan_items: List[Dict[str, Any]] = [] + for rank, item in enumerate(choices, start=1): + plan_result = self._assistant_mp_download_plan_response( + choice=self._safe_int(item.get("index"), 0), + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download_choice", + message=f"下载方案 {rank} 已生成", + ) + if not plan_result.get("success"): + continue + plan_data = plan_result.get("data") or {} + plan_id = self._clean_text(plan_data.get("plan_id")) + if plan_id and isinstance(self._workflow_plans.get(plan_id), dict): + execute_body = self._workflow_plans[plan_id].get("execute_body") + if not isinstance(execute_body, dict): + execute_body = {} + execute_body.update({ + "plan_rank": rank, + "multi_plan_group": group_id, + }) + self._workflow_plans[plan_id]["execute_body"] = execute_body + self._workflow_plans[plan_id]["plan_rank"] = rank + self._workflow_plans[plan_id]["plan_choice"] = self._safe_int(item.get("index"), 0) + self._workflow_plans[plan_id]["multi_plan_group"] = group_id + self._persist_workflow_plans() + plan_rows.append(self._assistant_format_download_plan_choice(rank=rank, plan_id=plan_id, item=item)) + plan_items.append({ + "rank": rank, + "plan_id": plan_id, + "choice": self._safe_int(item.get("index"), 0), + "item": item, + }) + if not plan_items: + return result + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + **current_state, + "kind": "assistant_mp_download_plans", + "stage": "download_plan_choices", + "pending_plan_group": group_id, + "plan_choices": plan_items, + "source_search": result.get("data") or {}, + }) + merged = { + "success": True, + "message": "\n".join([ + f"已按当前偏好生成 {len(plan_items)} 个待确认下载方案,均未实际下载:", + *plan_rows, + "回复方案编号 1/2/3 执行对应方案;回复“选择 资源编号”可先看详情。", + ]), + } + data = { + "action": "mp_download_plan_choices", + "ok": True, + "pending_plan_group": group_id, + "plan_choices": plan_items, + "ready_to_execute": True, + "write_effect": "state", + } + data["source_search"] = result.get("data") or {} + data["source_search_message"] = self._clean_text(result.get("message")) + merged["data"] = self._assistant_response_data(session=session, data=data) + return merged + + def _assistant_smart_search_stop_after_source( + self, + candidate: Optional[Dict[str, Any]], + preferences: Optional[Dict[str, Any]], + ) -> bool: + current = dict(candidate or {}) + if not current: + return False + score_value = self._safe_int(current.get("score"), 0) + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + hard_risks = current.get("hard_risk_reasons") or [] + recommended_action = self._clean_text(current.get("recommended_action")) + return score_value >= threshold and not hard_risks and recommended_action != "not_recommended" + + def _assistant_smart_search_candidate_from_cloud_item( + self, + item: Dict[str, Any], + *, + source_type: str, + ) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + provider = self._clean_text(item.get("pan_type") or item.get("channel") or item.get("provider")).lower() + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action == "ask_confirm": + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"计划选择 {index}" if index > 0 else "" + detail_command = f"选择 {index} 详情" if index > 0 else "" + return { + "source_type": source_type, + "title": self._clean_text(item.get("note") or item.get("title") or item.get("matched_title") or "未命名资源"), + "year": self._clean_text(item.get("year") or item.get("matched_year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "provider": provider or self._clean_text(item.get("source") or source_type), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "points_text": self._resource_points_text(item), + "provider": provider, + "size": self._clean_text(item.get("share_size") or item.get("size")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + def _assistant_smart_search_candidate_from_pt_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action in {"ask_confirm", "auto_download_pt"}: + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"下载{index}" if index > 0 else "" + detail_command = f"选择 {index}" if index > 0 else "" + return { + "source_type": "mp_pt", + "title": self._clean_text(torrent.get("title") or media.get("title") or "未命名资源"), + "year": self._clean_text(media.get("year")), + "media_type": self._clean_text(item.get("media_type") or media.get("type") or "tv"), + "provider": self._clean_text(torrent.get("site_name") or "pt"), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "seeders": self._safe_int(score.get("seeders"), self._safe_int(torrent.get("seeders"), 0)), + "volume_factor": self._clean_text(score.get("volume_factor") or torrent.get("volume_factor")), + "site_name": self._clean_text(score.get("site_name") or torrent.get("site_name")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + async def _assistant_smart_search_hdhive_resources( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + target_path: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return { + "source_type": "hdhive", + "ok": False, + "skipped": True, + "reason": disabled.get("message") or "影巢资源入口已关闭", + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "source_type": "hdhive", + "ok": False, + "reason": search_message, + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + candidates = result.get("candidates") or [] + if not candidates: + return { + "source_type": "hdhive", + "ok": True, + "reason": "未找到影巢候选", + "state": { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + "candidates": [], + "items": [], + "score_summary": {}, + } + + selected_candidate: Dict[str, Any] = {} + selected_preview: List[Dict[str, Any]] = [] + selected_summary: Dict[str, Any] = {} + for candidate in candidates[:3]: + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or media_type or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + continue + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path, + ) + if not preview: + continue + summary = self._score_summary(preview, limit=5) + best = summary.get("best") or {} + current_best = selected_summary.get("best") if isinstance(selected_summary.get("best"), dict) else {} + if not selected_preview or self._safe_int(best.get("score"), 0) > self._safe_int(current_best.get("score"), 0): + selected_candidate = dict(candidate or {}) + selected_preview = preview + selected_summary = summary + if self._assistant_smart_search_stop_after_source( + self._assistant_smart_search_candidate_from_cloud_item(self._best_scored_source_item(preview), source_type="hdhive"), + preferences, + ): + break + + state: Dict[str, Any] + if selected_preview: + state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "selected_candidate": selected_candidate, + "resources": selected_preview, + "page": 1, + "page_size": self._assistant_result_page_size, + } + else: + state = { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + } + return { + "source_type": "hdhive", + "ok": True, + "reason": search_message, + "state": state, + "candidates": candidates, + "items": selected_preview, + "score_summary": selected_summary, + } + + def _assistant_smart_search_decision_summary( + self, + *, + keyword: str, + preferences: Dict[str, Any], + checked: List[Dict[str, Any]], + best_candidate: Dict[str, Any], + available_sources: Optional[List[Dict[str, Any]]] = None, + blocked_sources: Optional[List[Dict[str, Any]]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + auto_threshold = self._safe_int( + prefs.get("auto_ingest_score_threshold"), + self._assistant_default_auto_ingest_score_threshold, + ) + source_names = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + preferred_command = self._clean_text(best_candidate.get("next_command")) + fallback_command = self._clean_text(best_candidate.get("detail_command")) + detail_command = "先看详情" if best_candidate.get("choice") else "" + detail_short_command = "详情" if best_candidate.get("choice") else "" + title = self._clean_text(best_candidate.get("title")) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + write_intent_commands = {"计划最佳", "执行最佳", "确认执行", "先计划"} + decision_mode = "show_detail" + confirm_required = False + decision_reason = "" + confirmation_prompt = "" + if not best_candidate: + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "decision_hint": "已按当前偏好尝试可用源,但没有找到符合条件的资源。", + "decision_mode": "not_recommended", + "decision_reason": "所有可用源都没有给出符合当前偏好的候选。", + "preferred_command": "", + "fallback_command": "", + "detail_command": "", + "detail_short_command": "", + "confirmation_prompt": "", + "plan_short_command": "", + "confirm_short_command": "", + "compact_commands": [], + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": False, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": "show_only", + } + if hard_risks: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最高分是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')},但存在硬风险。" + decision_mode = "not_recommended" + decision_reason = "当前最高分候选存在硬风险,不能作为直接推荐执行项。" + confirmation_prompt = "先看详情,或换源后再试。" + elif score_value >= threshold: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前首选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分)。" + if score_value >= auto_threshold: + decision_mode = "execute_now" + decision_reason = "当前首选分数已达到高可信区间,可以作为立即执行首选,但写入仍需明确意图。" + preferred_command = "执行最佳" + fallback_command = "计划最佳" + confirm_required = True + confirmation_prompt = "确认执行;如果想保守一点,回复:先计划 或 先看详情。" + else: + decision_mode = "make_plan" + decision_reason = "当前首选已达到建议确认阈值,优先生成待确认计划。" + preferred_command = "计划最佳" + fallback_command = "执行最佳" + confirm_required = True + confirmation_prompt = "先计划;如果想直接落地,回复:确认执行;如果想先检查资源,回复:先看详情。" + else: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最佳候选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分),但还没达到优先阈值。" + decision_mode = "show_detail" + decision_reason = "当前最佳候选分数偏低,优先查看详情或尝试切换搜索源。" + preferred_command = fallback_command or preferred_command + fallback_command = "计划最佳" if best_candidate.get("choice") and not hard_risks else "" + confirmation_prompt = "先看详情;如果仍要继续,回复:先计划 或 换影巢 / 换盘搜 / 换PT。" + if title: + hint = f"{hint} {title}" + auto_run_command = "" + confirm_command = "" + display_command = preferred_command or detail_command + preferred_requires_confirmation = preferred_command in write_intent_commands + fallback_requires_confirmation = fallback_command in write_intent_commands + if preferred_requires_confirmation: + confirm_command = preferred_command + if detail_command and detail_command != preferred_command: + command_policy = "read_then_confirm_write" + auto_run_command = detail_command + display_command = detail_command + recommended_agent_behavior = "auto_continue_then_wait_confirmation" + else: + command_policy = "wait_user_confirmation" + recommended_agent_behavior = "wait_user_confirmation" + can_auto_run_preferred = False + else: + command_policy = "safe_read_only" + can_auto_run_preferred = bool(preferred_command) + recommended_agent_behavior = "show_only" if decision_mode == "not_recommended" else "auto_continue" + display_command = preferred_command or detail_command + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "threshold": threshold, + "auto_ingest_score_threshold": auto_threshold, + "preferred_source": source_type, + "preferred_score": score_value, + "preferred_title": title[:160], + "decision_hint": hint.strip(), + "decision_mode": decision_mode, + "decision_reason": decision_reason or hint.strip(), + "preferred_command": preferred_command, + "fallback_command": fallback_command, + "detail_command": detail_command if best_candidate.get("choice") and not hard_risks else "", + "detail_short_command": detail_short_command if best_candidate.get("choice") and not hard_risks else "", + "confirmation_prompt": confirmation_prompt, + "plan_command": "计划最佳" if best_candidate.get("choice") and not hard_risks else "", + "plan_short_command": "计划" if best_candidate.get("choice") and not hard_risks else "", + "execute_command": "执行最佳" if best_candidate.get("choice") and not hard_risks else "", + "confirm_short_command": "确认" if best_candidate.get("choice") and not hard_risks else "", + "compact_commands": [ + command + for command in [preferred_command, fallback_command, detail_short_command or detail_command] + if command + ][:2], + "command_policy": command_policy, + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": fallback_requires_confirmation, + "can_auto_run_preferred": can_auto_run_preferred, + "auto_run_command": auto_run_command, + "confirm_command": confirm_command, + "display_command": display_command, + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": confirm_required, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": recommended_agent_behavior, + } + + def _assistant_smart_decision_entry_message( + self, + *, + keyword: str, + decision_summary: Dict[str, Any], + available_sources: List[Dict[str, Any]], + blocked_sources: List[Dict[str, Any]], + ) -> str: + checked = decision_summary.get("checked_sources") or [] + checked_text = " -> ".join( + { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + }.get(self._clean_text(item).lower(), self._clean_text(item)) + for item in checked + if self._clean_text(item) + ) + lines = [f"资源决策:{keyword}"] + if checked_text: + lines.append(f"已检查:{checked_text}") + if available_sources: + lines.append("可用源:" + " / ".join(self._clean_text(item.get("label")) for item in available_sources if self._clean_text(item.get("label")))) + if blocked_sources: + blocked_text = ";".join( + f"{self._clean_text(item.get('label'))}:{self._clean_text(item.get('reason'))}" + for item in blocked_sources[:3] + if self._clean_text(item.get("label")) and self._clean_text(item.get("reason")) + ) + if blocked_text: + lines.append("已跳过:" + blocked_text) + if self._clean_text(decision_summary.get("decision_hint")): + lines.append(self._clean_text(decision_summary.get("decision_hint"))) + if self._clean_text(decision_summary.get("decision_reason")): + lines.append("结论:" + self._clean_text(decision_summary.get("decision_reason"))) + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + confirmation_prompt = self._clean_text(decision_summary.get("confirmation_prompt")) + if preferred_command: + lines.append(f"首选:{preferred_command}") + if fallback_command and fallback_command != preferred_command: + lines.append(f"备选:{fallback_command}") + if confirmation_prompt: + lines.append("确认链:" + confirmation_prompt) + return "\n".join(line for line in lines if line).strip() + + def _assistant_smart_source_item_by_choice( + self, + source_states: Dict[str, Any], + *, + source_type: str, + choice: int, + ) -> Dict[str, Any]: + current_state = source_states.get(source_type) if isinstance(source_states, dict) else {} + current_state = current_state if isinstance(current_state, dict) else {} + if source_type == "pansou": + items = current_state.get("items") or [] + elif source_type == "hdhive": + items = current_state.get("resources") or current_state.get("items") or [] + else: + items = current_state.get("items") or [] + for item in items: + if self._safe_int((item or {}).get("index") or (item or {}).get("pick_index"), 0) == choice: + return dict(item or {}) + return {} + + def _assistant_smart_best_plan_response( + self, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的智能搜索结果,请先发送:智能搜索 片名 或 智能计划 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_search_session_not_found", + }), + } + best_candidate = current_state.get("best_candidate") if isinstance(current_state.get("best_candidate"), dict) else {} + decision_summary = current_state.get("decision_summary") if isinstance(current_state.get("decision_summary"), dict) else {} + checked = current_state.get("sources_checked") if isinstance(current_state.get("sources_checked"), list) else [] + source_states = current_state.get("source_states") if isinstance(current_state.get("source_states"), dict) else {} + if not best_candidate: + return { + "success": False, + "message": "智能搜索当前没有可用于生成计划的候选,请先换片名,或调整偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_not_found", + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best_candidate.get("risk_reasons") or []) if self._clean_text(value)] + origin = self._clean_text(current_state.get("origin")).lower() + final_path = ( + target_path + or self._clean_text(current_state.get("target_path")) + or self._clean_text(((source_states.get(source_type) or {}) if isinstance(source_states, dict) else {}).get("target_path")) + ) + + if hard_risks: + head = self._clean_text(decision_summary.get("decision_hint")) or "当前首选存在硬风险。" + tail = ";".join(hard_risks[:2]) + return { + "success": False, + "message": f"{head}\n未生成计划:{tail}", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_blocked", + "best_candidate": best_candidate, + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + result: Dict[str, Any] + if source_type == "pansou": + selected = self._assistant_smart_source_item_by_choice(source_states, source_type="pansou", choice=choice) or dict(best_candidate.get("raw_item") or {}) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return { + "success": False, + "message": "当前盘搜首选缺少可转存链接,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_share_url", + "best_candidate": best_candidate, + }), + } + provider_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": self._clean_text(selected.get("password")), + "path": final_path or provider_path, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "pansou", + "path": final_path or provider_path, + }, + message="智能搜索待确认计划已生成", + score_items=[selected], + extra_data={ + "choice": choice, + "source_type": "pansou", + "target_path": final_path or provider_path, + "selected_item": selected, + }, + ) + elif source_type == "hdhive": + resource = self._assistant_smart_source_item_by_choice(source_states, source_type="hdhive", choice=choice) or dict(best_candidate.get("raw_item") or {}) + slug = self._clean_text(resource.get("slug")) + if not slug: + return { + "success": False, + "message": "当前影巢首选缺少 slug,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_hdhive_slug", + "best_candidate": best_candidate, + }), + } + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path or self._hdhive_default_path, + "resource": resource, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "hdhive", + "path": final_path or self._hdhive_default_path, + }, + message="智能搜索待确认计划已生成", + score_items=[resource], + extra_data={ + "choice": choice, + "source_type": "hdhive", + "target_path": final_path or self._hdhive_default_path, + "selected_resource": resource, + }, + ) + elif source_type == "mp_pt": + preferences = self._assistant_preferences_for_session(session=session) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="smart_resource_plan", + message="智能搜索待确认计划已生成", + ) + else: + return { + "success": False, + "message": "当前首选来源暂不支持统一计划生成,请先查看详情后手动选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_unsupported_source", + "best_candidate": best_candidate, + }), + } + + data = dict(result.get("data") or {}) + adjusted_decision_summary = dict(decision_summary) + if origin == "mp_recommend" and choice and not hard_risks: + adjusted_decision_summary.update({ + "decision_mode": "make_plan", + "decision_reason": "推荐会话已为当前首项生成待确认计划;建议先看详情,再决定是否确认执行。", + "preferred_command": "确认", + "fallback_command": "详情", + "detail_command": "详情", + "detail_short_command": "详情", + "confirmation_prompt": "先看详情;如果确认无误,回复:确认。", + "plan_command": "计划", + "plan_short_command": "计划", + "execute_command": "确认", + "confirm_short_command": "确认", + "compact_commands": ["确认", "详情"], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + }) + data.update({ + "source_type": source_type, + "best_candidate": best_candidate, + "decision_summary": adjusted_decision_summary, + "sources_checked": checked, + "smart_plan_auto_selected": True, + }) + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in adjusted_decision_summary: + data[key] = adjusted_decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + if choice and not data.get("choice"): + data["choice"] = choice + result["data"] = data + message_lines = [ + "已根据智能搜索结果自动选择当前首选并生成待确认计划", + f"首选:{source_type} #{choice} | {score_value}分" + (f" | {title}" if title else ""), + result.get("message") or "", + ] + if risks: + message_lines.append("风险提示:" + ";".join(risks[:2])) + result["message"] = "\n".join(line for line in message_lines if line) + return result + + async def _assistant_execute_prepared_plan_result( + self, + request: Request, + *, + session: str, + cache_key: str, + plan_result: Dict[str, Any], + message_prefix: str = "", + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if not bool(plan_result.get("success")): + return plan_result + plan_data = dict(plan_result.get("data") or {}) + plan_id = self._clean_text(plan_data.get("plan_id")) + if not plan_id: + return { + "success": False, + "message": "当前没有可执行的待确认计划,请先生成计划后再执行。", + "data": self._assistant_response_data(session=session, data={ + "action": "execute_plan", + "ok": False, + "error_code": "plan_not_found", + }), + } + execute_result = await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": plan_id, + "prefer_unexecuted": True, + "stop_on_error": True, + "include_raw_results": False, + "compact": False, + "apikey": self._extract_apikey(request, {}), + }) + ) + execute_data = dict(execute_result.get("data") or {}) + for key in [ + "source_type", + "best_candidate", + "decision_summary", + "sources_checked", + "score_summary", + "choice", + "smart_plan_auto_selected", + ]: + if key in plan_data: + execute_data[key] = plan_data.get(key) + if extra_data: + execute_data.update(dict(extra_data)) + execute_result["data"] = execute_data + if message_prefix: + execute_result["message"] = "\n".join( + line for line in [message_prefix, self._clean_text(execute_result.get("message"))] + if line + ).strip() + return execute_result + + async def _assistant_smart_best_execute_response( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + plan_result = self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + ) + plan_data = dict(plan_result.get("data") or {}) + best_candidate = plan_data.get("best_candidate") if isinstance(plan_data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + source_label = {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get(source_type, source_type or "当前源") + transfer_source_text = { + "pansou": "盘搜搜索转存", + "hdhive": "影巢搜索转存", + "mp_pt": "MP/PT 下载", + }.get(source_type, source_label) + prefix = "已根据智能搜索结果自动生成并执行当前首选计划" + prefix += f"\n执行方式:{transfer_source_text}" + if choice: + prefix += f"\n首选:{source_label} #{choice} | {score_value}分" + (f" | {title}" if title else "") + return await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=prefix, + extra_data={ + "smart_execute_auto_selected": True, + "source_type": source_type, + }, + ) + + async def _assistant_cloud_transfer_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + effective_target_path = self._clean_text(target_path) + prefer_provider = self._clean_text((session_preference_overrides or {}).get("prefer_cloud_provider")).lower() + if not effective_target_path: + if prefer_provider == "quark": + effective_target_path = self._quark_default_path + elif prefer_provider == "115": + effective_target_path = self._p115_default_path + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + search_result=search_result, + origin=origin, + ) + best_candidate = dict((smart_state.get("best_candidate") or {})) + if not search_result.get("success") and not best_candidate.get("source_type"): + return search_result + + preferences = self._assistant_smart_merge_session_preferences( + self._assistant_preferences_for_session(session=session), + session_overrides=session_preference_overrides, + ) + threshold = self._safe_int( + preferences.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + if best_candidate.get("source_type") and score_value >= threshold and not hard_risks: + execute_result = await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=effective_target_path, + ) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if source_type == "hdhive": + raw_item = dict(best_candidate.get("raw_item") or {}) + points_text = self._resource_points_text(raw_item) + if points_text and points_text not in {"免费", "未标积分"}: + execute_result["message"] = "\n".join( + line + for line in [ + f"影巢积分:{points_text},已直接解锁并转存。", + self._clean_text(execute_result.get("message")), + ] + if line + ).strip() + return execute_result + + source_states = smart_state.get("source_states") if isinstance(smart_state.get("source_states"), dict) else {} + pansou_state = dict(source_states.get("pansou") or {}) + hdhive_state = dict(source_states.get("hdhive") or {}) + cloud_result = self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=pansou_state.get("items") or [], + pansou_total=len(pansou_state.get("items") or []), + hdhive_resources=hdhive_state.get("resources") or [], + hdhive_candidate=hdhive_state.get("selected_candidate") or {}, + hdhive_candidates=hdhive_state.get("candidates") or [], + target_path=effective_target_path or self._hdhive_default_path, + lead_note="当前云盘资源质量或风险未达到直接转存阈值,已列出盘搜 + 影巢结果供你手动选择。", + ) + top_choices: List[str] = [] + if best_candidate.get("choice") and best_candidate.get("source_type"): + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get( + self._clean_text(best_candidate.get("source_type")).lower(), + self._clean_text(best_candidate.get("source_type")), + ) + top_choices.append(f"{source_label} #{self._safe_int(best_candidate.get('choice'), 0)}") + for item in (smart_state.get("alternatives") or []): + if not isinstance(item, dict): + continue + source_type = self._clean_text(item.get("source_type")).lower() + if source_type not in {"pansou", "hdhive"}: + continue + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get(source_type, source_type) + label = f"{source_label} #{self._safe_int(item.get('choice'), 0)}" + if label not in top_choices and self._safe_int(item.get("choice"), 0) > 0: + top_choices.append(label) + if len(top_choices) >= 3: + break + if top_choices: + cloud_result["message"] = "\n".join( + [ + self._clean_text(cloud_result.get("message")), + "推荐优先看:" + " / ".join(top_choices[:3]), + ] + ).strip() + if isinstance(cloud_result.get("data"), dict): + cloud_result["data"]["action"] = "cloud_transfer_execute" + cloud_result["data"]["best_candidate"] = best_candidate + return cloud_result + + async def _assistant_smart_resource_search( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + action_name: str = "smart_resource_search", + origin: str = "", + ) -> Dict[str, Any]: + base_preferences = self._assistant_preferences_for_session(session=session) + preferences = self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + decision_profile=decision_profile, + ) + search_order = self._assistant_smart_search_source_order(preferences, source_order=source_order) + available_sources, blocked_sources = self._assistant_smart_source_availability( + preferences, + source_order=source_order, + ) + if not search_order: + return { + "success": False, + "message": "当前偏好把盘搜、影巢、MP/PT 都关闭了,请先保存偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": False, + "error_code": "no_enabled_sources", + "preference_status": self._assistant_preferences_status_brief(session=session), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + }), + } + + checked: List[Dict[str, Any]] = [] + alternatives: List[Dict[str, Any]] = [] + active_state: Dict[str, Any] = {} + best_candidate: Dict[str, Any] = {} + best_source_score = -1 + source_states: Dict[str, Any] = {} + threshold = self._safe_int(preferences.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold) + + for current_source in search_order: + if current_source == "pansou": + search_ok, payload, search_message = self._call_pansou_search(keyword) + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + for idx, item in enumerate(items, start=1): + item["index"] = idx + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=6) + source_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + } + source_states[current_source] = copy.deepcopy(source_state) + summary = self._score_summary(items, limit=5) + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="pansou") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(search_ok), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": search_message or ("盘搜无结果" if not items else ""), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="pansou") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + if current_source == "hdhive": + hdhive_result = await self._assistant_smart_search_hdhive_resources( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + preferences=preferences, + ) + source_state = dict(hdhive_result.get("state") or {}) + items = hdhive_result.get("items") or [] + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + source_state["items"] = items + source_state["resources"] = items + source_states[current_source] = copy.deepcopy(source_state) + summary = hdhive_result.get("score_summary") if isinstance(hdhive_result.get("score_summary"), dict) else {} + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="hdhive") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(hdhive_result.get("ok")), + "result_count": len(items), + "candidate_count": len(hdhive_result.get("candidates") or []), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._clean_text(hdhive_result.get("reason")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="hdhive") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + items = ((mp_result.get("data") or {}).get("items") or []) if isinstance(mp_result.get("data"), dict) else [] + summary = ((mp_result.get("data") or {}).get("score_summary") or {}) if isinstance(mp_result.get("data"), dict) else {} + source_state = copy.deepcopy(self._load_session(cache_key) or {}) + source_states[current_source] = source_state + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_pt_item(best_item) if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(mp_result.get("success")), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._assistant_result_message_head(mp_result.get("message")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_pt_item(item) + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + + alternatives = [item for item in alternatives if isinstance(item, dict) and item.get("choice")] + alternatives.sort( + key=lambda item: ( + self._safe_int(item.get("score"), 0), + 0 if item.get("hard_risk_reasons") else 1, + ), + reverse=True, + ) + unique_alternatives: List[Dict[str, Any]] = [] + seen_keys: set = set() + for item in alternatives: + dedupe_key = ( + self._clean_text(item.get("source_type")).lower(), + self._safe_int(item.get("choice"), 0), + ) + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + unique_alternatives.append(item) + + decision_summary = self._assistant_smart_search_decision_summary( + keyword=keyword, + preferences=preferences, + checked=checked, + best_candidate=best_candidate, + available_sources=available_sources, + blocked_sources=blocked_sources, + decision_profile=decision_profile, + ) + if active_state: + smart_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or active_state.get("target_path") or "", + "active_source": self._clean_text(best_candidate.get("source_type")), + "active_state": copy.deepcopy(active_state), + "source_states": source_states, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "source_order": search_order, + "decision_profile": self._clean_text(decision_profile), + "decision_entry": action_name, + "origin": self._clean_text(origin), + "session_preference_overrides": dict(session_preference_overrides or {}), + } + self._save_session(cache_key, smart_state) + + if action_name == "smart_resource_decision": + message_lines = [ + self._assistant_smart_decision_entry_message( + keyword=keyword, + decision_summary=decision_summary, + available_sources=available_sources, + blocked_sources=blocked_sources, + ) + ] + else: + message_lines = [ + f"智能搜索:{keyword}", + "已检查:" + " -> ".join( + {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get( + self._clean_text(item.get("source_type")).lower(), + self._clean_text(item.get("source_type")), + ) + for item in checked + if self._clean_text(item.get("source_type")) + ), + ] + if best_candidate: + message_lines.append(decision_summary.get("decision_hint") or "") + if best_candidate.get("hard_risk_reasons"): + message_lines.append("降级原因:" + ";".join(best_candidate.get("hard_risk_reasons")[:2])) + elif best_candidate.get("risk_reasons") and self._safe_int(best_candidate.get("score"), 0) < threshold: + message_lines.append("继续原因:" + ";".join(best_candidate.get("risk_reasons")[:2])) + else: + message_lines.append("当前按偏好筛过可用源后,没有找到可推荐结果。") + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + plan_command = self._clean_text(decision_summary.get("plan_command")) + execute_command = self._clean_text(decision_summary.get("execute_command")) + detail_short_command = self._clean_text(decision_summary.get("detail_short_command")) + plan_short_command = self._clean_text(decision_summary.get("plan_short_command")) + confirm_short_command = self._clean_text(decision_summary.get("confirm_short_command")) + if action_name != "smart_resource_decision" and preferred_command: + message_lines.append(f"建议先发:{preferred_command}") + if action_name != "smart_resource_decision" and fallback_command and fallback_command != preferred_command: + message_lines.append(f"备选:{fallback_command}") + if action_name != "smart_resource_decision" and plan_command and plan_command not in {preferred_command, fallback_command}: + message_lines.append(f"如需直接生成待确认计划:{plan_command}") + if action_name != "smart_resource_decision" and execute_command and execute_command not in {preferred_command, fallback_command, plan_command}: + message_lines.append(f"如需直接执行:{execute_command}") + override_summary = self._assistant_smart_session_overrides_summary(session_preference_overrides) + if override_summary: + message_lines.append("会话偏好:" + override_summary) + + score_summary = {} + active_source_type = self._clean_text(best_candidate.get("source_type")).lower() + if active_source_type == "pansou": + active_items = (source_states.get("pansou") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "hdhive": + active_items = (source_states.get("hdhive") or {}).get("resources") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "mp_pt": + active_items = (source_states.get("mp_pt") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + + return { + "success": True, + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": True, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "score_summary": score_summary, + "preference_status": self._assistant_preferences_status_brief(session=session), + "effective_preferences": preferences, + "session_preference_overrides": dict(session_preference_overrides or {}), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "decision_mode": self._clean_text(decision_summary.get("decision_mode")), + "decision_reason": self._clean_text(decision_summary.get("decision_reason")), + "next_actions": [ + command + for command in [ + preferred_command, + fallback_command, + detail_short_command, + plan_command, + plan_short_command, + execute_command, + confirm_short_command, + "继续决策", + "换影巢", + "换盘搜", + "换PT", + "保守一点", + "激进一点", + "只用夸克", + "只用115", + "只走PT", + "不用影巢", + "按保存偏好", + "跟进", + ] + if self._clean_text(command) + ], + }), + } + + async def _assistant_smart_resource_decision( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + return await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + action_name="smart_resource_decision", + origin=origin, + ) + + async def _assistant_smart_resource_decision_adjust( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]], + adjust_action: str, + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的资源决策会话,请先发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_session_not_found", + }), + } + keyword = self._clean_text(current_state.get("keyword")) + if not keyword: + return { + "success": False, + "message": "当前资源决策会话缺少片名,请重新发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_missing_keyword", + }), + } + media_type = self._clean_text(current_state.get("media_type") or "auto") or "auto" + year = self._clean_text(current_state.get("year")) + target_path = self._clean_text(current_state.get("target_path")) + source_order = list(current_state.get("source_order") or []) + session_preference_overrides = dict(current_state.get("session_preference_overrides") or {}) + if not source_order: + base_preferences = self._assistant_preferences_for_session(session=session) + source_order = self._assistant_smart_search_source_order( + self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + ) + ) + decision_profile = self._clean_text(current_state.get("decision_profile")) + if adjust_action == "decision_hdhive": + source_order = ["hdhive", "pansou", "mp_pt"] + elif adjust_action == "decision_pansou": + source_order = ["pansou", "hdhive", "mp_pt"] + elif adjust_action == "decision_mp_pt": + source_order = ["mp_pt", "pansou", "hdhive"] + elif adjust_action in { + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + session_preference_overrides, source_order = self._assistant_smart_apply_session_override( + session_preference_overrides, + adjust_action=adjust_action, + source_order=source_order, + ) + elif adjust_action == "decision_conservative": + decision_profile = "conservative" + elif adjust_action == "decision_aggressive": + decision_profile = "aggressive" + elif adjust_action == "decision_continue": + pass + return await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + ) + + async def _assistant_smart_resource_plan( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + async def _assistant_smart_resource_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + def _assistant_smart_state_after_search( + self, + *, + cache_key: str, + keyword: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + search_result: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + current_state = dict(self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) == "assistant_smart_search": + return current_state + data = (search_result or {}).get("data") if isinstance((search_result or {}).get("data"), dict) else {} + best_candidate = data.get("best_candidate") if isinstance(data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if not source_type: + return current_state + source_states = copy.deepcopy(current_state.get("source_states") or {}) if isinstance(current_state.get("source_states"), dict) else {} + raw_item = dict(best_candidate.get("raw_item") or {}) if isinstance(best_candidate.get("raw_item"), dict) else {} + choice = self._safe_int(best_candidate.get("choice") or raw_item.get("index"), 0) + final_path = self._clean_text(target_path or current_state.get("target_path") or data.get("target_path")) + + if source_type == "pansou" and raw_item: + item = dict(raw_item) + if choice and not item.get("index"): + item["index"] = choice + if not final_path: + share_url = self._clean_text(item.get("url")) + final_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + source_states[source_type] = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": final_path, + "items": [item], + } + elif source_type == "hdhive" and raw_item: + resource = dict(raw_item) + source_states[source_type] = { + "kind": "assistant_hdhive", + "stage": "resource_list", + "keyword": keyword, + "target_path": final_path or self._hdhive_default_path, + "resources": [resource], + "items": [resource], + } + + active_state = copy.deepcopy(source_states.get(source_type) or current_state.get("active_state") or {}) + fallback_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": final_path, + "active_source": source_type, + "active_state": active_state, + "source_states": source_states, + "sources_checked": data.get("sources_checked") if isinstance(data.get("sources_checked"), list) else [], + "best_candidate": best_candidate, + "alternatives": data.get("alternatives") if isinstance(data.get("alternatives"), list) else [], + "decision_summary": data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}, + "available_sources": data.get("available_sources") if isinstance(data.get("available_sources"), list) else [], + "blocked_sources": data.get("blocked_sources") if isinstance(data.get("blocked_sources"), list) else [], + "source_order": source_order or current_state.get("source_order") or [], + "decision_profile": self._clean_text(current_state.get("decision_profile") or ((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_profile")), + "decision_entry": self._clean_text(data.get("action") or current_state.get("decision_entry") or "smart_resource_search"), + "origin": self._clean_text(origin or current_state.get("origin")), + "session_preference_overrides": data.get("session_preference_overrides") if isinstance(data.get("session_preference_overrides"), dict) else dict(current_state.get("session_preference_overrides") or {}), + } + self._save_session(cache_key, fallback_state) + return fallback_state + + @staticmethod + def _assistant_score_warning_text(score: Dict[str, Any], *, limit: int = 3) -> str: + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + if not risks: + return "" + return "风险提示:" + ";".join(risks[:max(1, limit)]) + + def _assistant_mp_download_plan_response( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + workflow: str = "mp_download", + message: str = "PT 下载计划已生成", + ) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会生成下载计划。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”后再选择编号。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + result = self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": "pick_mp_download", + "session": session, + "session_id": cache_key, + "choice": choice, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "choice": choice, + "dry_run": False, + }, + message=message, + score_items=[selected], + extra_data={ + "choice": choice, + "selected": selected, + }, + ) + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + warning = self._assistant_score_warning_text(score) + if warning: + result["message"] = f"{result.get('message')}\n{warning}" + return result + + def _assistant_mp_subscribe_plan_response( + self, + *, + keyword: str, + session: str, + cache_key: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + action_name = "start_mp_subscribe_search" if immediate_search else "start_mp_subscribe" + workflow = "mp_subscribe_and_search" if immediate_search else "mp_subscribe" + label = "订阅并搜索计划已生成" if immediate_search else "订阅计划已生成" + return self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": action_name, + "session": session, + "session_id": cache_key, + "keyword": keyword, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "keyword": keyword, + "dry_run": False, + }, + message=label, + extra_data={ + "keyword": keyword, + "immediate_search": bool(immediate_search), + }, + ) + + def _assistant_mp_download_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "下载任务操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + } + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return { + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_target_not_found", + "target": target, + }), + } + downloader = self._clean_text(downloader) + return self._save_assistant_pick_plan_response( + workflow="mp_download_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + }], + execute_body={ + "workflow": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + "dry_run": False, + }, + message="下载任务操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + def _assistant_mp_subscribe_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "订阅操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + } + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id): + return { + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + } + return self._save_assistant_pick_plan_response( + workflow="mp_subscribe_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + }], + execute_body={ + "workflow": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "dry_run": False, + }, + message="订阅操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + async def _assistant_mp_download(self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any]) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会执行下载。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + "write_effect": "state", + }), + } + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + cache_choice = self._safe_int(selected.get("cache_index"), choice) + message_text = self._ensure_feishu_channel()._execute_media_download(cache_choice, cache_key) + ok = not message_text.startswith(( + "下载资源失败", + "下载提交失败", + "没有可用", + "序号超出范围", + )) and "无法连接" not in message_text and "添加下载任务失败" not in message_text + warning = self._assistant_score_warning_text(score) + if warning: + message_text = f"{message_text}\n{warning}".strip() + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": ok, + "choice": choice, + "cache_choice": cache_choice, + "selected": selected, + "score": score, + "write_effect": "write", + }), + } + + async def _assistant_mp_subscribe( + self, + *, + keyword: str, + session: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + message_text = self._ensure_feishu_channel()._execute_media_subscribe(keyword, immediate_search) + ok = not message_text.startswith("订阅失败") + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": ok, + "keyword": keyword, + "immediate_search": bool(immediate_search), + "write_effect": "write", + }), + } + + def _resolve_mp_download_task_target(self, *, target: str, cache_key: str) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index: + return dict(item) + hash_match = re.search(r"\b[0-9a-fA-F]{40}\b", target_text) + if hash_match: + task_hash = hash_match.group(0) + return {"hash": task_hash, "hash_short": task_hash[:8]} + short_hash_match = re.search(r"\b[0-9a-fA-F]{6,12}\b", target_text) + if short_hash_match: + short_hash = short_hash_match.group(0).lower() + for item in items: + task_hash = self._clean_text(item.get("hash")).lower() + if task_hash.startswith(short_hash): + return dict(item) + return {} + + async def _assistant_mp_download_tasks( + self, + *, + session: str, + cache_key: str, + status: str = "downloading", + title: str = "", + hash_value: str = "", + downloader: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_tasks( + downloader=downloader, + status=status or "downloading", + title=title, + hash_value=hash_value, + limit=max(1, min(30, self._safe_int(limit, 10))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_tasks", + "stage": "download_tasks", + "keyword": title or hash_value or status or "downloading", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_tasks", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": result.get("total", len(items)), + }), + } + + async def _assistant_mp_download_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_download_task_target(target=target, cache_key=cache_key) + task_hash = self._clean_text(selected.get("hash")) + if not task_hash: + return { + "success": False, + "message": "操作下载任务失败:请先发送“下载任务”获取列表,再按编号操作,例如“暂停下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "missing_download_task_hash", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_download_task( + action=control, + hash_value=task_hash, + downloader=downloader or self._clean_text(selected.get("downloader")), + delete_files=delete_files, + ) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_download_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_history( + title=title, + hash_value=hash_value, + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_history", + "stage": "download_history", + "keyword": title or hash_value or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_download_history", + "title": title, + "hash": hash_value, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + async def _assistant_mp_downloaders(self, *, session: str, cache_key: str) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_downloaders() + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_downloaders", + "stage": "downloaders", + "keyword": "downloaders", + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载器查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_downloaders", + "ok": bool(result.get("success")), + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + }), + } + + async def _assistant_mp_sites( + self, + *, + session: str, + cache_key: str, + status: str = "active", + name: str = "", + limit: int = 30, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_sites( + status=status or "active", + name=name, + limit=max(1, min(100, self._safe_int(limit, 30))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_sites", + "stage": "sites", + "keyword": name or status or "active", + "items": items, + "status": result.get("status") or status, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "站点查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_sites", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), 0), + }), + } + + def _resolve_mp_subscribe_target(self, *, target: str, cache_key: str, allow_raw_id: bool = False) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index or self._safe_int(item.get("id"), 0) == index: + return dict(item) + if allow_raw_id: + return {"id": index} + return {} + + async def _assistant_mp_subscribes( + self, + *, + session: str, + cache_key: str, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_subscribes( + status=status or "all", + media_type=media_type or "all", + name=name, + limit=max(1, min(100, self._safe_int(limit, 20))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_subscribes", + "stage": "subscribe_list", + "keyword": name or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribes", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + }), + } + + async def _assistant_mp_subscribe_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id) + subscribe_id = self._safe_int(selected.get("id") or target, 0) + if subscribe_id <= 0: + return { + "success": False, + "message": "操作订阅失败:请先发送“订阅列表”获取列表,再按编号操作,例如“搜索订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "missing_subscribe_id", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_subscribe(action=control, subscribe_id=subscribe_id) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_transfer_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status=status or "all", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_transfer_history", + "stage": "transfer_history", + "keyword": title or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "整理历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_transfer_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_history", + "status": result.get("status") or status, + "title": title, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + def _assistant_mp_detect_stage( + self, + *, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> str: + def has_term(value: Any, terms: List[str]) -> bool: + text = self._clean_text(value).lower() + return bool(text) and any(term in text for term in terms) + + failed_terms = ["失败", "错误", "error", "fail", "未识别", "识别失败", "入库失败", "整理失败"] + success_terms = ["成功", "完成", "已入库", "已整理", "已转移", "imported", "success", "completed"] + running_terms = ["处理中", "进行中", "转移中", "整理中", "入库中", "等待", "队列", "pending", "running"] + + for item in transfer_items: + if has_term(item.get("status_text") or item.get("status"), failed_terms): + return "failed" + for item in download_items: + if has_term(item.get("transfer_status_text") or item.get("transfer_status"), failed_terms): + return "failed" + + if task_items: + return "downloading" + + for item in transfer_items: + status_value = item.get("status_text") or item.get("status") + if has_term(status_value, success_terms): + return "imported" + if has_term(status_value, running_terms): + return "transferring" + + for item in download_items: + transfer_value = item.get("transfer_status_text") or item.get("transfer_status") + if has_term(transfer_value, success_terms): + return "imported" + if has_term(transfer_value, running_terms): + return "transferring" + + if transfer_items: + return "transferring" + if download_items: + return "downloaded" + return "not_found" + + def _assistant_mp_diagnosis_summary( + self, + *, + keyword: str, + hash_value: str, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + force_failed: bool = False, + ) -> Dict[str, Any]: + stage = "failed" if force_failed else self._assistant_mp_detect_stage( + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + matched_count = len(task_items) + len(download_items) + len(transfer_items) + matched = matched_count > 0 + evidence: List[str] = [] + risk_reasons: List[str] = [] + recommended_action = "" + follow_up_hint = "" + confidence = 0.0 + + if task_items: + first_task = task_items[0] + evidence.append( + f"下载任务 {self._clean_text(first_task.get('title')) or '-'} | " + f"{self._clean_text(first_task.get('progress')) or '-'} | " + f"{self._clean_text(first_task.get('state')) or '-'}" + ) + if download_items: + first_download = download_items[0] + evidence.append( + f"下载历史 {self._clean_text(first_download.get('title')) or '-'} | " + f"{self._clean_text(first_download.get('date')) or '-'} | " + f"{self._clean_text(first_download.get('transfer_status_text')) or '-'}" + ) + transfer_status_text = self._clean_text(first_download.get("transfer_status_text")) + if transfer_status_text and any(word in transfer_status_text for word in ["失败", "错误"]): + risk_reasons.append(f"下载历史显示整理状态异常:{transfer_status_text}") + if transfer_items: + first_transfer = transfer_items[0] + evidence.append( + f"整理历史 {self._clean_text(first_transfer.get('title')) or '-'} | " + f"{self._clean_text(first_transfer.get('status_text')) or '-'} | " + f"{self._clean_text(first_transfer.get('date')) or '-'}" + ) + status_text = self._clean_text(first_transfer.get("status_text")) + if status_text and any(word in status_text for word in ["失败", "错误"]): + risk_reasons.append(f"整理历史存在失败记录:{status_text}") + + if stage == "downloading": + confidence = 0.95 + recommended_action = "query_mp_download_tasks" + follow_up_hint = "资源仍在下载阶段,优先查看下载任务进度。" + elif stage == "downloaded": + confidence = 0.85 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已找到下载历史,但还没有明确的入库记录,下一步查看整理/入库历史。" + elif stage == "transferring": + confidence = 0.9 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "资源已经进入整理/入库链路,优先查看最近整理历史。" + elif stage == "imported": + confidence = 0.98 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已经找到成功入库线索,如需确认最终落地可查看最近整理历史。" + elif stage == "failed": + confidence = 0.92 if matched else 0.65 + recommended_action = "query_mp_local_diagnose" + follow_up_hint = "已发现失败线索,建议进入本地诊断汇总失败原因和下一步处理建议。" + elif stage == "not_found": + confidence = 0.95 + recommended_action = "query_mp_download_history" + follow_up_hint = "当前没有找到相关下载任务、下载历史或整理记录。先检查是否真的提交过下载/订阅。" + else: + confidence = 0.5 + recommended_action = "query_mp_lifecycle_status" + follow_up_hint = "状态不足以判断,建议继续查看生命周期聚合结果。" + + if hash_value: + evidence.append(f"Hash 检索:{hash_value}") + elif keyword: + evidence.append(f"关键词检索:{keyword}") + + return { + "status": "ok" if matched else "not_found", + "matched": matched, + "stage": stage, + "confidence": confidence, + "evidence": evidence[:6], + "risk_reasons": risk_reasons[:6], + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + } + + def _assistant_mp_diagnosis_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + hash_value: str, + preferred: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + } + keyword_body = dict(base_body) + if keyword: + keyword_body["keyword"] = keyword + if hash_value: + keyword_body["hash"] = hash_value + action_order = [preferred] + for name in ["query_mp_lifecycle_status", "query_mp_download_history", "query_mp_transfer_history", "query_mp_local_diagnose"]: + if name not in action_order: + action_order.append(name) + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_mp_lifecycle_status": "聚合查看下载任务、下载历史和整理/入库历史", + "query_mp_download_history": "查看下载历史,并确认是否已经提交过下载", + "query_mp_transfer_history": "查看整理/入库历史,确认是否已经落库或失败", + "query_mp_local_diagnose": "汇总本地/PT 链路的失败线索与处理建议", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**keyword_body, "name": name, "compact": True}, + ) + for name in action_order[:4] + ] + return action_order[:4], templates + + def _assistant_followup_command(self, action_name: str, *, keyword: str = "", hash_value: str = "") -> str: + target = self._clean_text(keyword or hash_value) + mapping = { + "query_execution_followup": "后续", + "query_mp_download_tasks": "下载任务", + "query_mp_subscribes": "订阅列表", + } + if action_name in mapping: + return mapping[action_name] + if action_name == "query_mp_ingest_status": + return f"入库 {target}".strip() + if action_name == "query_mp_lifecycle_status": + return f"状态 {target}".strip() + if action_name == "query_mp_download_history": + return f"记录 {target}".strip() + if action_name == "query_mp_transfer_history": + return f"入库记录 {target}".strip() + if action_name == "query_mp_local_diagnose": + return f"诊断 {target}".strip() + if action_name == "query_ai_failed_samples": + return f"失败样本 {target}".strip() if target else "失败样本" + if action_name == "query_ai_sample_worklist": + return f"工作清单 {target}".strip() if target else "工作清单" + if action_name == "query_ai_sample_insights": + return f"样本洞察 {target}".strip() if target else "样本洞察" + if action_name == "start_mp_media_search": + return f"MP搜索 {target}".strip() + return "" + + def _assistant_template_command( + self, + template: Dict[str, Any], + *, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> str: + if not isinstance(template, dict): + return "" + body = dict(template.get("action_body") or template.get("body") or {}) + action_name = self._clean_text(body.get("name")) or self._clean_text(template.get("name")) + target_text = self._clean_text(target or body.get("keyword") or body.get("hash") or body.get("target")) + command = self._assistant_followup_command( + action_name, + keyword=self._clean_text(keyword or body.get("keyword") or target_text), + hash_value=self._clean_text(hash_value or body.get("hash")), + ) + if command: + return command + if action_name == "query_mp_recent_activity": + return "最近" + if action_name == "execute_session_latest_plan": + return "执行计划" + if action_name == "mp_download_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停下载", + "resume": "恢复下载", + "delete": "删除下载", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "下载任务" + if action_name == "mp_subscribe_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停订阅", + "resume": "恢复订阅", + "delete": "删除订阅", + "search": "搜索订阅", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "订阅列表" + return "" + + def _assistant_error_summary( + self, + *, + error_code: str, + recommended_action: str = "", + message_head: str = "", + next_actions: Optional[List[Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> Dict[str, Any]: + code = self._clean_text(error_code) + if not code: + return {} + label_map = { + "latest_plan_not_executed": "当前会话还有待执行计划", + "plan_not_executed": "指定计划还没有执行", + "executed_plan_not_found": "当前没有可追踪的已执行计划", + "followup_not_available": "当前计划没有自动追踪动作", + "invalid_download_control_args": "下载任务参数不完整", + "download_target_not_found": "没有匹配到可操作的下载任务", + "invalid_subscribe_control_args": "订阅操作参数不完整", + "subscribe_target_not_found": "没有匹配到可操作的订阅", + } + hint_map = { + "latest_plan_not_executed": "先执行计划,再继续查看后续状态。", + "plan_not_executed": "先执行这条计划,再继续查看执行后追踪。", + "executed_plan_not_found": "先执行下载、订阅或转存计划,再查看后续。", + "followup_not_available": "当前计划只能改查状态、记录或最近活动。", + "invalid_download_control_args": "先发“下载任务”获取列表,再按编号暂停、恢复或删除。", + "download_target_not_found": "先发“下载任务”刷新列表,再按编号操作。", + "invalid_subscribe_control_args": "先发“订阅列表”获取列表,再按编号暂停、恢复或删除。", + "subscribe_target_not_found": "先发“订阅列表”刷新列表,再按编号操作。", + } + command_candidates: List[str] = [] + fallback_map = { + "latest_plan_not_executed": ["执行计划", "后续"], + "plan_not_executed": ["执行计划", "后续"], + "executed_plan_not_found": ["最近"], + "followup_not_available": [f"状态 {target}".strip(), f"记录 {target}".strip(), "最近"], + "invalid_download_control_args": ["下载任务"], + "download_target_not_found": ["下载任务"], + "invalid_subscribe_control_args": ["订阅列表"], + "subscribe_target_not_found": ["订阅列表"], + } + for command in fallback_map.get(code, []): + command_text = self._clean_text(command) + if command_text and command_text not in command_candidates: + command_candidates.append(command_text) + synthetic_templates: List[Dict[str, Any]] = [] + action_name = self._clean_text(recommended_action) + if action_name: + synthetic_templates.append({ + "name": action_name, + "action_body": { + "name": action_name, + "keyword": keyword, + "hash": hash_value, + "target": target, + }, + }) + synthetic_templates.extend( + item for item in (action_templates or []) if isinstance(item, dict) + ) + for item in synthetic_templates: + command = self._assistant_template_command( + item, + keyword=keyword, + hash_value=hash_value, + target=target, + ) + if command and command not in command_candidates: + command_candidates.append(command) + for name in [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)]: + command = self._assistant_followup_command(name, keyword=keyword or target, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + preferred_requires_confirmation = code in {"latest_plan_not_executed", "plan_not_executed"} + return { + "error_code": code, + "label": label_map.get(code, message_head or code), + "decision_hint": hint_map.get(code, message_head or "当前请求未完成,请先按建议补充上下文。"), + "command_policy": "confirm_then_resume" if preferred_requires_confirmation else "safe_read_recovery", + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": not preferred_requires_confirmation and bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + } + + def _format_quark_transfer_failure(self, detail: str = "", target_path: str = "") -> str: + text = self._clean_text(detail) + target = self._clean_text(target_path) or self._quark_default_path or "/飞书" + if "41031" in text or "分享者用户封禁链接查看受限" in text: + return ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + "建议改选同一列表里的其他夸克资源。" + ) + if "未获取到 stoken" in text: + try: + cookie_ok, _cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok = False + if cookie_ok: + return ( + "夸克转存失败:当前夸克登录态正常,但这条分享无法获取 stoken," + "更可能是分享链接失效、提取码错误或分享已受限。建议改选同一列表里的其他夸克资源。" + ) + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if "分享链接为空" in text or "无权查看内容" in text: + return ( + "夸克转存失败:当前分享内容为空或账号无权查看," + "更可能是分享链接失效、内容被取消分享或分享受限。建议改选其他资源。" + ) + if "require login [guest]" in text or "未配置夸克 Cookie" in text: + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text or text == f"无法转存到 {target}": + try: + cookie_ok, cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok, cookie_message = True, "" + cookie_text = self._clean_text(cookie_message) + if (not cookie_ok) and ( + "require login [guest]" in cookie_text + or "未配置夸克 Cookie" in cookie_text + or "cookie" in cookie_text.lower() + or "login" in cookie_text.lower() + ): + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text: + return ( + f"夸克转存失败:无法转存到 {target}。" + "当前原因未明,请先不要自行推断为路径问题,可稍后重试或先检查夸克登录态。" + ) + return f"夸克转存失败:{text}" + + @staticmethod + def _is_quark_share_restricted_message(detail: str) -> bool: + text = str(detail or "").strip() + if not text: + return False + return "41031" in text or "分享者用户封禁链接查看受限" in text + + async def _assistant_retry_pansou_quark_candidates( + self, + request: Request, + *, + items: List[Dict[str, Any]], + selected_index: int, + selected_url: str, + session: str, + target_path: str, + apikey: str, + ) -> Optional[Dict[str, Any]]: + candidates: List[Tuple[int, Dict[str, Any]]] = [] + for offset, raw_item in enumerate(items[selected_index:], start=selected_index + 1): + item = dict(raw_item or {}) + share_url = self._clean_text(item.get("url")) + if not share_url or not self._is_quark_url(share_url): + continue + if share_url == selected_url: + continue + candidates.append((offset, item)) + if len(candidates) >= 3: + break + + if not candidates: + return None + + for alt_index, alt_item in candidates: + alt_url = self._clean_text(alt_item.get("url")) + alt_code = self._clean_text(alt_item.get("password")) + alt_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": alt_url, + "access_code": alt_code, + "path": target_path, + "trigger": "Agent影视助手 盘搜夸克候选自动切换", + "apikey": apikey, + }) + ) + if alt_result.get("success"): + message = ( + f"原夸克链接已受限,已自动切换到同列表夸克候选 #{alt_index}。\n" + f"{str(alt_result.get('message') or '夸克转存成功')}" + ) + data = dict(alt_result.get("data") or {}) + data.update( + { + "provider": "quark", + "selected_choice": alt_index, + "auto_switched_from_choice": selected_index, + "auto_switched": True, + "selected_item": alt_item, + } + ) + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data=data), + } + + hint_choices = "、".join(f"#{idx}" for idx, _ in candidates) + return { + "success": False, + "message": ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + f"可改试同列表夸克候选 {hint_choices}。" + ), + "data": self._assistant_response_data( + session=session, + data={ + "provider": "quark", + "auto_switched": False, + "fallback_choices": [idx for idx, _ in candidates], + }, + ), + } + + def _assistant_followup_summary( + self, + *, + category: str, + stage: str, + recommended_action: str, + follow_up_hint: str, + next_actions: Optional[List[str]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + ) -> Dict[str, Any]: + action_names = [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)] + command_candidates: List[str] = [] + for name in action_names: + command = self._assistant_followup_command(name, keyword=keyword, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + label_map = { + "mp_download": "下载后追踪", + "mp_subscribe": "订阅后追踪", + "cloud_write": "云盘落库追踪", + "mp_diagnosis": "本地/PT 状态追踪", + "ai_reingest": "AI 二次识别重放追踪", + } + summary = { + "category": category, + "stage": self._clean_text(stage), + "label": label_map.get(category, category or "后续追踪"), + "preferred_action": self._clean_text(recommended_action), + "decision_hint": self._clean_text(follow_up_hint), + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + "next_actions": action_names[:4], + "action_templates_count": len([item for item in (action_templates or []) if isinstance(item, dict)]), + } + if category == "ai_reingest" and keyword: + summary.update({ + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "recommended_commands": ["诊断", "入库状态", "工作清单"], + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + }) + return summary + + @staticmethod + def _format_followup_summary_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + if not data: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(data.get("label")) + stage = AgentResourceOfficer._clean_text(data.get("stage")) + hint = AgentResourceOfficer._clean_text(data.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (data.get("compact_commands") or data.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"后续追踪:{label}{f' | {stage}' if stage else ''}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("可直接继续:" + " / ".join(commands)) + return lines + + @staticmethod + def _assistant_local_api_base_urls() -> List[str]: + return [ + "http://127.0.0.1:3001", + "http://127.0.0.1:3000", + "http://host.docker.internal:3000", + ] + + def _assistant_plugin_api_request( + self, + *, + plugin_name: str, + path: str, + query: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + method: str = "GET", + timeout: int = 15, + ) -> Dict[str, Any]: + token = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not token: + return {"success": False, "message": "服务端未配置 API Token"} + safe_path = "/" + self._clean_text(path).lstrip("/") + query_payload = dict(query or {}) + query_payload["apikey"] = token + last_error = "" + payload_bytes: Optional[bytes] = None + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + payload_bytes = json.dumps(dict(body or {}), ensure_ascii=False).encode("utf-8") + for base_url in self._assistant_local_api_base_urls(): + try: + url = f"{base_url}/api/v1/plugin/{plugin_name}{safe_path}?{urlencode(query_payload)}" + request = UrlRequest( + url=url, + data=payload_bytes, + headers=headers, + method=(method or "GET").upper(), + ) + with urlopen(request, timeout=max(5, self._safe_int(timeout, 15))) as response: + raw = response.read().decode("utf-8", errors="ignore") + payload = json.loads(raw or "{}") + if isinstance(payload, dict): + return payload + last_error = f"插件 {plugin_name} 返回了非字典 JSON" + except Exception as exc: + last_error = str(exc) + return { + "success": False, + "message": f"调用 {plugin_name}{safe_path} 失败:{last_error or '未知错误'}", + } + + @staticmethod + def _assistant_ai_recognizer_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + return running_plugins.get("AIRecognizerEnhancer") + + def _assistant_ai_failed_samples_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + return { + "success": True, + "data": { + "count": len(samples), + "samples": samples, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/failed_samples", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_worklist_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_summarize_sample"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + worklist = [plugin._summarize_sample(sample) for sample in samples] + return { + "success": True, + "data": { + "count": len(worklist), + "samples": worklist, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 工作清单失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_worklist", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_insights_payload(self, *, limit: int, top: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_build_sample_insights"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 200)))) + insights = plugin._build_sample_insights(samples, top=max(1, min(top, 20))) + return { + "success": True, + "data": insights, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 样本洞察失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_insights", + query={"limit": max(1, min(limit, 200)), "top": max(1, min(top, 20))}, + method="GET", + ) + + def _assistant_ai_replay_failed_sample_payload( + self, + *, + sample_index: int, + remove_if_resolved: bool, + ) -> Dict[str, Any]: + body = { + "sample_index": max(0, self._safe_int(sample_index, 0)), + "remove_if_resolved": bool(remove_if_resolved), + } + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_replay_failed_sample"): + try: + result = plugin._replay_failed_sample(body) + if isinstance(result, dict): + return result + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 重放 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/replay_failed_sample", + method="POST", + body=body, + ) + + def _assistant_ai_sample_by_index(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + for item in items: + current = item if isinstance(item, dict) else {} + if self._safe_int(current.get("sample_index"), 0) == desired: + return dict(current) + if self._safe_int(current.get("index"), 0) == desired: + return dict(current) + return {} + + @staticmethod + def _assistant_quarkdisk_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + plugin = running_plugins.get("QuarkDisk") or running_plugins.get("quarkdisk") + if plugin is not None: + return plugin + for candidate in running_plugins.values(): + if candidate is None: + continue + if candidate.__class__.__name__ == "QuarkDisk": + return candidate + if getattr(candidate, "plugin_name", "") == "夸克网盘存储": + return candidate + return None + + def _assistant_quarkdisk_clear_directory(self, path: str) -> Optional[Tuple[bool, Dict[str, Any], str]]: + plugin = self._assistant_quarkdisk_plugin() + if plugin is None or app_schemas is None: + return None + try: + storage_name = self._clean_text(getattr(plugin, "_disk_name", "")) or "夸克网盘" + clear_cache = getattr(plugin, "clear_cache", None) + + def _safe_clear_cache() -> None: + if callable(clear_cache): + try: + clear_cache() + except Exception: + pass + + def _resolve_folder() -> Any: + resolved = None + if hasattr(plugin, "get_folder"): + resolved = plugin.get_folder(storage=storage_name, path=Path(path)) + if resolved is None and hasattr(plugin, "get_file_item"): + resolved = plugin.get_file_item(storage=storage_name, path=Path(path)) + return resolved + + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 未定位到目录,改走直连夸克接口确认" + + items = [] + if hasattr(plugin, "list_files"): + items = plugin.list_files(folder, recursion=False) or [] + if not items: + _safe_clear_cache() + folder = _resolve_folder() or folder + items = plugin.list_files(folder, recursion=False) or [] + files = [item for item in items if getattr(item, "type", "") != "dir"] + folders = [item for item in items if getattr(item, "type", "") == "dir"] + if not items: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 返回空列表,改走直连夸克接口确认" + + removed_count = 0 + failed_items: List[str] = [] + for item in items: + ok = plugin.delete_file(item) if hasattr(plugin, "delete_file") else None + if ok: + removed_count += 1 + continue + item_path = self._clean_text(getattr(item, "path", "")) or self._clean_text(getattr(item, "name", "")) + if item_path: + failed_items.append(item_path) + result = { + "target_path": path, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self._clean_text(getattr(item, "name", "")) for item in items[:20] if self._clean_text(getattr(item, "name", ""))], + "failed_items": failed_items[:20], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + } + if failed_items: + return False, result, f"夸克网盘存储删除失败:{len(failed_items)} 项未删除" + return True, result, "success" + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 调用 quarkdisk 清空目录失败,改走直连夸克接口:{exc}") + return None + + def _assistant_ai_sample_reference(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + cached = self._assistant_ai_sample_by_index(cache_key=cache_key, sample_index=desired) + if cached: + return cached + for payload in ( + self._assistant_ai_failed_samples_payload(limit=1000), + self._assistant_ai_sample_worklist_payload(limit=1000), + ): + rows = [] + if isinstance((payload or {}).get("data"), dict): + rows = (payload.get("data") or {}).get("samples") or [] + if not isinstance(rows, list): + continue + for item in rows: + current = dict(item or {}) + if self._safe_int(current.get("sample_index") or current.get("index"), 0) == desired: + return current + return {} + + def _assistant_ai_replay_target_title(self, *, sample: Dict[str, Any], target: Dict[str, Any]) -> str: + current_sample = dict(sample or {}) + current_target = dict(target or {}) + return ( + self._clean_text(current_target.get("name")) + or self._clean_text(current_sample.get("verified_title")) + or self._clean_text(current_sample.get("guess_name")) + or self._clean_text(current_sample.get("title")) + or self._clean_text(current_sample.get("path")) + ) + + def _assistant_ai_sample_matches(self, sample: Dict[str, Any], keyword: str) -> bool: + needle = self._clean_text(keyword).lower() + if not needle: + return True + target = sample.get("inferred_target") if isinstance(sample.get("inferred_target"), dict) else {} + fields = [ + sample.get("title"), + sample.get("path"), + sample.get("reason"), + sample.get("guess_name"), + sample.get("verified_title"), + target.get("name"), + ] + for value in fields: + text = self._clean_text(value).lower() + if text and needle in text: + return True + return False + + def _assistant_ai_filtered_rows(self, rows: List[Dict[str, Any]], keyword: str) -> List[Dict[str, Any]]: + if not keyword: + return [dict(item or {}) for item in rows] + return [ + dict(item or {}) + for item in rows + if self._assistant_ai_sample_matches(dict(item or {}), keyword) + ] + + def _assistant_ai_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + "keyword": keyword, + "compact": True, + } + action_order = [ + "query_ai_sample_worklist", + "query_ai_sample_insights", + "query_ai_failed_samples", + "query_mp_local_diagnose", + ] + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_ai_sample_worklist": "查看 AI 识别失败样本工作清单,适合先挑目标", + "query_ai_sample_insights": "查看 AI 失败样本洞察,适合看重复样本和优先处理项", + "query_ai_failed_samples": "查看 AI 原始失败样本列表,适合核对标题、路径和失败原因", + "query_mp_local_diagnose": "回到本地/PT 诊断链,继续看入库失败阶段和证据", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": name}, + ) + for name in action_order + ] + return action_order, templates + + def _assistant_ai_sample_brief_lines( + self, + rows: List[Dict[str, Any]], + *, + limit: int = 5, + ) -> List[str]: + lines: List[str] = [] + for item in rows[: max(1, min(limit, 10))]: + label = ( + self._clean_text( + ((item.get("inferred_target") or {}).get("name")) + if isinstance(item.get("inferred_target"), dict) + else "" + ) + or self._clean_text(item.get("verified_title")) + or self._clean_text(item.get("guess_name")) + or self._clean_text(item.get("title")) + or "未命名样本" + ) + confidence = round(self._safe_float(item.get("guess_confidence"), 0.0), 2) + reason = self._clean_text(item.get("reason")) or "-" + lines.append( + f"{self._safe_int(item.get('sample_index'), 0)}. {label} | 置信度 {confidence} | {reason}" + ) + return lines + + def _assistant_mp_recent_activity_summary( + self, + *, + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> Dict[str, Any]: + evidence: List[str] = [] + for item in download_items[:3]: + evidence.append( + f"下载 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('transfer_status_text')) or '-'}" + ) + for item in transfer_items[:3]: + evidence.append( + f"入库 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('status_text')) or '-'}" + ) + return { + "status": "ok" if (download_items or transfer_items) else "not_found", + "matched": bool(download_items or transfer_items), + "stage": "unknown", + "confidence": 0.8 if (download_items or transfer_items) else 0.95, + "evidence": evidence[:6], + "risk_reasons": [], + "recommended_action": "query_mp_lifecycle_status", + "follow_up_hint": "先从最近活动里挑目标,再继续看单个资源的生命周期或本地诊断。", + } + + async def _assistant_mp_lifecycle_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(10, self._safe_int(limit, 5))) + channel = self._ensure_feishu_channel() + task_result = channel._query_download_tasks( + status="all", + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + download_result = channel._query_download_history( + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + transfer_result = channel._query_transfer_history( + title=title, + status="all", + limit=safe_limit, + ) + task_items = task_result.get("items") if isinstance(task_result.get("items"), list) else [] + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + keyword = title or hash_value or "全部" + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value=hash_value, + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value=hash_value, + preferred=self._clean_text(diagnosis_summary.get("recommended_action")) or "query_mp_download_history", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword=title, + hash_value=hash_value, + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = [f"MP 生命周期追踪:{keyword}"] + lines.append( + f"结论:{diagnosis_summary.get('stage') or 'unknown'} | " + f"置信度 {int(float(diagnosis_summary.get('confidence') or 0) * 100)}%" + ) + lines.append(f"活动下载任务:{len(task_items)} 条;下载历史:{download_result.get('total', len(download_items))} 条;整理历史:{transfer_result.get('total', len(transfer_items))} 条") + if task_items: + lines.append("下载任务:") + for item in task_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} | {item.get('progress') or '-'} | {item.get('state') or '-'} | Hash:{item.get('hash_short') or '-'}") + if download_items: + lines.append("下载历史:") + for item in download_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('date') or '-'} | {item.get('transfer_status_text') or '-'} | Hash:{item.get('download_hash_short') or '-'}") + if transfer_items: + lines.append("整理/入库历史:") + for item in transfer_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('status_text') or '-'} | {item.get('date') or '-'}") + if not task_items and not download_items and not transfer_items: + lines.append("未找到相关任务、下载历史或整理历史。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + lines.append("说明:这是只读聚合查询,用于判断资源处于搜索后、下载中、已下载还是已落库阶段。") + self._save_session(cache_key, { + "kind": "assistant_mp_lifecycle_status", + "stage": "lifecycle_status", + "keyword": keyword, + "items": { + "download_tasks": task_items, + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + ok = bool(task_result.get("success")) and bool(download_result.get("success")) and bool(transfer_result.get("success")) + return { + "success": ok, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_lifecycle_status", + "ok": ok, + "source_type": "moviepilot_lifecycle_status", + "title": title, + "hash": hash_value, + "download_tasks": { + "ok": bool(task_result.get("success")), + "total": self._safe_int(task_result.get("total"), len(task_items)), + "items": task_items, + }, + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_ingest_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + lines = [ + f"本地/PT 入库状态:{self._clean_text(title or hash_value) or '全部'}", + f"当前阶段:{stage}", + ] + if diagnosis_summary.get("evidence"): + lines.append("证据:") + for item in (diagnosis_summary.get("evidence") or [])[:5]: + lines.append(f"- {item}") + if diagnosis_summary.get("risk_reasons"): + lines.append("风险:") + for item in (diagnosis_summary.get("risk_reasons") or [])[:5]: + lines.append(f"- {item}") + lines.extend(self._format_followup_summary_lines(diagnosis_summary.get("followup_summary"))) + lifecycle_data["action"] = "mp_ingest_status" + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(lines), + "data": lifecycle_data, + } + + async def _assistant_mp_ingest_failures( + self, + *, + session: str, + cache_key: str, + title: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status="failed", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value="", + task_items=[], + download_items=[], + transfer_items=items, + force_failed=bool(items), + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value="", + preferred="query_mp_local_diagnose", + ) + lines = [f"本地/PT 失败线索:{self._clean_text(title) or '最近失败'}"] + if items: + for item in items[: min(len(items), 6)]: + lines.append( + f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | " + f"{item.get('status_text') or '-'} | {item.get('date') or '-'}" + ) + else: + lines.append("未找到匹配的整理/入库失败记录。") + self._save_session(cache_key, { + "kind": "assistant_mp_ingest_failures", + "stage": "ingest_failures", + "keyword": title or "failed", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_ingest_failures", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_failures", + "title": title, + "status": "failed", + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + "diagnosis_summary": diagnosis_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_ai_failed_samples( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_failed_samples_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="工作清单", + ) + lines = [f"AI 失败样本:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 失败样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_failed_samples", + "stage": "ai_failed_samples", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_failed_samples", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先看工作清单或样本洞察,再决定是否进入二次识别重放。", + }), + } + + async def _assistant_ai_sample_worklist( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_sample_worklist_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="样本洞察", + ) + lines = [f"AI 工作清单:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 工作清单样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_worklist", + "stage": "ai_sample_worklist", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_worklist", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_insights", + "follow_up_hint": "先看样本洞察确认哪些失败样本最值得重放或生成识别词。", + }), + } + + async def _assistant_ai_sample_insights( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 20, + top: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(100, self._safe_int(limit, 20))) + safe_top = max(1, min(10, self._safe_int(top, 5))) + result = self._assistant_ai_sample_insights_payload(limit=safe_limit, top=safe_top) + insights = dict((result.get("data") or {})) if isinstance(result.get("data"), dict) else {} + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + priority_samples = insights.get("priority_samples") if isinstance(insights.get("priority_samples"), list) else [] + decision_summary = self._assistant_ai_reingest_decision_summary( + items=priority_samples, + fallback_command="工作清单", + ) + lines = [f"AI 样本洞察:{self._clean_text(keyword) or '全局'}"] + total_count = self._safe_int(insights.get("total_count"), 0) + if total_count > 0: + lines.append(f"样本总数:{total_count}") + reason_counts = insights.get("reason_counts") if isinstance(insights.get("reason_counts"), list) else [] + if reason_counts: + lines.append("主要失败原因:") + for item in reason_counts[:safe_top]: + lines.append(f"- {self._clean_text(item.get('reason')) or '-'}:{self._safe_int(item.get('count'), 0)}") + repeated_groups = insights.get("repeated_groups") if isinstance(insights.get("repeated_groups"), list) else [] + if repeated_groups: + lines.append("重复出现的样本组:") + for item in repeated_groups[:safe_top]: + lines.append(f"- {self._clean_text(item.get('title')) or '-'}:{self._safe_int(item.get('count'), 0)}") + if priority_samples: + lines.append("优先处理样本:") + lines.extend(self._assistant_ai_sample_brief_lines(priority_samples, limit=safe_top)) + else: + lines.append("当前没有可分析的 AI 失败样本洞察。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_insights", + "stage": "ai_sample_insights", + "keyword": keyword or "all", + "items": insights, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_insights", + "ok": bool(result.get("success")), + "keyword": keyword, + "insights": insights, + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先挑优先样本,再确认是否进入 AI 二次识别重放。", + }), + } + + def _assistant_ai_replay_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + next_actions: List[str] = [ + "query_ai_sample_worklist", + "query_ai_failed_samples", + "query_ai_sample_insights", + ] + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + base_body = { + "session": session, + "session_id": session_id, + "compact": True, + } + templates: List[Dict[str, Any]] = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="继续查看这次二次识别相关的本地/PT 入库诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_local_diagnose", "keyword": keyword, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_ingest_status", "keyword": keyword, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续挑需要重放的失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,核对标题、路径和失败原因", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,判断失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + return next_actions, templates + + def _assistant_ai_reingest_decision_summary( + self, + *, + items: List[Dict[str, Any]], + fallback_command: str, + ) -> Dict[str, Any]: + fallback = self._clean_text(fallback_command) + first_index = 0 + for item in items or []: + first_index = self._safe_int((item or {}).get("index"), 0) + if first_index > 0: + break + if first_index <= 0: + return { + "decision_mode": "show_detail", + "decision_reason": "当前没有可直接重放的 AI 失败样本。", + "preferred_command": fallback, + "fallback_command": "", + "compact_commands": [fallback] if fallback else [], + "recommended_agent_behavior": "show_only" if fallback else "stop", + "auto_run_command": "", + "confirm_command": "", + "display_command": fallback, + } + replay_command = f"重放 {first_index}" + return { + "decision_mode": "make_plan", + "decision_reason": f"先为样本 #{first_index} 生成二次识别重放计划,再确认是否实际重放。", + "preferred_command": replay_command, + "fallback_command": fallback, + "compact_commands": [item for item in [replay_command, fallback] if item], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": replay_command, + "confirm_command": "确认", + "display_command": replay_command, + } + + def _assistant_ai_replay_sample_plan_response( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + reference = self._assistant_ai_sample_reference(cache_key=cache_key, sample_index=desired) + if not reference: + return { + "success": False, + "message": "未找到对应的 AI 失败样本。请先发送“工作清单”或“失败样本”查看当前样本编号。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "sample_not_found", + "sample_index": desired, + }), + } + title = self._assistant_ai_replay_target_title( + sample=reference, + target=(reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}), + ) + return self._save_assistant_pick_plan_response( + workflow="ai_replay_failed_sample", + session=session, + session_id=cache_key, + actions=[{ + "name": "replay_ai_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + }], + execute_body={ + "workflow": "ai_replay_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "dry_run": False, + }, + message="AI 二次识别重放计划已生成", + extra_data={ + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "source_sample": reference, + "target": reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}, + "keyword": title, + "decision_summary": { + "decision_mode": "execute_now", + "decision_reason": ( + "AI 二次识别重放计划已生成,确认后才会实际重放并尝试重新识别。" + if bool(remove_if_resolved) + else "AI 二次识别重放计划已生成,确认后会实际重放,但保留原失败样本。" + ), + "preferred_command": "确认", + "fallback_command": "工作清单", + "compact_commands": ["确认", "工作清单"], + "command_policy": "confirm_then_resume", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "wait_user_confirmation", + "auto_run_command": "", + "confirm_command": "确认", + "display_command": "确认", + }, + }, + ) + + def _assistant_ai_replay_execution_decision_summary( + self, + *, + ok: bool, + resolved: bool, + has_title: bool, + ) -> Dict[str, Any]: + if ok and resolved and has_title: + return { + "decision_mode": "show_detail", + "decision_reason": "这次二次识别已命中目标,下一步先看本地诊断确认是否已消除失败,再决定是否继续整理。", + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + } + if ok: + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放已完成,但暂未命中目标。先回看工作清单和样本洞察,再决定是否继续重放或补识别词。", + "preferred_command": "工作清单", + "fallback_command": "样本洞察", + "compact_commands": ["工作清单", "样本洞察"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "工作清单", + "confirm_command": "", + "display_command": "工作清单", + } + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放没有成功执行。先回看工作清单或失败样本,确认当前还能处理哪些样本。", + "preferred_command": "工作清单", + "fallback_command": "失败样本", + "compact_commands": ["工作清单", "失败样本"], + "command_policy": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + "auto_run_command": "", + "confirm_command": "", + "display_command": "工作清单", + } + + async def _assistant_ai_replay_failed_sample( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + result = self._assistant_ai_replay_failed_sample_payload( + sample_index=desired, + remove_if_resolved=bool(remove_if_resolved), + ) + ok = bool(result.get("success")) + payload = dict(result.get("data") or {}) if isinstance(result.get("data"), dict) else {} + source_sample = dict(payload.get("source_sample") or {}) if isinstance(payload.get("source_sample"), dict) else {} + target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} + title = self._assistant_ai_replay_target_title(sample=source_sample, target=target) + next_actions, action_templates = self._assistant_ai_replay_followups( + session=session, + session_id=cache_key, + keyword=title, + ) + if ok: + self._save_session(cache_key, { + "kind": "assistant_ai_replay", + "stage": "ai_replay_failed_sample", + "keyword": title or f"sample-{desired}", + "items": [source_sample] if source_sample else [], + "target_path": "", + "sample_index": desired, + "replay_result": { + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + }, + }) + lines = [f"AI 二次识别重放:样本 #{desired}"] + if title: + lines.append(f"目标:{title}") + if ok: + lines.append("结果:" + ("已命中目标并完成重放" if bool(payload.get("resolved")) else "已完成重放,但暂未命中目标")) + if payload.get("resolved_by_identifiers"): + lines.append("来源:当前识别词已直接命中目标。") + elif payload.get("resolved_by_recognizer"): + lines.append("来源:识别器重跑后命中了目标。") + if "sample_removed" in payload: + lines.append("样本移除:" + ("已移除" if payload.get("sample_removed") else "未移除")) + else: + lines.append(self._clean_text(result.get("message")) or "重放失败") + resolved = bool(payload.get("resolved")) + recommended_action = "query_mp_local_diagnose" if ok and resolved and title else "query_ai_sample_worklist" + follow_up_hint = ( + "先回到本地诊断或入库状态,确认这次重放是否已经消除失败。" + if ok and resolved and title + else "先看工作清单或失败样本,确认是否还有可重放样本。" + ) + decision_summary = self._assistant_ai_replay_execution_decision_summary( + ok=ok, + resolved=resolved, + has_title=bool(title), + ) + return { + "success": ok, + "message": "\n".join(line for line in lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": ok, + "sample_index": desired, + "source_sample": source_sample, + "target": target, + "identifier_preview": payload.get("identifier_preview") if isinstance(payload.get("identifier_preview"), dict) else {}, + "recognize_result": payload.get("recognize_result") if isinstance(payload.get("recognize_result"), dict) else {}, + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + "sample_removal_result": payload.get("sample_removal_result") if isinstance(payload.get("sample_removal_result"), dict) else {}, + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + "decision_summary": decision_summary, + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_recent_activity( + self, + *, + session: str, + cache_key: str, + limit: int = 10, + download_only: bool = False, + transfer_only: bool = False, + ) -> Dict[str, Any]: + safe_limit = max(1, min(20, self._safe_int(limit, 10))) + channel = self._ensure_feishu_channel() + download_result = {"success": True, "items": [], "total": 0} + transfer_result = {"success": True, "items": [], "total": 0} + if not transfer_only: + download_result = channel._query_download_history(title="", hash_value="", limit=safe_limit, page=1) + if not download_only: + transfer_result = channel._query_transfer_history(title="", status="all", limit=safe_limit, page=1) + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_recent_activity_summary( + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword="", + hash_value="", + preferred="query_mp_lifecycle_status", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword="", + hash_value="", + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = ["最近本地/PT 活动:"] + if download_items: + lines.append(f"最近下载:{len(download_items)} 条") + if transfer_items: + lines.append(f"最近入库:{len(transfer_items)} 条") + if not download_items and not transfer_items: + lines.append("当前没有可展示的最近下载或入库活动。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + self._save_session(cache_key, { + "kind": "assistant_mp_recent_activity", + "stage": "recent_activity", + "keyword": "recent_activity", + "items": { + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + return { + "success": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recent_activity", + "ok": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_local_diagnose( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + risk_reasons = diagnosis_summary.get("risk_reasons") or [] + evidence = diagnosis_summary.get("evidence") or [] + ai_worklist = await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=title, + limit=max(5, limit), + ) + ai_data = dict((ai_worklist or {}).get("data") or {}) + ai_items = ai_data.get("items") if isinstance(ai_data.get("items"), list) else [] + if ai_items: + evidence.append(f"AI 失败样本命中 {len(ai_items)} 条") + risk_reasons.append("存在可用于二次识别重放的 AI 失败样本") + message_lines = [ + f"本地诊断:{self._clean_text(title or hash_value) or '全部'}", + f"判断阶段:{stage}", + f"是否命中记录:{'是' if diagnosis_summary.get('matched') else '否'}", + ] + if evidence: + message_lines.append("诊断证据:") + for item in evidence[:6]: + message_lines.append(f"- {item}") + if risk_reasons: + message_lines.append("风险与失败线索:") + for item in risk_reasons[:6]: + message_lines.append(f"- {item}") + if ai_items: + message_lines.append("AI 失败样本:") + for item in self._assistant_ai_sample_brief_lines(ai_items, limit=3): + message_lines.append(f"- {item}") + if diagnosis_summary.get("follow_up_hint"): + message_lines.append(f"建议动作:{diagnosis_summary.get('follow_up_hint')}") + next_actions = [] + for name in lifecycle_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + for name in ai_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + action_templates = [] + for item in (lifecycle_data.get("action_templates") or []) + (ai_data.get("action_templates") or []): + if isinstance(item, dict): + action_templates.append(item) + diagnosis_summary["risk_reasons"] = risk_reasons[:6] + diagnosis_summary["evidence"] = evidence[:6] + lifecycle_data["diagnosis_summary"] = diagnosis_summary + lifecycle_data["action"] = "mp_local_diagnose" + lifecycle_data["ai_sample_worklist"] = { + "ok": bool(ai_data.get("ok")), + "count": self._safe_int(ai_data.get("count"), len(ai_items)), + "items": ai_items[:10], + } + lifecycle_data["next_actions"] = next_actions[:6] + lifecycle_data["action_templates"] = action_templates[:6] + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(message_lines), + "data": lifecycle_data, + } + + async def _assistant_mp_recommendations( + self, + *, + source: str = "tmdb_trending", + media_type: str = "all", + limit: int = 20, + session: str = "default", + cache_key: str = "", + ) -> Dict[str, Any]: + try: + from app.chain.recommend import RecommendChain + from app.schemas.types import MediaType, media_type_to_agent + except Exception as exc: + return { + "success": False, + "message": f"MP 推荐失败:当前环境缺少推荐依赖 {exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + max_limit = max(1, min(50, self._safe_int(limit, 20))) + source_name = self._clean_text(source) or "tmdb_trending" + media_type_name = self._clean_text(media_type) or "all" + chain = RecommendChain() + try: + def collect_items(raw_results: List[Dict[str, Any]], media_type_filter: str = "") -> List[Dict[str, Any]]: + current_media_type = media_type_filter or media_type_name + collected = [] + for raw_item in (raw_results or [])[:max_limit]: + if not isinstance(raw_item, dict): + continue + item_type = raw_item.get("type") + if current_media_type != "all": + enum_type = MediaType.from_agent(current_media_type) + agent_type = media_type_to_agent(item_type) + expected_types = {current_media_type} + if current_media_type == "movie": + expected_types.add("电影") + elif current_media_type == "tv": + expected_types.update({"电视剧", "剧集"}) + if enum_type and item_type != enum_type and agent_type not in expected_types: + continue + collected.append({ + "index": len(collected) + 1, + "title": raw_item.get("title"), + "year": raw_item.get("year"), + "type": media_type_to_agent(item_type), + "tmdb_id": raw_item.get("tmdb_id"), + "douban_id": raw_item.get("douban_id"), + "vote_average": raw_item.get("vote_average"), + "poster_path": raw_item.get("poster_path"), + "detail_link": raw_item.get("detail_link"), + }) + return collected + + results: List[Dict[str, Any]] = [] + if source_name == "tmdb_trending": + results = await chain.async_tmdb_trending(page=1) + elif source_name == "tmdb_movies": + results = await chain.async_tmdb_movies(page=1) + elif source_name == "tmdb_tvs": + results = await chain.async_tmdb_tvs(page=1) + elif source_name in {"douban_hot", "douban_movie_hot"}: + results = await chain.async_douban_movie_hot(page=1, count=max_limit) + if source_name == "douban_hot" and media_type_name in {"all", "tv"}: + results.extend(await chain.async_douban_tv_hot(page=1, count=max_limit)) + elif source_name == "douban_tv_hot": + results = await chain.async_douban_tv_hot(page=1, count=max_limit) + elif source_name == "douban_movie_showing": + results = await chain.async_douban_movie_showing(page=1, count=max_limit) + elif source_name == "douban_movie_top250": + results = await chain.async_douban_movie_top250(page=1, count=max_limit) + elif source_name == "douban_tv_animation": + results = await chain.async_douban_tv_animation(page=1, count=max_limit) + elif source_name == "bangumi_calendar": + results = await chain.async_bangumi_calendar(page=1, count=max_limit) + else: + return { + "success": False, + "message": f"不支持的推荐来源:{source_name}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + items = collect_items(results) + fallback_source = "" + if not items and source_name != "tmdb_trending": + fallback_source = "tmdb_trending" + fallback_media_type = media_type_name if media_type_name in {"movie", "tv"} else "all" + items = collect_items(await chain.async_tmdb_trending(page=1), fallback_media_type) + display_source = fallback_source or source_name + lines = [f"MP 热门推荐:{display_source},共 {len(items)} 条"] + if fallback_source: + lines.append(f"注:{source_name} 当前暂无结果,已自动回退 {fallback_source}。") + for item in items[:10]: + lines.append(f"{item.get('index')}. {item.get('title') or '-'} ({item.get('year') or '-'}) | {item.get('type') or '-'} | 评分 {item.get('vote_average') or '-'}") + lines.append("下一步:回复“选择 1 决策”进入统一资源决策。") + lines.append("如果已经明确意图,也可以直接发“选择 1 计划”或“选择 1 确认”;也支持直接回复“详情”“计划”“确认”,默认作用于当前榜单首项。") + lines.append("如果想走单源,也可以回复“选择 1”进入 MP 原生搜索,或“选择 1 影巢”“选择 1 盘搜”。") + decision_summary = self._assistant_mp_recommendation_decision_summary() + if cache_key: + self._save_session(cache_key, { + "kind": "assistant_mp_recommend", + "stage": "result", + "source": fallback_source or source_name, + "requested_source": source_name, + "media_type": media_type_name, + "keyword": "", + "items": items, + "target_path": "", + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": True, + "source_type": "moviepilot_recommendation", + "source": fallback_source or source_name, + "requested_source": source_name, + "fallback_source": fallback_source, + "media_type": media_type_name, + "items": items, + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }), + } + except Exception as exc: + logger.error(f"MP 推荐失败:{source_name} {exc}", exc_info=True) + return { + "success": False, + "message": f"MP 推荐失败:{exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + + def _format_mp_recommend_item_detail_text(self, item: Dict[str, Any]) -> str: + title = self._clean_text(item.get("title")) or "-" + year = self._clean_text(item.get("year")) or "-" + media_type = self._clean_text(item.get("type")) or "-" + tmdb_id = self._clean_text(item.get("tmdb_id")) or "-" + douban_id = self._clean_text(item.get("douban_id")) or "-" + vote = self._clean_text(item.get("vote_average")) or "-" + lines = [ + "MP 推荐条目详情", + f"标题:{title}", + f"年份:{year}", + f"类型:{media_type}", + f"评分:{vote}", + f"TMDB:{tmdb_id}", + f"豆瓣:{douban_id}", + "下一步:回复“决策”“计划”“确认”,或“盘搜”“影巢”“原生”。", + ] + return "\n".join(lines) + + def _assistant_mp_recommendation_decision_summary(self) -> Dict[str, Any]: + return { + "decision_mode": "show_detail", + "decision_reason": "推荐列表默认先看当前榜单首项详情,再决定是否生成计划或直接确认执行。", + "decision_hint": "当前推荐会话支持直接对首项继续:详情 / 决策 / 计划 / 确认,也支持切到盘搜 / 影巢 / 原生。", + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "show_only", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + } + + def _assistant_recommend_handoff_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + current_state = dict(state or {}) + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + selected_index = self._safe_int(handoff.get("selected_index"), 0) + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + return { + "source": self._clean_text(handoff.get("source")), + "requested_source": self._clean_text(handoff.get("requested_source")), + "media_type": self._clean_text(handoff.get("media_type")), + "selected_index": selected_index if selected_index > 0 else None, + "selected_title": self._clean_text(selected_item.get("title")), + "return_short_command": "回推荐", + "source_short_commands": { + "pansou": "盘搜", + "hdhive": "影巢", + "mp": "原生", + }, + } + + def _assistant_recommend_handoff_state( + self, + *, + source: str, + requested_source: str, + media_type: str, + selected_index: int, + selected_item: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "source": self._clean_text(source), + "requested_source": self._clean_text(requested_source or source), + "media_type": self._clean_text(media_type or "all"), + "selected_index": max(0, self._safe_int(selected_index, 0)), + "return_short_command": "回推荐", + } + if isinstance(selected_item, dict) and selected_item: + payload["selected_item"] = dict(selected_item) + return payload + + async def _assistant_restore_mp_recommendation_handoff( + self, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + limit: int = 20, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + result = await self._assistant_mp_recommendations( + source=self._clean_text(handoff.get("requested_source") or handoff.get("source") or "tmdb_trending"), + media_type=self._clean_text(handoff.get("media_type") or "all"), + limit=limit, + session=session, + cache_key=cache_key, + ) + if not result.get("success"): + return result + selected_index = max(0, self._safe_int(handoff.get("selected_index"), 0)) + if selected_index > 0: + refreshed_state = self._load_session(cache_key) or {} + items = refreshed_state.get("items") if isinstance(refreshed_state.get("items"), list) else [] + if selected_index <= len(items): + self._save_session(cache_key, { + **refreshed_state, + "selected_index": selected_index, + "selected_item": dict(items[selected_index - 1] or {}), + }) + message = str(result.get("message") or "").strip() + if selected_index > 0: + message = f"{message}\n已返回推荐列表,默认仍指向第 {selected_index} 项。".strip() + result["message"] = message + payload = dict(result.get("data") or {}) + if selected_index > 0: + payload["selected_index"] = selected_index + payload["return_short_command"] = "" + result["data"] = self._assistant_response_data(session=session, data=payload) + return result + + async def _assistant_switch_recommend_handoff_source( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可切换的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法切换源。请先发送:回推荐。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": self._clean_text(mode), + "keyword": keyword, + "media_type": media_type, + "recommend_handoff": dict(handoff), + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_run_recommend_source_compound( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + followup_action: str, + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + mode = self._clean_text(mode).lower() + followup_action = self._clean_text(followup_action) + kind = self._clean_text(state.get("kind")) + if mode not in {"pansou", "mp"}: + return { + "success": False, + "message": "当前只支持 盘搜/原生 的单条详情、计划、确认命令。", + "data": self._assistant_response_data(session=session, data={ + "action": "recommend_source_compound", + "ok": False, + "error_code": "unsupported_recommend_source_compound_mode", + }), + } + + if kind == "assistant_mp_recommend": + selected_index = max(1, self._safe_int(state.get("selected_index"), 0) or 1) + source_result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": selected_index, + "mode": mode, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if not source_result.get("success"): + return source_result + else: + kind_mode = { + "assistant_pansou": "pansou", + "assistant_mp": "mp", + "assistant_hdhive": "hdhive", + }.get(kind, "") + if kind_mode != mode: + source_result = await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=mode, + ) + if not source_result.get("success"): + return source_result + + return await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": 0, + "action": followup_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_route_recommend_handoff_to_smart_decision( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法回到统一资源决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": "smart_decision", + "keyword": keyword, + "media_type": media_type, + "origin": "mp_recommend", + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_confirm_recommend_handoff( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + pending_plan = self._find_workflow_plan( + session=session, + session_id=cache_key, + executed=False, + ) + handoff_state = dict(state or {}) + if pending_plan and not isinstance(handoff_state.get("recommend_handoff"), dict): + pending_handoff = pending_plan.get("recommend_handoff") if isinstance(pending_plan.get("recommend_handoff"), dict) else {} + if pending_handoff: + handoff_state["recommend_handoff"] = dict(pending_handoff) + if pending_plan: + result = await self.api_assistant_plan_execute(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "prefer_unexecuted": True, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + kind = self._clean_text(state.get("kind")) + if kind in {"assistant_pansou", "assistant_mp"}: + result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best_execute", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + return { + "success": False, + "message": "当前会话没有待确认计划。影巢候选阶段请先选择编号;如果想回统一搜索决策,请回复:决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "confirm_recommend_handoff", + "ok": False, + "error_code": "recommend_handoff_pending_plan_missing", + "preferred_command": "决策", + "fallback_command": "回推荐", + }), + } + + def _assistant_recommend_handoff_short_metadata( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + handoff = (state or {}).get("recommend_handoff") if isinstance((state or {}).get("recommend_handoff"), dict) else {} + source_short_commands = handoff.get("source_short_commands") if isinstance(handoff.get("source_short_commands"), dict) else {} + return { + "recommend_handoff": dict(handoff) if handoff else {}, + "return_short_command": self._clean_text(handoff.get("return_short_command") or "回推荐") if handoff else "", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": self._clean_text(source_short_commands.get("pansou") or "盘搜") if handoff else "", + "hdhive_short_command": self._clean_text(source_short_commands.get("hdhive") or "影巢") if handoff else "", + "mp_short_command": self._clean_text(source_short_commands.get("mp") or "原生") if handoff else "", + } + + def _assistant_recommend_handoff_plan_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "confirm", + "label": "待确认计划", + "decision_hint": "当前推荐条目已生成计划;先看详情,再决定是否确认执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "确认", + "fallback_command": "详情", + "compact_commands": ["确认", "详情"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_entry_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "source_entry", + "label": "已切入单源", + "decision_hint": "当前推荐条目已切入单源结果;先看详情,再决定是否生成计划。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_detail_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "detail", + "label": "已查看详情", + "decision_hint": "当前推荐条目详情已展开;可以先生成计划,确认无误后再执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": True, + "preferred_command": "计划", + "fallback_command": "确认", + "compact_commands": ["计划", "确认"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "计划", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_execute_failure_followup( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "category": "recommend_handoff", + "stage": "write_failed", + "label": "执行失败,建议换路", + "preferred_action": "return_to_smart_decision", + "decision_hint": "当前推荐条目的执行失败;优先回到统一资源决策,或回推荐后切换其它源。", + "command_policy": "safe_read_recovery", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "决策", + "fallback_command": meta.get("return_short_command") or "回推荐", + "compact_commands": ["决策", meta.get("return_short_command") or "回推荐"], + "recommended_commands": [ + "决策", + meta.get("return_short_command") or "回推荐", + meta.get("hdhive_short_command") or "影巢", + meta.get("mp_short_command") or "原生", + ], + "recommended_agent_behavior": "show_only", + **meta, + } + + def _persist_workflow_plans(self) -> None: + try: + items = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + )[:self._workflow_plan_limit] + self._workflow_plans = { + self._clean_text(item.get("plan_id")): item + for item in items + if self._clean_text(item.get("plan_id")) + } + self.save_data(key=self._workflow_plan_store_key, value=self._workflow_plans) + except Exception: + pass + + def _restore_workflow_plans(self) -> None: + try: + restored = self.get_data(self._workflow_plan_store_key) or {} + if isinstance(restored, dict): + self._workflow_plans = { + self._clean_text(plan_id): dict(payload) + for plan_id, payload in restored.items() + if self._clean_text(plan_id) and isinstance(payload, dict) + } + except Exception: + self._workflow_plans = {} + + def _save_workflow_plan( + self, + *, + workflow: str, + session: str, + session_id: str = "", + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + ) -> Dict[str, Any]: + plan_id = self._new_session_id("plan") + created_at = int(time.time()) + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plan = { + "plan_id": plan_id, + "workflow": self._clean_text(workflow), + "session": session_name, + "session_id": normalized_session_id, + "actions": [dict(item or {}) for item in (actions or [])], + "execute_body": dict(execute_body or {}), + "created_at": created_at, + "created_at_text": self._format_unix_time(created_at), + "executed_at": 0, + "executed_at_text": "", + "executed": False, + } + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + return dict(plan) + + def _save_assistant_pick_plan_response( + self, + *, + workflow: str, + session: str, + session_id: str, + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + message: str, + score_items: Optional[List[Dict[str, Any]]] = None, + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + plan = self._save_workflow_plan( + workflow=workflow, + session=session, + session_id=session_id, + actions=actions, + execute_body=execute_body, + ) + plan_id = self._clean_text(plan.get("plan_id")) + recommend_handoff = extra_data.get("recommend_handoff") if isinstance((extra_data or {}).get("recommend_handoff"), dict) else {} + if plan_id and recommend_handoff and isinstance(self._workflow_plans.get(plan_id), dict): + self._workflow_plans[plan_id]["recommend_handoff"] = dict(recommend_handoff) + self._persist_workflow_plans() + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": plan_id, + "session": session, + "session_id": session_id, + "prefer_unexecuted": True, + }, + ) + data = { + "action": "workflow_plan", + "ok": True, + "plan_id": plan_id, + "workflow": workflow, + "dry_run": True, + "workflow_actions": [dict(item or {}) for item in actions], + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan_id}, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "next_actions": ["execute_plan"], + "action_templates": [template], + "write_effect": "state", + } + if score_items: + data["score_summary"] = self._score_summary(score_items, limit=1) + if extra_data: + data.update(extra_data) + decision_summary = data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {} + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in decision_summary and key not in data: + data[key] = decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + choice = self._safe_int(data.get("choice"), 0) + confirm_hint = f"回复“执行计划”或“{choice}”确认执行。" if choice > 0 else "回复“执行计划”确认执行。" + return { + "success": True, + "message": f"{message}:{plan_id}\n未实际执行。{confirm_hint}", + "data": self._assistant_response_data(session=session, data=data), + } + + def _load_workflow_plan(self, plan_id: str) -> Optional[Dict[str, Any]]: + plan = (self._workflow_plans or {}).get(self._clean_text(plan_id)) + return dict(plan) if isinstance(plan, dict) else None + + def _find_workflow_plan( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + ) -> Optional[Dict[str, Any]]: + clean_plan_id = self._clean_text(plan_id) + if clean_plan_id: + return self._load_workflow_plan(clean_plan_id) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + for plan in plans: + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + return dict(plan) + return None + + def _find_pending_multi_plan( + self, + *, + session: str, + session_id: str, + rank: int = 0, + choice: int = 0, + group_id: str = "", + ) -> Optional[Dict[str, Any]]: + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + candidates: List[Dict[str, Any]] = [] + for plan in (self._workflow_plans or {}).values(): + if not isinstance(plan, dict) or bool(plan.get("executed")): + continue + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + plan_group = self._clean_text(plan.get("multi_plan_group") or execute_body.get("multi_plan_group")) + if group_id and plan_group != group_id: + continue + plan_rank = self._safe_int(plan.get("plan_rank") or execute_body.get("plan_rank"), 0) + plan_choice = self._safe_int(plan.get("plan_choice") or execute_body.get("choice") or execute_body.get("index"), 0) + if rank > 0 and plan_rank != rank: + continue + if choice > 0 and plan_choice != choice and plan_rank != choice: + continue + candidates.append(dict(plan)) + candidates.sort( + key=lambda item: ( + -self._safe_int(item.get("created_at"), 0), + self._safe_int(item.get("plan_rank") or (item.get("execute_body") or {}).get("plan_rank"), 999), + ) + ) + return candidates[0] if candidates else None + + def _workflow_plan_public_item(self, plan: Dict[str, Any], *, include_actions: bool = False) -> Dict[str, Any]: + current = dict(plan or {}) + actions = current.get("actions") or [] + item = { + "plan_id": self._clean_text(current.get("plan_id")), + "workflow": self._clean_text(current.get("workflow")), + "session": self._clean_text(current.get("session")), + "session_id": self._clean_text(current.get("session_id")), + "created_at": self._safe_int(current.get("created_at"), 0), + "created_at_text": self._clean_text(current.get("created_at_text")), + "executed": bool(current.get("executed")), + "executed_at": self._safe_int(current.get("executed_at"), 0), + "executed_at_text": self._clean_text(current.get("executed_at_text")), + "last_success": current.get("last_success"), + "last_message": self._clean_text(current.get("last_message")), + "action_count": len(actions) if isinstance(actions, list) else 0, + } + if include_actions: + item["actions"] = [dict(action or {}) for action in actions] if isinstance(actions, list) else [] + item["execute_body"] = dict(current.get("execute_body") or {}) + return item + + def _session_workflow_plan_public_data(self, *, session: str = "", session_id: str = "") -> Dict[str, Any]: + pending = self._find_workflow_plan(session=session, session_id=session_id, executed=False) + latest = pending or self._find_workflow_plan(session=session, session_id=session_id, executed=None) + if not latest: + return { + "has_plan": False, + "has_pending": False, + "latest": None, + } + return { + "has_plan": True, + "has_pending": bool(pending), + "latest": self._workflow_plan_public_item(latest, include_actions=False), + } + + def _assistant_plans_public_data( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + items: List[Dict[str, Any]] = [] + matching_total = 0 + for plan in plans: + if session_id_filter and self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + matching_total += 1 + if len(items) < max_limit: + items.append(self._workflow_plan_public_item(plan, include_actions=include_actions)) + return { + "total": matching_total, + "total_matching": matching_total, + "total_all": len(self._workflow_plans or {}), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "include_actions": bool(include_actions), + "items": items, + } + + def _format_assistant_plans_text( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> str: + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 保存计划。" + lines = [f"已保存计划:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "已执行" if item.get("executed") else "待执行" + line = ( + f"{index}. {item.get('plan_id') or '-'} | {status} | " + f"{item.get('workflow') or '-'} | {item.get('session') or '-'} | " + f"{item.get('action_count') or 0}步 | {item.get('created_at_text') or '-'}" + ) + if item.get("last_message"): + line = f"{line} | {item.get('last_message')}" + lines.append(line) + lines.append("下一步:可用 agent_resource_officer_execute_plan 执行 plan_id,或用 agent_resource_officer_plans_clear 清理。") + return "\n".join(lines) + + def _clear_workflow_plans( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + clean_plan_id = self._clean_text(plan_id) + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not any([clean_plan_id, session_id_filter, executed is not None, all_plans]): + return { + "ok": False, + "message": "请指定 plan_id、session/session_id、executed 过滤条件,或显式 all_plans=true", + "removed": 0, + "removed_ids": [], + } + removed_ids: List[str] = [] + for current_id, plan in list((self._workflow_plans or {}).items()): + if len(removed_ids) >= max_limit: + break + current = dict(plan or {}) + if clean_plan_id and self._clean_text(current_id) != clean_plan_id: + continue + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(current.get("executed")) != bool(executed): + continue + removed_ids.append(self._clean_text(current_id)) + for current_id in removed_ids: + self._workflow_plans.pop(current_id, None) + if removed_ids: + self._persist_workflow_plans() + return { + "ok": True, + "message": f"已清理 {len(removed_ids)} 条计划", + "removed": len(removed_ids), + "removed_ids": removed_ids, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "all_plans": bool(all_plans), + } + + def _record_assistant_execution( + self, + *, + action: str, + session: str = "default", + session_id: str = "", + success: bool = False, + message: str = "", + summary: Optional[Dict[str, Any]] = None, + ) -> None: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + entry = { + "id": self._new_session_id("exec"), + "time": int(time.time()), + "time_text": self._format_unix_time(int(time.time())), + "action": self._clean_text(action), + "session": session_name, + "session_id": normalized_session_id, + "success": bool(success), + "message_head": self._assistant_result_message_head(message), + "summary": dict(summary or {}), + } + self._execution_history.append(entry) + self._execution_history = self._execution_history[-self._execution_history_limit:] + self._persist_execution_history() + + def _assistant_history_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + items: List[Dict[str, Any]] = [] + for entry in reversed(self._execution_history or []): + current = dict(entry or {}) + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + items.append(current) + if len(items) >= max_limit: + break + return { + "total": len(self._execution_history or []), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "items": items, + } + + def _format_assistant_history_text( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> str: + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 执行历史。" + lines = [f"最近执行历史:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "成功" if item.get("success") else "失败" + line = f"{index}. {item.get('time_text') or '-'} | {status} | {item.get('action') or '-'} | {item.get('session') or '-'}" + if item.get("message_head"): + line = f"{line} | {item.get('message_head')}" + lines.append(line) + return "\n".join(lines) + + def _is_session_expired(self, payload: Optional[Dict[str, Any]]) -> bool: + session = dict(payload or {}) + updated_at = self._safe_int(session.get("updated_at"), 0) + if updated_at <= 0: + return False + return (int(time.time()) - updated_at) > self._session_retention_seconds + + @staticmethod + def _format_unix_time(value: Any) -> str: + try: + timestamp = int(value) + except Exception: + return "" + if timestamp <= 0: + return "" + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + except Exception: + return "" + + @staticmethod + def _group_resource_preview(items: List[Dict[str, Any]], per_group: Optional[int] = 6) -> List[Dict[str, Any]]: + groups: Dict[str, List[Dict[str, Any]]] = {"115": [], "quark": [], "other": []} + for item in items: + pan = str(item.get("pan_type") or "").lower() + if pan == "115": + key = "115" + elif pan == "quark": + key = "quark" + else: + key = "other" + if per_group is None or len(groups[key]) < per_group: + groups[key].append(item) + ordered = groups["115"] + groups["quark"] + if not ordered: + ordered = groups["other"] + preview: List[Dict[str, Any]] = [] + for index, item in enumerate(ordered, 1): + row = dict(item) + row["pick_index"] = index + preview.append(row) + return preview + + def _assistant_session_id(self, session: str) -> str: + session = self._clean_text(session) or "default" + return f"assistant::{session}" + + def _assistant_session_name_from_id(self, session_id: str) -> str: + clean_session_id = self._clean_text(session_id) + if clean_session_id.startswith("assistant::"): + return clean_session_id.split("assistant::", 1)[1] or "default" + return clean_session_id or "default" + + def _normalize_assistant_session_ref( + self, + *, + session: Any = None, + session_id: Any = None, + fallback: str = "default", + ) -> Tuple[str, str]: + clean_session_id = self._clean_text(session_id) + if clean_session_id: + session_name = self._assistant_session_name_from_id(clean_session_id) + return session_name, self._assistant_session_id(session_name) + session_name = self._clean_text(session) or fallback + return session_name, self._assistant_session_id(session_name) + + def _p115_status_snapshot(self) -> Dict[str, Any]: + health_ok, result, health_message = self._ensure_p115_service().health() + return { + "ready": health_ok, + "message": health_message or result.get("message") or "", + "direct_source": self._clean_text(result.get("direct_source")), + "helper_ready": bool(result.get("helper_ready")), + "client_type": self._p115_client_type, + "default_target_path": self._p115_default_path, + "cookie_mode": self._clean_text((result.get("cookie_state") or {}).get("mode")), + } + + def _format_p115_next_actions(self, status: Optional[Dict[str, Any]] = None) -> str: + current = dict(status or self._p115_status_snapshot()) + final_path = current.get("default_target_path") or self._p115_default_path + if current.get("ready"): + lines = [ + "下一步建议:", + f"1. 直接发:链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 也可以直接贴 115 链接,不写前缀也能识别", + "3. 搜资源可发:影巢搜索 片名", + "4. 外部搜资源可发:盘搜搜索 片名", + "5. 想复查登录状态可发:115状态", + ] + else: + lines = [ + "下一步建议:", + "1. 回复:115登录", + "2. 扫码确认后回复:检查115登录", + "3. 登录完成后可回复:115状态", + f"4. 然后可直接发 115 链接转存到 {final_path}", + "5. 也可以继续发:影巢搜索 片名", + ] + return "\n".join(lines) + + def _format_p115_transfer_failure( + self, + *, + detail: str = "", + target_path: str = "", + title: str = "115 转存失败", + ) -> str: + status = self._p115_status_snapshot() + final_path = target_path or status.get("default_target_path") or self._p115_default_path + clean_detail = self._clean_text(detail) + status_message = self._clean_text(status.get("message")) + lines = [title] + if clean_detail: + lines.append(f"原因:{clean_detail}") + if final_path: + lines.append(f"目标目录:{final_path}") + lines.append(f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}") + if status_message and status_message.lower() not in {"success", "ok"} and status_message != clean_detail: + lines.append(f"状态详情:{status_message}") + if status.get("ready"): + lines.append("建议:先回复 115状态 检查当前会话;如果还是失败,再回复 115登录 重新扫码。") + else: + lines.append("建议:先回复 115登录,扫码成功后再重试当前操作。") + return "\n".join(lines) + + @staticmethod + def _format_p115_resume_hint(title: str = "") -> str: + clean_title = str(title or "").strip() + prefix = f"已记住这次 115 任务({clean_title})。" if clean_title else "已记住这次 115 任务。" + return f"{prefix}\n登录成功后回复:检查115登录,我会自动继续处理。" + + def _save_pending_p115_share( + self, + session_id: str, + *, + share_url: str, + access_code: str = "", + target_path: str = "", + source: str = "", + title: str = "", + last_error: str = "", + ) -> None: + clean_url = self._clean_text(share_url) + if not clean_url: + return + state = self._load_session(session_id) or {} + previous = dict(state.get("pending_p115") or {}) + now = int(time.time()) + state["pending_p115"] = { + "kind": "share_route", + "share_url": clean_url, + "access_code": self._clean_text(access_code), + "target_path": target_path or self._p115_default_path, + "source": self._clean_text(source), + "title": self._clean_text(title), + "created_at": self._safe_int(previous.get("created_at"), now) or now, + "last_attempt_at": now, + "retry_count": max(0, self._safe_int(previous.get("retry_count"), 0)), + "last_error": self._clean_text(last_error) or self._clean_text(previous.get("last_error")), + } + if not state.get("kind"): + state["kind"] = "assistant_p115_pending" + state["stage"] = "pending_login" + self._save_session(session_id, state) + + def _clear_pending_p115_share(self, session_id: str) -> None: + state = self._load_session(session_id) + if not state or "pending_p115" not in state: + return + state.pop("pending_p115", None) + self._save_session(session_id, state) + + def _pending_p115_summary(self, state: Optional[Dict[str, Any]]) -> str: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return "" + title = self._clean_text(pending.get("title")) or "未命名任务" + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + source = self._clean_text(pending.get("source")) or "unknown" + created_at = self._format_unix_time(pending.get("created_at")) + last_attempt_at = self._format_unix_time(pending.get("last_attempt_at")) + retry_count = max(0, self._safe_int(pending.get("retry_count"), 0)) + last_error = self._clean_text(pending.get("last_error")) + lines = [ + "待继续的 115 任务:", + f"资源:{title}", + f"目录:{target_path}", + f"来源:{source}", + ] + if created_at: + lines.append(f"首次记录:{created_at}") + if last_attempt_at: + lines.append(f"最近尝试:{last_attempt_at}") + if retry_count: + lines.append(f"重试次数:{retry_count}") + if last_error: + lines.append(f"最近错误:{last_error}") + lines.append("可用命令:继续115任务 / 取消115任务") + return "\n".join(lines) + + def _pending_p115_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + pending = dict((state or {}).get("pending_p115") or {}) + if not self._clean_text(pending.get("share_url")): + return {"has_pending": False} + return { + "has_pending": True, + "title": self._clean_text(pending.get("title")) or "未命名任务", + "target_path": self._clean_text(pending.get("target_path")) or self._p115_default_path, + "source": self._clean_text(pending.get("source")) or "unknown", + "created_at": self._safe_int(pending.get("created_at"), 0), + "created_at_text": self._format_unix_time(pending.get("created_at")), + "last_attempt_at": self._safe_int(pending.get("last_attempt_at"), 0), + "last_attempt_at_text": self._format_unix_time(pending.get("last_attempt_at")), + "retry_count": max(0, self._safe_int(pending.get("retry_count"), 0)), + "last_error": self._clean_text(pending.get("last_error")), + } + + @staticmethod + def _assistant_find_action_template( + templates: Optional[List[Dict[str, Any]]], + names: List[str], + ) -> Optional[Dict[str, Any]]: + rows = [dict(item or {}) for item in (templates or []) if isinstance(item, dict)] + for current_name in names: + for item in rows: + if str(item.get("name") or "").strip() == current_name: + return item + return None + + def _assistant_recovery_public_data( + self, + *, + session_state: Optional[Dict[str, Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + state = dict(session_state or {}) + templates = [dict(item or {}) for item in (action_templates or state.get("action_templates") or []) if isinstance(item, dict)] + saved_plan = dict(state.get("saved_plan") or {}) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id") or saved_plan.get("plan_id")) + latest_plan_executed = bool(latest_plan.get("executed")) + pending_p115 = dict(state.get("pending_p115") or {}) + has_session = bool(state.get("has_session")) + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + session_name = self._clean_text(state.get("session")) or self._assistant_session_name_from_id(self._clean_text(state.get("session_id"))) + session_id = self._clean_text(state.get("session_id")) + mode = "" + reason = "" + template: Optional[Dict[str, Any]] = None + + if saved_plan.get("has_pending"): + mode = "resume_saved_plan" + reason = "当前会话存在待执行计划" + template = self._assistant_find_action_template(templates, [ + "execute_latest_plan", + "execute_plan", + "execute_session_latest_plan", + ]) + elif latest_plan_executed and latest_plan_id: + mode = "followup_executed_plan" + reason = "当前会话最近一条计划已执行,建议先做统一后续追踪" + template = self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动追踪下载、订阅或入库后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **({"session": session_name} if session_name else {}), + **({"session_id": session_id} if session_id else {}), + "plan_id": latest_plan_id, + }, + ) + elif pending_p115.get("has_pending"): + mode = "resume_pending_115" + reason = "当前会话存在待继续的 115 任务" + template = self._assistant_find_action_template(templates, [ + "resume_pending_115", + "check_115_login", + ]) + elif has_session and kind == "assistant_pansou": + mode = "continue_pansou" + reason = "当前会话停留在盘搜结果列表" + template = self._assistant_find_action_template(templates, ["pick_pansou_result"]) + elif has_session and kind == "assistant_mp_candidate": + mode = "continue_mp_candidate" + reason = "当前会话停留在 MP 媒体候选列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp": + mode = "continue_mp_search" + reason = "当前会话停留在 MP 原生搜索结果列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_best_result_detail", + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp_recommend": + mode = "continue_mp_recommend" + reason = "当前会话停留在 MP 热门推荐列表" + template = self._assistant_find_action_template(templates, [ + "pick_recommend_smart_decision", + "pick_recommend_smart_plan", + "pick_recommend_mp_search", + ]) + elif has_session and kind == "assistant_mp_download_tasks": + mode = "continue_mp_download_tasks" + reason = "当前会话停留在 MP 下载任务列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "pause_mp_download", + "resume_mp_download", + "delete_mp_download", + ]) + elif has_session and kind == "assistant_mp_download_history": + mode = "continue_mp_download_history" + reason = "当前会话停留在 MP 下载历史" + template = self._assistant_find_action_template(templates, [ + "query_mp_lifecycle_status", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_downloaders": + mode = "continue_mp_downloaders" + reason = "当前会话停留在 MP 下载器状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_sites", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_sites": + mode = "continue_mp_sites" + reason = "当前会话停留在 MP 站点状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_downloaders", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_subscribes": + mode = "continue_mp_subscribes" + reason = "当前会话停留在 MP 订阅列表" + template = self._assistant_find_action_template(templates, [ + "start_mp_subscribe", + "search_mp_subscribe", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_lifecycle_status": + mode = "continue_mp_lifecycle_status" + reason = "当前会话停留在 MP 生命周期追踪" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_hdhive" and stage == "candidate": + mode = "continue_hdhive_candidate" + reason = "当前会话停留在影巢候选列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_candidate"]) + elif has_session and kind == "assistant_hdhive" and stage == "resource": + mode = "continue_hdhive_resource" + reason = "当前会话停留在影巢资源列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_resource"]) + elif has_session and kind == "assistant_p115_login": + mode = "continue_115_login" + reason = "当前会话停留在 115 登录检查阶段" + template = self._assistant_find_action_template(templates, ["check_115_login", "show_115_status"]) + elif self._assistant_find_action_template(templates, ["preferences_save"]): + mode = "onboard_preferences" + reason = "智能体片源偏好未初始化,建议先询问并保存用户偏好" + template = self._assistant_find_action_template(templates, ["preferences_save"]) + else: + mode = "start_new" + reason = "当前没有待恢复的执行状态,可直接开始新任务" + template = self._assistant_find_action_template(templates, [ + "start_pansou_search", + "start_hdhive_search", + "start_115_login", + ]) + + can_resume = mode != "start_new" and bool(template) + return { + "mode": mode, + "reason": reason, + "can_resume": can_resume, + "recommended_action": self._clean_text((template or {}).get("name")), + "recommended_tool": self._clean_text((template or {}).get("tool")), + "action_template": template or None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in templates[:6] + if self._clean_text(item.get("name")) + ], + } + + def _assistant_session_public_data(self, session: str = "default") -> Dict[str, Any]: + session_name = self._clean_text(session) or "default" + session_id = self._assistant_session_id(session_name) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + state = self._load_session(session_id) or {} + if not state: + payload = { + "has_session": False, + "session": session_name, + "session_id": session_id, + "saved_plan": saved_plan, + "suggested_actions": ["execute_plan.session", "smart_entry"] if saved_plan.get("has_pending") else ["smart_entry"], + } + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + target_path = self._clean_text(state.get("target_path")) + payload: Dict[str, Any] = { + "has_session": True, + "session": session_name, + "session_id": session_id, + "kind": kind, + "stage": stage, + "updated_at": self._safe_int(state.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(state.get("updated_at")), + "target_path": target_path, + "keyword": self._clean_text(state.get("keyword")), + "media_type": self._clean_text(state.get("media_type")), + "year": self._clean_text(state.get("year")), + "pending_p115": self._pending_p115_public_data(state), + "saved_plan": saved_plan, + "suggested_actions": [], + } + + if kind == "assistant_pansou": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "channel": self._clean_text(item.get("channel")), + "title": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + } + for idx, item in enumerate(items[:6]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["smart_pick.choice", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=影巢", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(((item.get("torrent_info") or {}).get("title")) or item.get("title")), + "site": self._clean_text((item.get("torrent_info") or {}).get("site_name")), + "seeders": (item.get("torrent_info") or {}).get("seeders"), + "volume_factor": self._clean_text((item.get("torrent_info") or {}).get("volume_factor")), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["mp_download.choice", "mp_subscribe.keyword", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=盘搜", + "smart_entry.text=影巢", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + payload.update({ + "stage": "candidate", + "candidate_count": len(candidates), + "page": self._safe_int(state.get("page"), 1), + "page_size": self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size), + "candidates_preview": [ + { + "index": idx + 1, + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "tmdb_id": item.get("tmdb_id"), + } + for idx, item in enumerate(candidates[:10]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + elif kind == "assistant_mp_download_tasks": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "hash_short": self._clean_text(item.get("hash_short")), + "downloader": self._clean_text(item.get("downloader")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_download_control.pause", "mp_download_control.resume", "mp_download_control.delete", "session_clear"] + if items else + ["mp_media_search", "mp_download_history", "session_clear"] + ), + }) + elif kind == "assistant_mp_download_history": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + "download_hash_short": self._clean_text(item.get("download_hash_short")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_lifecycle_status", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_downloaders": + items = state.get("items") or [] + payload.update({ + "enabled_count": self._safe_int(state.get("enabled_count"), 0), + "result_count": len(items), + "items_preview": [ + { + "name": self._clean_text(item.get("name")), + "type": self._clean_text(item.get("type")), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + } + for item in items[:8] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_sites", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_sites": + items = state.get("items") or [] + payload.update({ + "status": self._clean_text(state.get("status")), + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "name": self._clean_text(item.get("name")), + "domain": self._clean_text(item.get("domain")), + "enabled": bool(item.get("enabled")), + "has_cookie": bool(item.get("has_cookie")), + "priority": item.get("priority"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_downloaders", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_subscribes": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "id": self._safe_int(item.get("id"), 0), + "title": self._clean_text(item.get("name")), + "year": self._clean_text(item.get("year")), + "state": self._clean_text(item.get("state")), + "lack_episode": item.get("lack_episode"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_subscribe_control.search", "mp_subscribe_control.pause", "mp_subscribe_control.resume", "mp_subscribe_control.delete", "session_clear"] + if items else + ["mp_subscribe.keyword", "mp_media_search", "session_clear"] + ), + }) + elif kind == "assistant_mp_lifecycle_status": + result_groups = state.get("items") if isinstance(state.get("items"), dict) else {} + task_items = result_groups.get("download_tasks") if isinstance(result_groups.get("download_tasks"), list) else [] + download_items = result_groups.get("download_history") if isinstance(result_groups.get("download_history"), list) else [] + transfer_items = result_groups.get("transfer_history") if isinstance(result_groups.get("transfer_history"), list) else [] + payload.update({ + "download_task_count": len(task_items), + "download_history_count": len(download_items), + "transfer_history_count": len(transfer_items), + "items_preview": [ + { + "kind": "download_task", + "title": self._clean_text(item.get("title")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for item in task_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "download_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + } + for item in download_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "transfer_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "status_text": self._clean_text(item.get("status_text")), + } + for item in transfer_items[:3] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_media_search", "mp_download_history", "session_clear"], + }) + elif kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + payload.update({ + "source": self._clean_text(state.get("source")), + "result_count": len(items), + "selected_index": selected_index if selected_index > 0 else None, + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "type": self._clean_text(item.get("type")), + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "douban_id": self._clean_text(item.get("douban_id")), + "vote_average": item.get("vote_average"), + } + for idx, item in enumerate(items[:10]) + if isinstance(item, dict) + ], + "suggested_actions": [ + "smart_pick.choice", + "smart_entry.text=电影", + "smart_entry.text=电视剧", + "smart_entry.text=豆瓣", + "smart_entry.text=热映", + "smart_entry.text=番剧", + "smart_pick.choice mode=hdhive", + "smart_pick.choice mode=pansou", + "session_clear", + ], + }) + if selected_index > 0: + payload["selected_item"] = dict(state.get("selected_item") or {}) + payload["suggested_actions"] = [ + *list(payload.get("suggested_actions") or []), + "smart_entry.text=详情", + "smart_entry.text=决策", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=原生", + "smart_entry.text=影巢", + "smart_entry.text=盘搜", + ] + elif kind == "assistant_update_check": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("_index") or item.get("index"), idx + 1), + "source_type": self._clean_text(item.get("_update_source")), + "title": self._clean_text(item.get("note") or item.get("title") or item.get("remark")), + "provider": self._clean_text(item.get("channel") or item.get("pan_type")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=计划", "session_clear"], + }) + elif kind == "assistant_hdhive": + payload["recommend_handoff"] = self._assistant_recommend_handoff_public_data(state) + if stage == "candidate": + candidates = state.get("candidates") or [] + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "page": current_page, + "page_size": page_size, + "total_candidates": len(candidates), + "total_pages": total_pages, + "candidates_preview": [ + { + "index": start + idx + 1, + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type")), + "actors": item.get("actors") or [], + } + for idx, item in enumerate(candidates[start:end]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif stage == "resource": + resources = state.get("resources") or [] + selected_candidate = dict(state.get("selected_candidate") or {}) + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "selected_candidate": { + "tmdb_id": self._clean_text(selected_candidate.get("tmdb_id")), + "title": self._clean_text(selected_candidate.get("title")), + "year": self._clean_text(selected_candidate.get("year")), + "media_type": self._clean_text(selected_candidate.get("media_type")), + "actors": selected_candidate.get("actors") or [], + }, + "page": current_page, + "page_size": page_size, + "total_resources": len(resources), + "total_pages": total_pages, + "resource_count_115": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "115"]), + "resource_count_quark": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "quark"]), + "resources_preview": [ + { + "index": self._safe_int(item.get("pick_index"), idx + 1), + "provider": self._clean_text(item.get("pan_type")), + "title": self._clean_text(item.get("title") or item.get("matched_title")), + "points": item.get("unlock_points"), + "points_text": self._resource_points_text(item), + "quality": self._clean_text(self._list_text(item.get("video_resolution")) or item.get("quality")), + "source": self._clean_text(self._list_text(item.get("source"))), + "size": self._clean_text(item.get("share_size") or item.get("size")), + "episodes": self._resource_episode_text(item), + "subtitle": self._resource_subtitle_text(item), + "remark": self._resource_remark_text(item), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(resources[start:end], start=start + 1) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(resources, limit=5), + "suggested_actions": ["smart_pick.choice", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_p115_login": + payload.update({ + "client_type": self._clean_text(state.get("client_type")) or self._p115_client_type, + "has_qrcode_session": bool(self._clean_text(state.get("uid")) and self._clean_text(state.get("time")) and self._clean_text(state.get("sign"))), + "suggested_actions": ["smart_entry.text=检查115登录", "p115_status", "session_clear"], + }) + else: + payload["suggested_actions"] = ["smart_entry", "session_clear"] + if saved_plan.get("has_pending"): + payload["suggested_actions"] = ["execute_plan.session", *list(payload.get("suggested_actions") or [])] + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + def _assistant_session_brief_public_data(self, session_id: str, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(state or {}) + name = str(session_id or "") + session_name = name.split("assistant::", 1)[1] if name.startswith("assistant::") else name or "default" + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=name) + result: Dict[str, Any] = { + "session": session_name, + "session_id": name or self._assistant_session_id(session_name), + "kind": self._clean_text(payload.get("kind")), + "stage": self._clean_text(payload.get("stage")), + "updated_at": self._safe_int(payload.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(payload.get("updated_at")), + "keyword": self._clean_text(payload.get("keyword")), + "target_path": self._clean_text(payload.get("target_path")), + "has_pending_p115": bool(self._clean_text(((payload.get("pending_p115") or {}).get("share_url")))), + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": saved_plan.get("latest"), + } + if result["kind"] == "assistant_pansou": + result["result_count"] = len(payload.get("items") or []) + elif result["kind"] == "assistant_mp_recommend": + result["result_count"] = len(payload.get("items") or []) + result["source"] = self._clean_text(payload.get("source")) + elif result["kind"] == "assistant_hdhive": + if result["stage"] == "candidate": + candidates = payload.get("candidates") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + result["total_candidates"] = len(candidates) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["stage"] == "resource": + selected = dict(payload.get("selected_candidate") or {}) + resources = payload.get("resources") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), 10)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + result["selected_title"] = self._clean_text(selected.get("title")) + result["selected_year"] = self._clean_text(selected.get("year")) + result["total_resources"] = len(resources) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["kind"] == "assistant_p115_login": + result["client_type"] = self._clean_text(payload.get("client_type")) or self._p115_client_type + result["recovery"] = self._assistant_recovery_public_data( + session_state={ + **result, + "pending_p115": self._pending_p115_public_data(payload), + "saved_plan": saved_plan, + }, + action_templates=[ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": result.get("session_id"), "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": result.get("session_id")}, + ), + ], + ) + return result + + def _assistant_plan_only_session_brief_public_data(self, session_id: str) -> Dict[str, Any]: + session_name = self._assistant_session_name_from_id(session_id) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + latest = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest.get("plan_id")) + latest_executed = bool(latest.get("executed")) + return { + "session": session_name, + "session_id": self._assistant_session_id(session_name), + "kind": "assistant_workflow_plan", + "stage": "planned", + "updated_at": self._safe_int(latest.get("created_at"), 0), + "updated_at_text": self._clean_text(latest.get("created_at_text")), + "keyword": "", + "target_path": "", + "has_pending_p115": False, + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": latest or None, + "recovery": { + "mode": "resume_saved_plan" if saved_plan.get("has_pending") else "followup_executed_plan" if latest_executed and latest_plan_id else "inspect_plan_only_session", + "reason": "当前会话只有 dry_run 计划,尚未生成交互会话缓存", + "can_resume": bool(saved_plan.get("has_pending") or (latest_executed and latest_plan_id)), + "recommended_action": "execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + "recommended_tool": "agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + "action_template": self._assistant_action_template( + name="execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + description="按 session_id 执行该会话最近一条待执行计划" if saved_plan.get("has_pending") else "按最近已执行计划自动追踪下载、订阅或入库后续状态" if latest_executed and latest_plan_id else "查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute" if saved_plan.get("has_pending") else "/api/v1/plugin/AgentResourceOfficer/assistant/action" if latest_executed and latest_plan_id else "/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True} if saved_plan.get("has_pending") else {"session_id": self._assistant_session_id(session_name), "name": "query_execution_followup", "plan_id": latest_plan_id} if latest_executed and latest_plan_id else {"session_id": self._assistant_session_id(session_name)}, + ), + "alternatives": ["execute_session_latest_plan", "inspect_session"] if saved_plan.get("has_pending") else ["query_execution_followup", "inspect_session"] if latest_executed and latest_plan_id else ["inspect_session"], + }, + } + + @staticmethod + def _assistant_action_template( + *, + name: str, + description: str, + endpoint: str, + body: Dict[str, Any], + method: str = "POST", + tool: str = "", + ) -> Dict[str, Any]: + body_payload = dict(body or {}) + compact_paths = [ + "/assistant/action", + "/assistant/actions", + "/assistant/workflow", + "/assistant/plan/execute", + "/assistant/recover", + "/assistant/route", + "/assistant/pick", + "/assistant/session", + "/assistant/sessions", + "/assistant/history", + "/assistant/plans", + "/assistant/readiness", + "/assistant/capabilities", + ] + if "compact" not in body_payload and any(path in endpoint for path in compact_paths): + body_payload["compact"] = True + action_body: Dict[str, Any] = {"name": name} + for key in [ + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "status", + "hash", + "target", + "control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "top", + "page", + "plan_id", + "prefer_unexecuted", + "preferences", + "compact", + "mode", + "source", + ]: + if key in body_payload: + action_body[key] = body_payload.get(key) + return { + "name": name, + "description": description, + "endpoint": endpoint, + "method": method, + "tool": tool, + "body": body_payload, + "action_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "action_tool": "agent_resource_officer_execute_action", + "action_body": action_body, + } + + @staticmethod + def _assistant_compact_action_templates( + primary: Optional[Dict[str, Any]] = None, + templates: Optional[List[Dict[str, Any]]] = None, + *, + limit: int = 6, + ) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + seen: set[str] = set() + for item in [primary, *(templates or [])]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(dict(item)) + if len(result) >= max(1, limit): + break + return result + + @staticmethod + def _assistant_compact_next_actions( + primary: Optional[List[Any]] = None, + secondary: Optional[List[Any]] = None, + *, + limit: int = 6, + ) -> List[str]: + result: List[str] = [] + seen: set[str] = set() + for item in [*(primary or []), *(secondary or [])]: + name = str(item or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(name) + if len(result) >= max(1, limit): + break + return result + + def _assistant_action_templates(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: + session_name = self._clean_text(data.get("session")) or "default" + session_id = self._clean_text(data.get("session_id")) or self._assistant_session_id(session_name) + base_route = { + "session": session_name, + "session_id": session_id, + } + base_pick = { + "session": session_name, + "session_id": session_id, + } + base_state = { + "session": session_name, + "session_id": session_id, + } + templates: List[Dict[str, Any]] = [] + preference_status = self._assistant_preferences_status_brief(session=session_name) + preference_template = self._assistant_action_template( + name="preferences_save", + description="保存智能体片源偏好;首次接入建议先询问用户后再保存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + tool="agent_resource_officer_preferences", + body={**base_state, "preferences": self._assistant_default_preferences_template()}, + ) + + if not data.get("has_session"): + templates = [] + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + saved_plan = dict(data.get("saved_plan") or {}) + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + templates.extend([ + self._assistant_action_template( + name="start_pansou_search", + description="发起新的盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "pansou", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="start_hdhive_search", + description="发起新的影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "hdhive", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="query_mp_media_detail", + description="使用 MoviePilot 原生识别确认媒体信息和 TMDB/Douban/IMDB ID", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_media_detail", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_recommendations", + description="查看 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "start_mp_recommendations", "source": "tmdb_trending", "media_type": "all"}, + ), + self._assistant_action_template( + name="query_mp_download_tasks", + description="查看 MP 下载任务状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_tasks", "status": "downloading"}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看 MP 下载历史,并关联整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="聚合查看 MP 下载任务、下载历史和整理/入库历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_lifecycle_status", "keyword": "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="query_mp_sites", + description="查看 MP 站点启用状态和 Cookie 是否存在,不返回 Cookie 明文", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看 MP 订阅列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_subscribes", "status": "all", "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看 MP 最近整理/入库历史,用于判断下载后是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_transfer_history", "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="start_115_login", + description="发起新的 115 扫码登录", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_start"}, + ), + ]) + return templates + + kind = self._clean_text(data.get("kind")) + stage = self._clean_text(data.get("stage")) + pending = dict(data.get("pending_p115") or {}) + saved_plan = dict(data.get("saved_plan") or {}) + target_path = self._clean_text(data.get("target_path")) + + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + + templates.append( + self._assistant_action_template( + name="inspect_session_state", + description="重新获取当前会话详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body=base_state, + ) + ) + + if kind == "assistant_pansou": + templates.extend([ + self._assistant_action_template( + name="pick_pansou_result", + description="按编号选择盘搜结果继续转存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._p115_default_path}, + ), + self._assistant_action_template( + name="plan_pansou_result", + description="按编号生成盘搜转存计划,不立即写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._p115_default_path}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前盘搜结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_pansou_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + self._assistant_action_template( + name="handoff_pansou_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_detail", + description="查看当前盘搜首选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_plan", + description="为当前盘搜首选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_pansou_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前盘搜首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_pansou_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_mp": + templates.extend([ + self._assistant_action_template( + name="query_mp_best_result_detail", + description="查看当前 MP 搜索结果里评分最高的 PT 候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_best_result_detail"}, + ), + self._assistant_action_template( + name="pick_mp_best_download", + description="按当前评分最高的 MP 搜索结果生成下载计划;不会静默下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_best_download"}, + ), + self._assistant_action_template( + name="query_mp_search_result_detail", + description="按编号查看 MP 原生搜索结果详情和 PT 评分理由", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_search_result_detail", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="pick_mp_download", + description="按编号为 MP 原生搜索结果生成下载计划;不会立即下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_download", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe", + description="按当前关键词生成 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe_search", + description="按当前关键词生成“订阅并搜索”计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe_search", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前原生搜索结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_mp_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_mp_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_mp_best_detail", + description="查看当前 MP 搜索里评分最高的候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_mp_best_plan", + description="为当前 MP 搜索里评分最高的候选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_mp_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前 MP 首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_mp_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + ]) + elif kind == "assistant_mp_download_tasks": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="pause_mp_download", + description="按编号暂停下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_download", + description="按编号恢复下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_download", + description="按编号删除下载任务;默认不删除文件", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "delete", "target": "<1-N>", "delete_files": False}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="当前没有下载中任务,改查下载历史和整理状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_download_history": + templates.extend([ + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": data.get("keyword") or "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_downloaders": + templates.extend([ + self._assistant_action_template( + name="query_mp_sites", + description="查看 PT 站点启用状态和 Cookie 是否存在", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_sites": + templates.extend([ + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_subscribes": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="search_mp_subscribe", + description="按编号触发订阅搜索;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "search", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="pause_mp_subscribe", + description="按编号暂停订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_subscribe", + description="按编号恢复订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_subscribe", + description="按编号删除订阅", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "delete", "target": "<1-N>"}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="start_mp_subscribe", + description="按关键词生成新的 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_lifecycle_status": + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="继续查看 MP 下载历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "title": data.get("keyword") or "", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_recommend": + templates.extend([ + self._assistant_action_template( + name="pick_recommend_smart_decision", + description="按编号选择推荐条目并进入统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_decision"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_plan", + description="按编号选择推荐条目并直接生成统一资源计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_plan"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_execute", + description="按编号选择推荐条目并直接进入统一资源执行链", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_execute"}, + ), + self._assistant_action_template( + name="pick_recommend_mp_search", + description="按编号选择推荐条目并进入 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "mp"}, + ), + self._assistant_action_template( + name="pick_recommend_hdhive_search", + description="按编号选择推荐条目并进入影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "hdhive"}, + ), + self._assistant_action_template( + name="pick_recommend_pansou_search", + description="按编号选择推荐条目并进入盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "pansou"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "candidate": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_candidate", + description="按编号选择影巢候选影片进入资源列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="candidate_detail", + description="补充当前候选页详情,例如主演", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "detail"}, + ), + self._assistant_action_template( + name="candidate_next_page", + description="翻到候选下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢候选对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "resource": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_resource", + description="按编号选择影巢资源,解锁并路由到对应网盘", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="plan_hdhive_resource", + description="按编号生成影巢解锁/转存计划,不立即扣分或写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="resource_next_page", + description="翻到影巢资源下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢资源列表对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_p115_login": + templates.extend([ + self._assistant_action_template( + name="check_115_login", + description="检查 115 扫码是否已确认,并在成功后自动继续待任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_check"}, + ), + self._assistant_action_template( + name="show_115_status", + description="查看当前 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_status"}, + ), + ]) + + if pending.get("has_pending"): + templates.extend([ + self._assistant_action_template( + name="resume_pending_115", + description="继续当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_resume"}, + ), + self._assistant_action_template( + name="cancel_pending_115", + description="取消当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_cancel"}, + ), + ]) + + templates.append( + self._assistant_action_template( + name="clear_current_session", + description="清理当前会话缓存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session/clear", + tool="agent_resource_officer_session_clear", + body=base_state, + ) + ) + return templates + + def _assistant_sessions_public_data( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> Dict[str, Any]: + kind_filter = self._clean_text(kind) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + items_by_session: Dict[str, Dict[str, Any]] = {} + for session_id, payload in (self._session_cache or {}).items(): + if not str(session_id).startswith("assistant::"): + continue + session = dict(payload or {}) + if self._is_session_expired(session): + continue + brief = self._assistant_session_brief_public_data(str(session_id), session) + items_by_session[brief.get("session_id") or str(session_id)] = brief + for plan in (self._workflow_plans or {}).values(): + current = dict(plan or {}) + plan_session_id = self._clean_text(current.get("session_id")) + if not plan_session_id: + continue + brief = items_by_session.get(plan_session_id) + if brief: + if not brief.get("has_saved_plan"): + refreshed = self._assistant_session_brief_public_data(plan_session_id, self._load_session(plan_session_id) or {}) + items_by_session[plan_session_id] = refreshed + continue + items_by_session[plan_session_id] = self._assistant_plan_only_session_brief_public_data(plan_session_id) + items: List[Dict[str, Any]] = [] + for brief in items_by_session.values(): + if kind_filter and brief.get("kind") != kind_filter: + continue + if has_pending_p115 is not None and bool(brief.get("has_pending_p115")) != bool(has_pending_p115): + continue + items.append(brief) + items.sort(key=lambda item: self._safe_int(item.get("updated_at"), 0), reverse=True) + return { + "total": len(items), + "limit": max_limit, + "items": items[:max_limit], + "filters": { + "kind": kind_filter, + "has_pending_p115": has_pending_p115, + }, + "action_templates": [ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": "", "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": ""}, + ), + self._assistant_action_template( + name="clear_session_by_id", + description="按 session_id 清理单个会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"session_id": ""}, + ), + ], + "recovery": ( + dict((items[:max_limit][0] or {}).get("recovery") or {}) + if (items[:max_limit] and isinstance(items[:max_limit][0], dict)) + else { + "mode": "start_new", + "reason": "当前没有活跃会话,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + } + + def _assistant_recover_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + requested_session = self._clean_text(session) + requested_session_id = self._clean_text(session_id) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + if requested_session or requested_session_id: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=requested_session or "default", + session_id=requested_session_id, + ) + state = self._assistant_session_public_data(session=session_name) + return { + "scope": "session", + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "selected_session": { + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "kind": state.get("kind"), + "stage": state.get("stage"), + "keyword": state.get("keyword"), + "has_pending_plan": bool((state.get("saved_plan") or {}).get("has_pending")), + "has_pending_p115": bool((state.get("pending_p115") or {}).get("has_pending")), + }, + "session_state": state, + "sessions": None, + "recovery": dict(state.get("recovery") or self._assistant_recovery_public_data(session_state=state)), + } + + sessions = self._assistant_sessions_public_data(limit=max_limit) + items = [dict(item or {}) for item in (sessions.get("items") or []) if isinstance(item, dict)] + selected: Optional[Dict[str, Any]] = None + + current_recovery = dict(sessions.get("recovery") or {}) + template_body = dict(((current_recovery.get("action_template") or {}).get("body") or {})) + preferred_session_id = self._clean_text(template_body.get("session_id")) + if preferred_session_id: + selected = next((item for item in items if self._clean_text(item.get("session_id")) == preferred_session_id), None) + if not selected: + selected = next((item for item in items if bool((item.get("recovery") or {}).get("can_resume"))), None) + if not selected: + selected = next((item for item in items if item.get("has_pending_plan") or item.get("has_pending_p115")), None) + if not selected and items: + selected = items[0] + + if selected: + session_name = self._clean_text(selected.get("session")) or "default" + selected_session_id = self._clean_text(selected.get("session_id")) or self._assistant_session_id(session_name) + state = self._assistant_session_public_data(session=session_name) + recovery = dict(state.get("recovery") or selected.get("recovery") or current_recovery) + selected = {**selected, "recovery": recovery} + return { + "scope": "global", + "session": session_name, + "session_id": selected_session_id, + "selected_session": selected, + "session_state": state, + "sessions": sessions, + "recovery": recovery, + } + + state = self._assistant_session_public_data(session="default") + return { + "scope": "global", + "session": "default", + "session_id": state.get("session_id") or self._assistant_session_id("default"), + "selected_session": None, + "session_state": state, + "sessions": sessions, + "recovery": dict(state.get("recovery") or current_recovery), + } + + def _format_assistant_recover_text(self, data: Dict[str, Any]) -> str: + recovery = dict((data or {}).get("recovery") or {}) + selected = dict((data or {}).get("selected_session") or {}) + lines = [ + "Agent影视助手 恢复入口", + f"范围:{(data or {}).get('scope') or 'session'}", + f"会话:{(data or {}).get('session') or 'default'}", + f"模式:{recovery.get('mode') or 'unknown'}", + f"原因:{recovery.get('reason') or '-'}", + f"可恢复:{'是' if recovery.get('can_resume') else '否'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + if recovery.get("recommended_tool"): + lines.append(f"推荐 Tool:{recovery.get('recommended_tool')}") + if selected.get("kind") or selected.get("keyword"): + detail = " / ".join( + str(item) + for item in [ + selected.get("kind"), + selected.get("stage"), + selected.get("keyword"), + ] + if item + ) + lines.append(f"当前状态:{detail}") + if recovery.get("can_resume"): + lines.append("如需直接恢复,可调用 assistant/recover 并传 execute=true。") + return "\n".join(lines) + + def _assistant_recover_response_data(self, data: Dict[str, Any], compact: bool = False) -> Dict[str, Any]: + if not compact: + return self._assistant_response_data(session=(data or {}).get("session") or "default", data=data) + + payload = dict(data or {}) + session_state = dict(payload.pop("session_state", {}) or {}) + payload.pop("sessions", None) + recovery = dict(payload.get("recovery") or {}) + selected = payload.get("selected_session") + if isinstance(selected, dict) and selected.get("recovery"): + selected = dict(selected) + selected.pop("recovery", None) + payload["selected_session"] = selected + + session_name = self._clean_text(payload.get("session") or session_state.get("session")) or "default" + session_id = self._clean_text(payload.get("session_id") or session_state.get("session_id")) or self._assistant_session_id(session_name) + action_template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + session_templates = session_state.get("action_templates") if isinstance(session_state.get("action_templates"), list) else [] + payload.update({ + "protocol_version": "assistant.v1", + "compact": True, + "session": session_name, + "session_id": session_id, + "next_actions": [ + item for item in [ + recovery.get("recommended_action"), + *(session_state.get("suggested_actions") or []), + ] + if item + ][:6], + "action_templates": self._assistant_compact_action_templates(action_template, session_templates), + }) + return payload + + def _assistant_session_compact_data(self, session_state: Dict[str, Any]) -> Dict[str, Any]: + state = dict(session_state or {}) + recovery = dict(state.get("recovery") or {}) + saved_plan = dict(state.get("saved_plan") or {}) + pending_p115 = dict(state.get("pending_p115") or {}) + payload: Dict[str, Any] = { + "protocol_version": "assistant.v1", + "action": "session_state", + "ok": True, + "compact": True, + "has_session": bool(state.get("has_session")), + "session": self._clean_text(state.get("session")) or "default", + "session_id": self._clean_text(state.get("session_id")), + "kind": self._clean_text(state.get("kind")), + "stage": self._clean_text(state.get("stage")), + "keyword": self._clean_text(state.get("keyword")), + "target_path": self._clean_text(state.get("target_path")), + "updated_at": state.get("updated_at"), + "updated_at_text": state.get("updated_at_text"), + "saved_plan": { + "has_pending": bool(saved_plan.get("has_pending")), + "plan_id": self._clean_text((saved_plan.get("latest") or {}).get("plan_id") or saved_plan.get("plan_id")), + }, + "pending_p115": { + "has_pending": bool(pending_p115.get("has_pending")), + "target_path": self._clean_text(pending_p115.get("target_path")), + "retry_count": pending_p115.get("retry_count"), + }, + "recovery": recovery, + "next_actions": state.get("suggested_actions") or [], + "action_templates": self._assistant_compact_action_templates( + recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None, + state.get("action_templates") if isinstance(state.get("action_templates"), list) else [], + ), + } + for key in [ + "result_count", + "total_candidates", + "page", + "total_pages", + "selected_candidate", + "total_resources", + "resource_count_115", + "resource_count_quark", + "score_summary", + "client_type", + ]: + if key in state: + payload[key] = state.get(key) + return payload + + def _assistant_sessions_compact_data(self, sessions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(sessions_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + recovery = dict(item.get("recovery") or {}) + items.append({ + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "keyword": self._clean_text(item.get("keyword")), + "target_path": self._clean_text(item.get("target_path")), + "updated_at": item.get("updated_at"), + "updated_at_text": item.get("updated_at_text"), + "has_pending_plan": bool(item.get("has_pending_plan")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "recovery_mode": self._clean_text(recovery.get("mode")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + }) + recovery = dict(data.get("recovery") or {}) + return { + "protocol_version": "assistant.v1", + "action": "sessions", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "filters": data.get("filters") or {}, + "items": items, + "recovery": recovery, + "next_actions": [recovery.get("recommended_action")] if recovery.get("recommended_action") else [], + "action_templates": [recovery.get("action_template")] if isinstance(recovery.get("action_template"), dict) else [], + } + + def _assistant_history_compact_data(self, history_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(history_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + summary = dict(item.get("summary") or {}) + steps = summary.get("steps") + items.append({ + "time_text": self._clean_text(item.get("time_text")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "workflow": self._clean_text(summary.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "message_head": self._clean_text(item.get("message_head")), + "steps": steps if isinstance(steps, int) else None, + }) + return { + "protocol_version": "assistant.v1", + "action": "history", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "items": items, + } + + def _assistant_plans_compact_data(self, plans_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plans_data or {}) + items: List[Dict[str, Any]] = [] + first_pending: Optional[Dict[str, Any]] = None + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + plan_id = self._clean_text(item.get("plan_id")) + executed = bool(item.get("executed")) + compact_item = { + "plan_id": plan_id, + "workflow": self._clean_text(item.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "executed": executed, + "action_count": self._safe_int(item.get("action_count"), 0), + "created_at_text": self._clean_text(item.get("created_at_text")), + "last_success": item.get("last_success"), + "last_message": self._clean_text(item.get("last_message")), + } + items.append(compact_item) + if not executed and first_pending is None and plan_id: + first_pending = compact_item + templates: List[Dict[str, Any]] = [] + if first_pending: + templates.append(self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": first_pending.get("plan_id"), + "session": first_pending.get("session"), + "session_id": first_pending.get("session_id"), + "prefer_unexecuted": True, + }, + )) + return { + "protocol_version": "assistant.v1", + "action": "plans", + "ok": True, + "compact": True, + "total": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_matching": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_all": data.get("total_all") if data.get("total_all") is not None else (data.get("total") or 0), + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "executed": data.get("executed"), + "include_actions": False, + "items": items, + "next_actions": ["execute_plan"] if first_pending else [], + "action_templates": templates, + } + + def _assistant_compact_action_results(self, rows: Any) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for item in rows or []: + if not isinstance(item, dict): + continue + results.append({ + "index": item.get("index"), + "name": self._clean_text(item.get("name")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "ok": bool(item.get("ok")) if "ok" in item else bool(item.get("success")), + "message_head": self._clean_text(item.get("message_head")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "next_actions": item.get("next_actions") or [], + }) + if isinstance(item.get("score_summary"), dict): + results[-1]["score_summary"] = item.get("score_summary") + if isinstance(item.get("diagnosis_summary"), dict): + results[-1]["diagnosis_summary"] = item.get("diagnosis_summary") + return results + + def _assistant_plan_execute_followup( + self, + *, + workflow: str, + session: str, + session_id: str, + session_state: Optional[Dict[str, Any]] = None, + ok: bool, + plan_id: str = "", + ) -> Dict[str, Any]: + workflow_name = self._clean_text(workflow) + if not ok and workflow_name != "smart_resource_plan": + return {"next_actions": [], "action_templates": [], "recommended_action": "", "follow_up_hint": ""} + + workflow_stage = workflow_name + state = dict(session_state or {}) + session_name = self._clean_text(session or state.get("session")) or "default" + session_cache = self._clean_text(session_id or state.get("session_id")) or self._assistant_session_id(session_name) + keyword = self._clean_text(state.get("keyword")) + plan_execute_body = dict(state.get("plan_execute_body") or {}) + if workflow_name == "smart_resource_plan": + inferred_source = self._clean_text( + state.get("source_type") + or ((state.get("best_candidate") or {}).get("source_type")) + or plan_execute_body.get("mode") + ).lower() + if inferred_source == "mp": + inferred_source = "mp_pt" + if inferred_source == "mp_pt": + workflow_name = "mp_best_download" + elif inferred_source in {"pansou", "hdhive"}: + workflow_name = inferred_source + base_route = { + "session": session_name, + "session_id": session_cache, + } + base_state = { + "session": session_name, + "session_id": session_cache, + } + keyword_value = keyword or "<关键词>" + next_actions: List[str] = [] + templates: List[Dict[str, Any]] = [] + follow_up_hint = "" + clean_plan_id = self._clean_text(plan_id) + + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动去查下载历史,再继续判断是否已整理、已入库或失败。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询下载后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看下载历史,并继续追踪整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "keyword": keyword_value, "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总失败线索并给出本地/PT 入库诊断建议", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + next_actions = ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查订阅列表,再决定是否继续搜索或进入入库追踪。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询订阅后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看订阅列表,确认当前订阅是否已生效", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_subscribes", "status": "all", "keyword": keyword, "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": keyword_value}, + ), + ] + elif workflow_name in {"share_transfer", "pansou_transfer_selected", "hdhive_unlock", "hdhive_unlock_selected", "pansou", "hdhive"}: + next_actions = ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查整理/入库历史,失败时再切到本地诊断。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询云盘转存后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看最近整理/入库历史,确认转存资源是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_transfer_history", "keyword": keyword, "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总下载后未入库或整理失败的诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + if workflow_stage == "smart_resource_plan" and keyword: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_local_diagnose"] + follow_up_hint = "推荐先跟进当前发现链的落库状态;它会先看统一后续,再判断是否已入库或需要本地诊断。" + templates = [ + templates[0], + self._assistant_action_template( + name="query_mp_ingest_status", + description="按推荐标题查看当前是否已下载、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + templates[-1], + ] + elif workflow_name == "ai_replay_failed_sample": + next_actions = ["query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + follow_up_hint = "先回看 AI 工作清单和失败样本,确认这次二次识别是否减少了失败样本。" + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + follow_up_hint = "先看本地诊断或入库状态,再回看 AI 工作清单确认失败样本是否已减少。" + templates = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="查看这次二次识别关联的本地/PT 入库诊断", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续处理剩余失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,确认当前仍有哪些标题或路径失败", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,确认失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + + category = "cloud_write" + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + category = "mp_download" + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + category = "mp_subscribe" + elif workflow_name == "ai_replay_failed_sample": + category = "ai_reingest" + followup_summary = self._assistant_followup_summary( + category=category, + stage=self._clean_text(workflow_stage), + recommended_action=next_actions[0] if next_actions else "", + follow_up_hint=follow_up_hint, + next_actions=next_actions, + action_templates=templates, + keyword=keyword, + ) + return { + "next_actions": next_actions, + "action_templates": templates, + "recommended_action": next_actions[0] if next_actions else "", + "follow_up_hint": follow_up_hint, + "followup_summary": followup_summary, + } + + async def _assistant_execution_followup( + self, + request: Request, + *, + session: str, + session_id: str, + plan_id: str = "", + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + target_plan_id = self._clean_text(plan_id) + plan = self._find_workflow_plan( + plan_id=target_plan_id, + session=session_name, + session_id=cache_key, + executed=True if not target_plan_id else None, + ) + if target_plan_id and plan and not bool(plan.get("executed")): + return { + "success": False, + "message": f"计划 {target_plan_id} 还没有执行,暂时无法做执行后追踪。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "plan_not_executed", + "plan_id": target_plan_id, + }), + } + if not plan: + latest_any = self._find_workflow_plan(session=session_name, session_id=cache_key, executed=None) + error_code = "executed_plan_not_found" + if latest_any and not bool(latest_any.get("executed")): + error_code = "latest_plan_not_executed" + return { + "success": False, + "message": "当前会话没有可追踪的已执行计划,请先执行下载、订阅或转存计划。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": error_code, + "plan_id": target_plan_id, + }), + } + + source_plan = self._workflow_plan_public_item(plan, include_actions=False) + state = self._load_session(self._clean_text(plan.get("session_id"))) or {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(plan.get("workflow")), + session=self._clean_text(plan.get("session")) or session_name, + session_id=self._clean_text(plan.get("session_id")) or cache_key, + session_state=state, + ok=bool(plan.get("last_success")) if plan.get("last_success") is not None else True, + plan_id=self._clean_text(plan.get("plan_id")), + ) + action_templates = [item for item in (followup.get("action_templates") or []) if isinstance(item, dict)] + resolved_template = next( + (item for item in action_templates if self._clean_text(item.get("name")) != "query_execution_followup"), + {}, + ) + resolved_name = self._clean_text(resolved_template.get("name")) + if not resolved_name: + return { + "success": False, + "message": "当前执行计划没有可自动继续的只读追踪动作。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "followup_not_available", + "plan_id": self._clean_text(plan.get("plan_id")), + "source_plan": source_plan, + }), + } + action_body = dict(resolved_template.get("action_body") or resolved_template.get("body") or {}) + action_body["compact"] = False + action_body["apikey"] = self._extract_apikey(request, {}) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "execution_followup", + "ok": bool(result.get("success")), + "plan_id": self._clean_text(plan.get("plan_id")), + "workflow": self._clean_text(plan.get("workflow")), + "source_plan": source_plan, + "recommended_action": self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(followup.get("follow_up_hint")), + "resolved_followup_action": resolved_name, + "followup_summary": followup.get("followup_summary") or {}, + }) + message_lines = [ + f"已执行后续追踪:{resolved_name}", + self._clean_text(result.get("message")), + ] + message_lines.extend(self._format_followup_summary_lines(followup.get("followup_summary"))) + return { + "success": bool(result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + async def _assistant_smart_followup( + self, + request: Request, + *, + session: str, + session_id: str, + keyword: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + title = self._clean_text(keyword) + hash_text = self._clean_text(hash_value) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=cache_key) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id")) + latest_executed = bool(latest_plan.get("executed")) + + if title or hash_text: + result = await self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=title, + hash_value=hash_text, + limit=limit, + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_lifecycle_status", + "smart_mode": "keyword_lifecycle", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按关键词查看本地/PT 状态", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if latest_executed and latest_plan_id: + result = await self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=latest_plan_id, + ) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": self._clean_text(payload.get("resolved_followup_action")) or "execution_followup", + "smart_mode": "executed_plan_followup", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按最近已执行计划继续追踪", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if saved_plan.get("has_pending"): + template = self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True}, + ) + return { + "success": False, + "message": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "smart_followup", + "ok": False, + "error_code": "latest_plan_not_executed", + "recommended_action": "execute_session_latest_plan", + "follow_up_hint": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "action_templates": [template], + "smart_mode": "pending_plan_blocked", + }), + } + + result = await self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=max(5, limit), + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_recent_activity", + "smart_mode": "recent_activity_fallback", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:当前没有指定片名,也没有已执行计划,先看最近活动", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + def _assistant_actions_compact_data(self, actions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(actions_data or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "execute_actions", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=self._clean_text(data.get("error_code")), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=self._assistant_result_message_head(data.get("message") or data.get("message_head")), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in ["decision_mode", "decision_reason"]: + if key in data: + payload[key] = data.get(key) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return payload + + @staticmethod + def _assistant_compact_command_summary(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + data = dict(payload or {}) + score_summary = data.get("score_summary") if isinstance(data.get("score_summary"), dict) else {} + candidates = [ + ("error_summary", data.get("error_summary") if isinstance(data.get("error_summary"), dict) else {}), + ("followup_summary", data.get("followup_summary") if isinstance(data.get("followup_summary"), dict) else {}), + ("decision_summary", data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}), + ("score_summary", score_summary.get("decision") if isinstance(score_summary.get("decision"), dict) else {}), + ] + for source, summary in candidates: + if not isinstance(summary, dict) or not summary: + continue + compact_commands = [ + AgentResourceOfficer._clean_text(item) + for item in (summary.get("compact_commands") or summary.get("recommended_commands") or []) + if AgentResourceOfficer._clean_text(item) + ] + preferred_command = AgentResourceOfficer._clean_text(summary.get("preferred_command")) + fallback_command = AgentResourceOfficer._clean_text(summary.get("fallback_command")) + if preferred_command or compact_commands: + return { + "command_source": source, + "command_policy": AgentResourceOfficer._clean_text(summary.get("command_policy")) or ("confirm_then_resume" if bool(summary.get("preferred_requires_confirmation")) else "safe_read_only"), + "preferred_requires_confirmation": bool(summary.get("preferred_requires_confirmation")), + "fallback_requires_confirmation": bool(summary.get("fallback_requires_confirmation")), + "can_auto_run_preferred": bool(summary.get("can_auto_run_preferred")) if "can_auto_run_preferred" in summary else not bool(summary.get("preferred_requires_confirmation")), + "preferred_command": preferred_command or (compact_commands[0] if compact_commands else ""), + "fallback_command": fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), + "compact_commands": compact_commands[:2], + "recommended_agent_behavior": AgentResourceOfficer._clean_text(summary.get("recommended_agent_behavior")), + "auto_run_command": AgentResourceOfficer._clean_text(summary.get("auto_run_command")), + "confirm_command": AgentResourceOfficer._clean_text(summary.get("confirm_command")), + "display_command": AgentResourceOfficer._clean_text(summary.get("display_command")), + "detail_short_command": AgentResourceOfficer._clean_text(summary.get("detail_short_command")), + "decision_short_command": AgentResourceOfficer._clean_text(summary.get("decision_short_command")), + "plan_short_command": AgentResourceOfficer._clean_text(summary.get("plan_short_command")), + "confirm_short_command": AgentResourceOfficer._clean_text(summary.get("confirm_short_command")), + "pansou_short_command": AgentResourceOfficer._clean_text(summary.get("pansou_short_command")), + "hdhive_short_command": AgentResourceOfficer._clean_text(summary.get("hdhive_short_command")), + "mp_short_command": AgentResourceOfficer._clean_text(summary.get("mp_short_command")), + } + return {} + + def _assistant_plan_execute_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + success_count = len([item for item in results if item.get("success")]) + last_result = results[-1] if results else {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(data.get("workflow")), + session=self._clean_text(data.get("session") or session_state.get("session")) or "default", + session_id=self._clean_text(data.get("session_id") or session_state.get("session_id")), + session_state=session_state, + ok=bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + plan_id=self._clean_text(data.get("plan_id")), + ) + payload = { + "protocol_version": "assistant.v1", + "action": "execute_plan", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or "write", + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "plan_id": self._clean_text(data.get("plan_id")), + "workflow": self._clean_text(data.get("workflow")), + "plan_auto_selected": bool(data.get("plan_auto_selected")), + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "plan_executed_at": data.get("plan_executed_at"), + "plan_executed_at_text": data.get("plan_executed_at_text"), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "result_summary": { + "success_count": success_count, + "failure_count": max(len(results) - success_count, 0), + "last_action": self._clean_text(last_result.get("action")), + "last_message_head": self._clean_text(last_result.get("message_head")), + }, + "recommended_action": self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")), + "next_actions": self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or session_state.get("suggested_actions") or [], + ), + "action_templates": self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ), + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=payload.get("recommended_action"), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("recovery"), dict): + payload["recovery"] = data.get("recovery") + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_single_action_compact_response(self, name: str, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or self._clean_text(name) or "execute_action", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action")) or self._clean_text(name)), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "name": self._clean_text(name), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + for key in ["plan_id", "workflow", "plan_auto_selected", "has_session", "has_pending"]: + if key in data: + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in [ + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "decision_mode", + "decision_reason", + "detail_short_command", + "decision_short_command", + "plan_short_command", + "confirm_short_command", + "pansou_short_command", + "hdhive_short_command", + "mp_short_command", + "auto_run_command", + "confirm_command", + "display_command", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + ]: + if key in data: + payload[key] = data.get(key) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_interaction_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "assistant_interaction", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action"))), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "keyword": self._clean_text(session_state.get("keyword")), + "target_path": self._clean_text(session_state.get("target_path")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target") or session_state.get("keyword")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("insights"), dict): + payload["insights"] = data.get("insights") + if isinstance(data.get("recommend_handoff"), dict): + payload["recommend_handoff"] = data.get("recommend_handoff") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in [ + "provider", + "source", + "requested_source", + "fallback_source", + "media_type", + "page", + "total_pages", + "selected_candidate", + "selected_resource", + "plan_id", + "workflow", + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "smart_plan_auto_selected", + "smart_execute_auto_selected", + "decision_mode", + "decision_reason", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + "selected_index", + "return_short_command", + ]: + if key in data: + payload[key] = data.get(key) + for key in ["best_candidate"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("items"), list): + payload["items"] = data.get("items") + for key in ["items", "candidates", "resources"]: + if isinstance(data.get(key), list): + payload[f"{key}_count"] = len(data.get(key) or []) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_workflow_plan_compact_data(self, plan_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plan_data or {}) + session_state = dict(data.get("session_state") or {}) + plan_id = self._clean_text(data.get("plan_id")) + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的 dry_run 计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"plan_id": plan_id}, + ) if plan_id else None + return { + "protocol_version": "assistant.v1", + "action": "workflow_plan", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "plan_id": plan_id, + "workflow": self._clean_text(data.get("workflow")), + "dry_run": True, + "estimated_steps": data.get("estimated_steps") or 0, + "ready_to_execute": bool(data.get("ready_to_execute")), + "execute_plan_endpoint": data.get("execute_plan_endpoint"), + "execute_plan_body": data.get("execute_plan_body") or {"plan_id": plan_id}, + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "preference_status": data.get("preference_status") or {}, + "needs_onboarding": bool((data.get("preference_status") or {}).get("needs_onboarding")), + "score_summary": data.get("score_summary") or {}, + "decision_summary": data.get("decision_summary") or {}, + "best_candidate": data.get("best_candidate") or {}, + "sources_checked": data.get("sources_checked") or [], + "available_sources": data.get("available_sources") or [], + "blocked_sources": data.get("blocked_sources") or [], + "effective_preferences": data.get("effective_preferences") or {}, + "session_preference_overrides": data.get("session_preference_overrides") or {}, + "smart_plan_auto_selected": bool(data.get("smart_plan_auto_selected")), + "detail_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("detail_short_command")), + "decision_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_short_command")), + "plan_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("plan_short_command")), + "confirm_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("confirm_short_command")), + "pansou_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("pansou_short_command")), + "hdhive_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("hdhive_short_command")), + "mp_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("mp_short_command")), + "next_actions": ["execute_plan"] if plan_id else [], + "action_templates": [template] if template else [], + } + + def _format_assistant_sessions_text( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> str: + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有活跃的 Agent影视助手 会话。" + lines = [ + f"当前活跃会话:{data.get('total') or 0} 个", + "可直接用 assistant/session 查看单个会话详情,也可按 session_id 直接恢复最近计划。", + ] + for idx, item in enumerate(items, 1): + line = f"{idx}. {item.get('session')} | {item.get('kind') or '-'} | {item.get('stage') or '-'}" + if item.get("keyword"): + line = f"{line} | {item.get('keyword')}" + lines.append(line) + detail_parts: List[str] = [] + if item.get("target_path"): + detail_parts.append(f"目录:{item.get('target_path')}") + if item.get("updated_at_text"): + detail_parts.append(f"更新:{item.get('updated_at_text')}") + if item.get("has_pending_p115"): + detail_parts.append("含待继续115任务") + if item.get("has_pending_plan"): + detail_parts.append("含待执行计划") + if item.get("selected_title"): + detail_parts.append(f"已选:{item.get('selected_title')}") + if item.get("result_count"): + detail_parts.append(f"结果:{item.get('result_count')}") + if item.get("total_candidates"): + detail_parts.append(f"候选:{item.get('total_candidates')}") + if item.get("total_resources"): + detail_parts.append(f"资源:{item.get('total_resources')}") + if detail_parts: + lines.append(" " + " | ".join(detail_parts)) + return "\n".join(lines) + + def _clear_assistant_sessions( + self, + *, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + cleared_ids: List[str] = [] + if self._clean_text(session_id) or self._clean_text(session): + _, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if cache_key in self._session_cache: + self._session_cache.pop(cache_key, None) + cleared_ids.append(cache_key) + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + kind_filter = self._clean_text(kind) + for current_session_id, payload in list((self._session_cache or {}).items()): + if len(cleared_ids) >= max_limit: + break + if not str(current_session_id).startswith("assistant::"): + continue + current = dict(payload or {}) + expired = self._is_session_expired(current) + if stale_only and not expired: + continue + if not stale_only and expired: + continue + if not all_sessions: + if kind_filter and self._clean_text(current.get("kind")) != kind_filter: + continue + if has_pending_p115 is not None: + has_pending = bool(self._clean_text(((current.get("pending_p115") or {}).get("share_url")))) + if has_pending != bool(has_pending_p115): + continue + if not kind_filter and has_pending_p115 is None and not stale_only: + continue + self._session_cache.pop(current_session_id, None) + cleared_ids.append(str(current_session_id)) + + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + def _format_assistant_session_summary(self, session: str = "default") -> str: + data = self._assistant_session_public_data(session=session) + if not data.get("has_session"): + return "\n".join([ + "当前没有活跃会话。", + "可直接调用 smart_entry 发起新操作,例如:", + "1. text=盘搜搜索 大君夫人", + "2. text=影巢搜索 蜘蛛侠", + "3. text=链接 https://115cdn.com/s/xxxx path=/待整理", + ]) + + lines = [ + "当前会话状态", + f"会话:{data.get('session')}", + f"类型:{data.get('kind') or '-'}", + f"阶段:{data.get('stage') or '-'}", + ] + if data.get("keyword"): + lines.append(f"关键词:{data.get('keyword')}") + if data.get("target_path"): + lines.append(f"目录:{data.get('target_path')}") + if data.get("updated_at_text"): + lines.append(f"最近更新:{data.get('updated_at_text')}") + if data.get("kind") == "assistant_pansou": + lines.append(f"结果数:{data.get('result_count') or 0}") + lines.append("下一步:调用 smart_pick,传入 choice=编号") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "candidate": + lines.append(f"候选数:{data.get('total_candidates') or 0}") + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=编号,或 action=详情 / 下一页") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "resource": + selected = data.get("selected_candidate") or {} + if selected.get("title"): + lines.append(f"已选影片:{selected.get('title')} ({selected.get('year') or '-'})") + lines.append(f"资源数:{data.get('total_resources') or 0}") + if data.get("page") and data.get("total_pages"): + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=资源编号,或 action=下一页") + elif data.get("kind") == "assistant_p115_login": + lines.append(f"扫码客户端:{data.get('client_type') or self._p115_client_type}") + lines.append("下一步:调用 smart_entry,传入 text=检查115登录") + + pending = data.get("pending_p115") or {} + if pending.get("has_pending"): + lines.append("存在待继续的 115 任务") + lines.append(f"任务:{pending.get('title')}") + lines.append(f"待转目录:{pending.get('target_path')}") + + actions = data.get("suggested_actions") or [] + if actions: + lines.append("建议动作:" + " / ".join(str(item) for item in actions if item)) + return "\n".join(lines) + + def _session_key_for_tool(self, session: str = "default") -> str: + clean_session = self._clean_text(session) or "default" + if clean_session.startswith("assistant::"): + return clean_session + return self._assistant_session_id(clean_session) + + def _execute_pending_p115_share( + self, + *, + session_id: str, + state: Dict[str, Any], + trigger: str, + ) -> Tuple[bool, str, Dict[str, Any]]: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return False, "", {} + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + transfer_ok, result, transfer_message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(pending.get("access_code")), + path=target_path, + trigger=trigger, + ) + if transfer_ok: + self._clear_pending_p115_share(session_id) + message = "\n".join( + [ + "115 转存已完成", + f"目录:{result.get('path') or target_path}", + f"结果:{transfer_message or result.get('message') or 'success'}", + ] + ) + return True, message, {"provider": "115", "result": result} + + failure_message = self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path, + ) + current_state = self._load_session(session_id) or dict(state or {}) + pending["retry_count"] = max(0, self._safe_int(pending.get("retry_count"), 0)) + 1 + pending["last_attempt_at"] = int(time.time()) + pending["last_error"] = failure_message + current_state["pending_p115"] = pending + if not current_state.get("kind"): + current_state["kind"] = "assistant_p115_pending" + current_state["stage"] = "pending_login" + self._save_session(session_id, current_state) + return False, failure_message, {"provider": "115", "result": result} + + async def _resume_pending_p115_share( + self, + request: Request, + body: Dict[str, Any], + *, + session_id: str, + state: Dict[str, Any], + ) -> Tuple[bool, str, Dict[str, Any]]: + return self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 115 登录后自动继续", + ) + + def _format_p115_status_summary(self, *, title: str = "115 当前状态") -> str: + status = self._p115_status_snapshot() + lines = [ + title, + f"可用状态:{'可用' if status.get('ready') else '待修复'}", + f"默认目录:{status.get('default_target_path') or self._p115_default_path}", + f"扫码客户端:{status.get('client_type') or self._p115_client_type}", + ] + if status.get("direct_source"): + lines.append(f"直转来源:{status.get('direct_source')}") + elif status.get("helper_ready"): + lines.append("直转来源:P115StrmHelper") + if status.get("cookie_mode") == "client_cookie": + lines.append("当前会话:已保存扫码会话") + elif status.get("cookie_mode") == "invalid_cookie": + lines.append("当前会话:已配置但看起来不是扫码会话") + else: + lines.append("当前会话:复用 115 助手客户端") + if status.get("message") and not status.get("ready"): + lines.append(f"详情:{status.get('message')}") + lines.append(self._format_p115_next_actions(status)) + return "\n".join(lines) + + def _format_p115_help_text(self) -> str: + status = self._p115_status_snapshot() + final_path = status.get("default_target_path") or self._p115_default_path + lines = [ + "115 使用帮助", + f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}", + f"默认目录:{final_path}", + "如果 115 转存因登录问题失败,我会记住这次任务;扫码成功后回复 检查115登录,会自动继续执行。", + "常用示例:", + f"1. 链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 影巢搜索 蜘蛛侠", + "3. 盘搜搜索 大君夫人", + "4. 115登录", + "5. 检查115登录", + "6. 115状态", + "7. 继续115任务 / 取消115任务", + self._format_p115_next_actions(status), + ] + return "\n".join(lines) + + def _format_assistant_help_text(self, session: str = "default") -> str: + session_name = self._clean_text(session) or "default" + lines = [ + "Agent影视助手 使用帮助", + f"当前会话:{session_name}", + "推荐优先使用原生 Tool:agent_resource_officer_smart_entry 与 agent_resource_officer_smart_pick。", + "smart_entry 常用示例:", + "1. text=盘搜搜索 大君夫人", + "2. text=搜索 大君夫人 默认走盘搜", + "3. text=云盘搜索 大君夫人 只走盘搜 + 影巢", + "4. text=影巢搜索 蜘蛛侠", + "5. text=MP搜索 蜘蛛侠 或 PT搜索 蜘蛛侠", + "6. text=115登录", + "7. text=检查115登录", + "8. text=链接 https://115cdn.com/s/xxxx path=/待整理", + "9. text=链接 https://pan.quark.cn/s/xxxx 位置=分享", + "10. text=转存 蜘蛛侠 默认等同 115转存;text=下载 蜘蛛侠 只走 MP/PT,先展示候选和 PT 资源,不自动提交下载", + "11. text=下载任务;暂停下载 1 / 恢复下载 1 / 删除下载 1 会先生成计划", + "12. text=站点状态;下载器状态 用于排查 PT 搜索/下载环境", + "13. text=记录 片名 用于判断资源是否提交过下载并进入整理流程", + "14. text=状态 片名 一次查看下载任务、下载历史和入库历史", + "15. text=识别 片名 使用 MoviePilot 原生识别确认 TMDB/Douban/IMDB 信息", + "16. text=订阅列表;搜索订阅 1 / 暂停订阅 1 / 恢复订阅 1 / 删除订阅 1 会先生成计划", + "17. text=入库记录;入库失败 片名 用于判断下载后是否已经整理落库", + "18. text=执行计划 执行当前会话最近待执行计划;text=执行 plan-xxxx 精确执行指定计划", + "19. text=偏好 / 保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库 / 重置偏好", + "20. text=后续 / 最近 / 入库 片名 / 诊断 片名 是更省 token 的本地/PT 跟踪短命令", + "21. text=跟进 / 跟进 片名 是统一跟进入口:有已执行计划时自动跟执行后状态,有片名时直接看生命周期", + "smart_pick 常用示例:", + "1. choice=1", + "2. action=详情", + "3. action=下一页", + "MP 搜索结果里,choice=1 会先展示 PT 详情和评分理由;确认下载再发 text=下载1。", + "MP 搜索结果里,action=最佳 会展示当前评分最高候选,适合智能体省 token 决策。", + "MP 搜索结果里,text=下载最佳 会按当前最高分候选生成下载计划,不会静默下载。", + "说明:同一个 session 会自动串起候选列表、资源列表、115 待任务与扫码续跑。", + self._format_p115_next_actions(self._p115_status_snapshot()), + ] + pending_summary = self._pending_p115_summary(self._load_session(self._assistant_session_id(session_name)) or {}) + if pending_summary: + lines.extend(["", pending_summary]) + return "\n".join(line for line in lines if line) + + def _assistant_capabilities_public_data(self) -> Dict[str, Any]: + return { + "version": self.plugin_version, + "defaults": { + "hdhive_path": self._hdhive_default_path, + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "p115_client_type": self._p115_client_type, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "pt_min_seeders": self._default_assistant_preferences().get("pt_min_seeders"), + "auto_ingest": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_enabled": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_score_threshold": self._default_assistant_preferences().get("auto_ingest_score_threshold"), + "confirm_score_threshold": self._default_assistant_preferences().get("confirm_score_threshold"), + }, + "smart_entry": { + "supports_text": True, + "supports_structured_fields": True, + "modes": ["mp", "pansou", "hdhive"], + "actions": [ + "assistant_help", + "p115_qrcode_start", + "p115_qrcode_check", + "p115_status", + "p115_help", + "p115_pending", + "p115_resume", + "p115_cancel", + "hdhive_checkin", + "hdhive_checkin_history", + "mp_media_detail", + "mp_download_tasks", + "mp_download_history", + "mp_lifecycle_status", + "mp_download_control", + "mp_downloaders", + "mp_sites", + "mp_subscribes", + "mp_subscribe_control", + "mp_transfer_history", + "mp_download", + "mp_download_best", + "mp_subscribe", + "mp_subscribe_search", + "mp_recommendations", + "execute_plan", + "plans_list", + "plans_clear", + "scoring_policy", + "preferences_get", + "preferences_save", + "preferences_reset", + ], + "structured_fields": [ + "session", + "session_id", + "path", + "mode", + "keyword", + "url", + "access_code", + "media_type", + "year", + "client_type", + "is_gambler", + "action", + "plan_id", + "status", + "hash", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "downloader", + "download_control", + "subscribe_control", + "delete_files", + "page", + "compact", + ], + }, + "assistant_preferences": { + "fields": ["session", "session_id", "user_key", "preferences", "reset", "compact"], + "description": "智能体片源偏好画像:云盘与 PT 分源评分都会读取这里;无偏好时建议先完成一次偏好询问。", + }, + "scoring_policy": self._assistant_scoring_policy_public_data(), + "smart_pick": { + "fields": ["session", "session_id", "choice", "action", "path", "compact"], + "actions": ["detail", "next_page"], + }, + "assistant_session": { + "fields": ["session", "session_id", "compact"], + "description": "compact=true 时返回低 token 会话快照,不嵌套完整 session_state。", + }, + "assistant_capabilities": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 能力清单,不嵌套完整 session_state。", + }, + "assistant_readiness": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 就绪状态,不嵌套完整 session_state。", + }, + "assistant_sessions": { + "fields": ["kind", "has_pending_p115", "compact", "limit"], + "description": "compact=true 时返回低 token 会话列表,不嵌套 default session_state。", + }, + "assistant_history": { + "fields": ["session", "session_id", "compact", "limit"], + "description": "compact=true 时返回低 token 执行历史,不嵌套 default session_state。", + }, + "assistant_action": { + "fields": [ + "name", + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "source", + "status", + "hash", + "target", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "control", + "subscribe_control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "page", + "plan_id", + "prefer_unexecuted", + "compact", + ], + "description": "compact=true 时返回低 token 单动作摘要,不嵌套完整 session_state。", + }, + "assistant_actions": { + "fields": [ + "actions", + "session", + "session_id", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 批量执行摘要,不嵌套完整 session_state。", + }, + "assistant_workflow": self._assistant_workflow_catalog(), + "assistant_plan_execute": { + "fields": [ + "plan_id", + "session", + "session_id", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 计划执行摘要,不嵌套完整 session_state。", + }, + "assistant_plans": { + "fields": [ + "session", + "session_id", + "executed", + "include_actions", + "compact", + "limit", + ], + "description": "compact=true 时返回低 token 计划列表,不嵌套 default session_state。", + }, + "assistant_plans_clear": { + "fields": [ + "plan_id", + "session", + "session_id", + "executed", + "all_plans", + "limit", + ], + }, + "assistant_recover": { + "fields": [ + "session", + "session_id", + "execute", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + "limit", + ], + "description": "单入口恢复协议:不传 session 时自动挑选最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可返回低 token 回执。", + }, + "assistant_maintain": { + "fields": [ + "execute", + "limit", + ], + "description": "低风险维护入口:execute=false 只返回建议;execute=true 清理过期会话和已执行计划,不清理待执行计划。", + }, + "assistant_request_templates": { + "fields": [ + "limit", + ], + "description": "轻量请求模板入口:返回外部智能体常用 assistant 请求模板,适合缓存为调用说明。", + }, + "session_tools": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/toolbox", + "assistant/request_templates", + "assistant/selfcheck", + "assistant/readiness", + "assistant/history", + "assistant/action", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + "assistant/plans/clear", + "assistant/recover", + "assistant/sessions", + "assistant/sessions/clear", + "assistant/session", + "assistant/session/clear", + ], + "response_envelope": { + "fields": [ + "protocol_version", + "action", + "ok", + "session", + "session_id", + "session_state", + "next_actions", + "action_templates", + ], + "description": "assistant/route 与 assistant/pick 返回的 data 中会统一附带当前会话状态、建议下一步动作与可直接调用的动作模板,上层智能体可直接按结构化字段继续编排。", + }, + "agent_tools": [ + "agent_resource_officer_capabilities", + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_toolbox", + "agent_resource_officer_request_templates", + "agent_resource_officer_selfcheck", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_history", + "agent_resource_officer_execute_action", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_plans", + "agent_resource_officer_plans_clear", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_preferences", + "agent_resource_officer_help", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + "agent_resource_officer_sessions", + "agent_resource_officer_sessions_clear", + "agent_resource_officer_session_state", + "agent_resource_officer_session_clear", + ], + } + + def _assistant_capabilities_compact_data(self, capabilities_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(capabilities_data or {}) + workflow_catalog = dict(data.get("assistant_workflow") or {}) + workflows = [ + self._clean_text(item.get("name")) + for item in workflow_catalog.get("workflows") or [] + if isinstance(item, dict) and self._clean_text(item.get("name")) + ] + compact_endpoints = [ + "assistant/capabilities", + "assistant/startup", + "assistant/maintain", + "assistant/request_templates", + "assistant/readiness", + "assistant/recover", + "assistant/session", + "assistant/sessions", + "assistant/history", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + ] + return { + "protocol_version": "assistant.v1", + "action": "capabilities", + "ok": True, + "compact": True, + "version": data.get("version"), + "defaults": data.get("defaults") or {}, + "smart_entry_modes": (data.get("smart_entry") or {}).get("modes") or [], + "smart_entry_actions": (data.get("smart_entry") or {}).get("actions") or [], + "smart_pick_actions": (data.get("smart_pick") or {}).get("actions") or [], + "workflows": workflows, + "request_templates": bool(data.get("assistant_request_templates")), + "scoring_policy": data.get("scoring_policy") or {}, + "recommended_start": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/selfcheck", + "assistant/toolbox", + "assistant/request_templates", + "assistant/readiness?compact=true", + ], + "compact_endpoints": compact_endpoints, + "agent_tools": data.get("agent_tools") or [], + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_readiness", "smart_entry", "assistant_workflow"], + } + + def _format_assistant_capabilities_text(self) -> str: + data = self._assistant_capabilities_public_data() + defaults = data.get("defaults") or {} + lines = [ + "Agent影视助手 能力说明", + f"版本:{data.get('version')}", + "推荐上层调用顺序:", + "1. 先看 capabilities 或 assistant/startup", + "2. 如需恢复会话,可先看 assistant/sessions", + "3. 再调用 smart_entry", + "4. 之后用 assistant/session 或 session_state 判断下一步", + "5. 最后再调用 smart_pick 或 session_clear", + "默认目录:", + f"- 影巢:{defaults.get('hdhive_path')}", + f"- 115:{defaults.get('p115_path')}", + f"- 夸克:{defaults.get('quark_path')}", + f"- 115 客户端:{defaults.get('p115_client_type')}", + f"影巢资源入口:{'开启' if defaults.get('hdhive_resource_enabled') else '关闭'};单资源积分上限:{defaults.get('hdhive_max_unlock_points')} 分(0 表示不限制)", + "默认评分策略:", + f"- PT 最低做种数:{defaults.get('pt_min_seeders')}", + f"- 建议确认分数线:{defaults.get('confirm_score_threshold')}", + f"- 自动入库:{'开启' if defaults.get('auto_ingest_enabled') else '关闭'};自动入库分数线:{defaults.get('auto_ingest_score_threshold')}", + "启动聚合包:assistant/startup,一次返回 pulse、自检、核心工具、端点和恢复建议,适合外部智能体开场调用", + "轻量启动探针:assistant/pulse,返回版本、关键服务状态与最佳恢复建议,适合外部智能体每次开场调用", + "轻量工具清单:assistant/toolbox,返回推荐工具、端点、工作流和命令示例,适合外部智能体初始化系统提示", + "轻量协议自检:assistant/selfcheck,返回 compact 模板、布尔解析和低 token 入口健康状态", + "启动探针:assistant/readiness,可直接判断外部智能体是否可以开始调用;compact=true 可减少嵌套回执", + "执行历史:assistant/history,可查看最近 action/workflow 的成功状态和摘要;compact=true 可减少嵌套回执", + "smart_entry 结构化字段:session / session_id / path / mode / keyword / url / access_code / media_type / year / client_type / action / plan_id", + "smart_entry 结构化模式:mp / pansou / hdhive", + "smart_entry 动作:assistant_help / p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel", + "smart_entry 与 smart_pick 支持 compact=true,可减少搜索与选择链路嵌套回执", + "smart_pick 字段:session / session_id / choice / action / path / compact", + "smart_pick 动作:detail / next_page", + "动作执行入口:assistant/action,可直接执行 action_templates 里的 name + body;compact=true 可减少嵌套回执", + "批量动作入口:assistant/actions,可一次执行多步 action_body;compact=true 可减少嵌套回执", + "预设工作流入口:assistant/workflow,可用 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / share_transfer / p115_status 等短参数场景;compact=true 可减少嵌套回执", + "计划执行入口:assistant/plan/execute,可执行 dry_run 返回的 plan_id;compact=true 可减少嵌套回执", + "自然语言计划确认:smart_entry 支持“执行计划”和“执行 plan-xxxx”,用于确认已生成的下载、订阅或控制计划", + "计划管理入口:assistant/plans 与 assistant/plans/clear,可查询或清理 dry_run 保存的计划;compact=true 可减少嵌套回执", + "单入口恢复:assistant/recover,可自动选择最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可减少回执字段", + "统一回执字段:protocol_version / action / ok / session / session_id / session_state / next_actions / action_templates", + ] + return "\n".join(lines) + + def _assistant_readiness_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + sessions = self._assistant_sessions_public_data(limit=10) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_resource_enabled: + warnings.append("影巢资源搜索/解锁已关闭,外部智能体应改用 MP 搜索或盘搜") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置,影巢相关工作流不可用") + if not p115_status.get("ready"): + warnings.append("115 当前不可用,需要先扫码或修复执行层") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置,夸克转存可能需要先刷新") + + ready_for_external_agent = bool(self._enabled) + pending_plans = self._assistant_plans_public_data(executed=False, limit=5) + pending_plan_templates = [ + self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": item.get("plan_id"), + "session": item.get("session"), + "session_id": item.get("session_id"), + "prefer_unexecuted": True, + }, + ) + for item in (pending_plans.get("items") or []) + if isinstance(item, dict) and self._clean_text(item.get("plan_id")) + ] + return { + "version": self.plugin_version, + "enabled": self._enabled, + "ready_for_external_agent": ready_for_external_agent, + "can_start": ready_for_external_agent, + "services": { + "p115": p115_status, + "hdhive": { + "configured": bool(self._hdhive_api_key), + "base_url": self._hdhive_base_url, + "default_path": self._hdhive_default_path, + }, + "quark": { + "configured": bool(self._quark_cookie), + "default_path": self._quark_default_path, + "auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + }, + }, + "active_sessions": { + "total": sessions.get("total") or 0, + "preview": sessions.get("items") or [], + }, + "saved_plans": { + "total": len(self._workflow_plans or {}), + "pending": len(pending_plans.get("items") or []), + "pending_preview": pending_plans.get("items") or [], + "action_templates": pending_plan_templates, + }, + "recovery": ( + { + "mode": "resume_saved_plan", + "reason": "当前存在待执行计划,可直接恢复", + "can_resume": True, + "recommended_action": self._clean_text((pending_plan_templates[0] or {}).get("name")) if pending_plan_templates else "", + "recommended_tool": self._clean_text((pending_plan_templates[0] or {}).get("tool")) if pending_plan_templates else "", + "action_template": pending_plan_templates[0] if pending_plan_templates else None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in pending_plan_templates[:5] + if self._clean_text(item.get("name")) + ], + } + if pending_plan_templates + else { + "mode": "start_new", + "reason": "当前没有待恢复计划,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + "recommended_entrypoints": [ + "GET /api/v1/plugin/AgentResourceOfficer/assistant/startup", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/readiness", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/actions", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/route", + ], + "recommended_tools": [ + "agent_resource_officer_startup", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_run_workflow", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_smart_entry", + ], + "warnings": warnings, + "suggested_first_call": { + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "body": { + "name": "pansou_search", + "session": "external-agent-demo", + "keyword": "片名", + }, + }, + } + + def _assistant_readiness_compact_data(self, readiness_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(readiness_data or {}) + services = dict(data.get("services") or {}) + p115 = dict(services.get("p115") or {}) + hdhive = dict(services.get("hdhive") or {}) + quark = dict(services.get("quark") or {}) + recovery = dict(data.get("recovery") or {}) + saved_plans = dict(data.get("saved_plans") or {}) + template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + return { + "protocol_version": "assistant.v1", + "action": "readiness", + "ok": bool(data.get("can_start")), + "compact": True, + "version": data.get("version"), + "enabled": bool(data.get("enabled")), + "can_start": bool(data.get("can_start")), + "services": { + "p115_ready": bool(p115.get("ready")), + "hdhive_configured": bool(hdhive.get("configured")), + "quark_configured": bool(quark.get("configured")), + }, + "active_sessions_total": (data.get("active_sessions") or {}).get("total") or 0, + "saved_plans_total": saved_plans.get("total") or 0, + "saved_plans_pending": saved_plans.get("pending") or 0, + "recovery": { + "mode": self._clean_text(recovery.get("mode")), + "can_resume": bool(recovery.get("can_resume")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + "recommended_tool": self._clean_text(recovery.get("recommended_tool")), + "reason": self._clean_text(recovery.get("reason")), + }, + "warnings": data.get("warnings") or [], + "next_actions": [ + item for item in [ + recovery.get("recommended_action") if recovery.get("can_resume") else "", + "assistant_workflow", + "smart_entry", + ] + if item + ], + "action_templates": [template] if template else [], + } + + def _format_assistant_readiness_text(self) -> str: + data = self._assistant_readiness_public_data() + services = data.get("services") or {} + p115 = services.get("p115") or {} + hdhive = services.get("hdhive") or {} + quark = services.get("quark") or {} + lines = [ + "Agent影视助手 启动就绪", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"外部智能体:{'可以启动' if data.get('can_start') else '暂不可启动'}", + f"115:{'可用' if p115.get('ready') else '不可用'}", + f"影巢:{'已配置' if hdhive.get('configured') else '未配置'}", + f"夸克:{'已配置' if quark.get('configured') else '未配置'}", + f"活跃会话:{(data.get('active_sessions') or {}).get('total') or 0}", + f"待执行计划:{(data.get('saved_plans') or {}).get('pending') or 0}", + "推荐入口:assistant/workflow 或 assistant/actions", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_pulse_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + recovery_data = self._assistant_recover_public_data(limit=10) + recovery_data.update({ + "action": "recover", + "ok": True, + "execute_requested": False, + "executed": False, + }) + recovery_compact = self._assistant_recover_response_data(recovery_data, compact=True) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置") + if not p115_status.get("ready"): + warnings.append("115 当前不可用") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置") + return { + "protocol_version": "assistant.v1", + "action": "pulse", + "ok": bool(self._enabled), + "version": self.plugin_version, + "enabled": self._enabled, + "can_start": bool(self._enabled), + "services": { + "p115_ready": bool(p115_status.get("ready")), + "p115_direct_ready": bool(p115_status.get("direct_ready")), + "hdhive_configured": bool(self._hdhive_api_key), + "quark_configured": bool(self._quark_cookie), + }, + "warnings": warnings, + "session": recovery_compact.get("session"), + "session_id": recovery_compact.get("session_id"), + "recovery": recovery_compact.get("recovery") or {}, + "selected_session": recovery_compact.get("selected_session"), + "next_actions": recovery_compact.get("next_actions") or [], + "action_templates": recovery_compact.get("action_templates") or [], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + }, + } + + def _format_assistant_pulse_text(self) -> str: + data = self._assistant_pulse_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + lines = [ + "Agent影视助手 轻量启动状态", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'}", + f"影巢:{'已配置' if services.get('hdhive_configured') else '未配置'}", + f"夸克:{'已配置' if services.get('quark_configured') else '未配置'}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_maintenance_snapshot(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + pending_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and not bool(item.get("executed")) + ) + executed_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and bool(item.get("executed")) + ) + stale_session_count = sum( + 1 + for item in (self._session_cache or {}).values() + if isinstance(item, dict) and self._is_session_expired(item) + ) + action_templates = [ + self._assistant_action_template( + name="clear_stale_sessions", + description="清理过期 assistant 会话,不影响仍有效的当前会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"stale_only": True, "limit": max_limit}, + ), + self._assistant_action_template( + name="clear_executed_plans", + description="清理已执行的保存计划,不影响待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plans/clear", + tool="agent_resource_officer_plans_clear", + body={"executed": True, "limit": max_limit}, + ), + ] + recommended_actions = [ + item.get("name") + for item in action_templates + if ( + (item.get("name") == "clear_stale_sessions" and stale_session_count > 0) + or (item.get("name") == "clear_executed_plans" and executed_plan_count > 0) + ) + ] + return { + "active_sessions": len(self._session_cache or {}), + "stale_sessions": stale_session_count, + "saved_plans_total": len(self._workflow_plans or {}), + "saved_plans_pending": pending_plan_count, + "saved_plans_executed": executed_plan_count, + "recommended_actions": recommended_actions, + "action_templates": action_templates, + "safe_to_execute": bool(recommended_actions), + "dry_run_method": "GET", + "execute_method": "POST", + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "execute_body": {"execute": True, "limit": max_limit}, + "execution_note": "GET 只返回 dry-run;只有 POST execute=true 会实际清理,并写入 assistant/history。", + "limit": max_limit, + } + + def _assistant_maintain_public_data(self, execute: bool = False, limit: int = 100) -> Dict[str, Any]: + before = self._assistant_maintenance_snapshot(limit=limit) + executed_actions: List[Dict[str, Any]] = [] + if execute: + if before.get("stale_sessions", 0) > 0: + result = self._clear_assistant_sessions(stale_only=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_stale_sessions", + "success": True, + "removed": result.get("cleared_count") or 0, + }) + if before.get("saved_plans_executed", 0) > 0: + result = self._clear_workflow_plans(executed=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_executed_plans", + "success": bool(result.get("ok")), + "removed": result.get("removed") or 0, + }) + after = self._assistant_maintenance_snapshot(limit=limit) + return { + "protocol_version": "assistant.v1", + "action": "maintain", + "ok": True, + "compact": True, + "version": self.plugin_version, + "execute_requested": bool(execute), + "executed": bool(executed_actions), + "executed_actions": executed_actions, + "before": before, + "after": after, + "next_actions": after.get("recommended_actions") or [], + "action_templates": after.get("action_templates") or [], + } + + def _format_assistant_maintain_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_maintain_public_data(execute=False) + before = payload.get("before") or {} + after = payload.get("after") or before + lines = [ + "Agent影视助手 低风险维护", + f"版本:{payload.get('version')}", + f"执行:{'是' if payload.get('execute_requested') else '否'}", + "维护前:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**before), + "维护后:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**after), + ] + executed_actions = payload.get("executed_actions") or [] + if executed_actions: + lines.append("已执行:" + " / ".join(f"{item.get('name')}({item.get('removed')})" for item in executed_actions)) + recommended = after.get("recommended_actions") or [] + if recommended: + lines.append("仍建议:" + " / ".join(str(item) for item in recommended if item)) + return "\n".join(lines) + + def _assistant_startup_public_data(self) -> Dict[str, Any]: + pulse = self._assistant_pulse_public_data() + selfcheck = self._assistant_selfcheck_public_data() + toolbox = self._assistant_toolbox_public_data() + maintenance = self._assistant_maintenance_snapshot(limit=100) + request_templates = self._assistant_request_templates_public_data(limit=100) + tools = toolbox.get("tools") or {} + endpoints = toolbox.get("endpoints") or {} + key_names = [ + "startup", + "maintain", + "pulse", + "selfcheck", + "request_templates", + "recover", + "workflow", + "route", + "pick", + "execute_action", + "execute_actions", + ] + key_tools = {name: tools.get(name) for name in key_names if tools.get(name)} + key_endpoints = { + name: endpoints.get(name) + for name in ["startup", "maintain", "pulse", "selfcheck", "request_templates", "recover", "workflow", "action", "actions", "route", "pick"] + if endpoints.get(name) + } + recovery = pulse.get("recovery") or {} + recommended_templates_recipe = "continue" if bool(recovery.get("can_resume")) else "bootstrap" + recommended_templates_reason = ( + "检测到可恢复会话,优先读取继续会话流程。" + if recommended_templates_recipe == "continue" + else "未检测到必须恢复的会话,优先读取安全启动流程。" + ) + return { + "protocol_version": "assistant.v1", + "action": "startup", + "ok": bool(pulse.get("can_start")) and bool(selfcheck.get("ok")), + "compact": True, + "version": self.plugin_version, + "services": pulse.get("services") or {}, + "warnings": pulse.get("warnings") or [], + "session": pulse.get("session"), + "session_id": pulse.get("session_id"), + "recovery": pulse.get("recovery") or {}, + "selected_session": pulse.get("selected_session"), + "action_templates": pulse.get("action_templates") or [], + "maintenance": maintenance, + "selfcheck": { + "ok": bool(selfcheck.get("ok")), + "checks": selfcheck.get("checks") or {}, + }, + "defaults": toolbox.get("defaults") or {}, + "startup_order": toolbox.get("startup_order") or [], + "tools": key_tools, + "endpoints": key_endpoints, + "workflows": [item.get("name") for item in (toolbox.get("workflows") or []) if item.get("name")], + "actions": toolbox.get("actions") or [], + "command_examples": (toolbox.get("command_examples") or [])[:6], + "request_templates": request_templates, + "request_templates_schema_version": self.request_templates_schema_version, + "recommended_request_templates": self._assistant_recommended_request_templates_data( + recipe=recommended_templates_recipe, + reason=recommended_templates_reason, + ), + "next_actions": pulse.get("next_actions") or ["assistant_recover", "assistant_workflow", "smart_entry"], + "recommended_endpoints": key_endpoints, + } + + def _assistant_recommended_request_templates_data(self, recipe: str = "bootstrap", reason: str = "") -> Dict[str, Any]: + recipe_name = self._clean_text(recipe) or "bootstrap" + return { + "recipe": recipe_name, + "reason": self._clean_text(reason), + "include_templates": False, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/request_templates?apikey={MP_API_TOKEN}", + "tool": "agent_resource_officer_request_templates", + "tool_args": { + "recipe": recipe_name, + "include_templates": False, + }, + } + + def _format_assistant_startup_text(self) -> str: + data = self._assistant_startup_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + checks = (data.get("selfcheck") or {}).get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 启动聚合包", + f"版本:{data.get('version')}", + f"可启动:{'是' if data.get('ok') else '否'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'};影巢:{'已配' if services.get('hdhive_configured') else '未配'};夸克:{'已配' if services.get('quark_configured') else '未配'}", + f"自检:{'通过' if not failed else '失败 ' + ', '.join(failed)}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + f"可执行模板:{len(data.get('action_templates') or [])} 个", + "状态:活跃会话 {active_sessions};过期会话 {stale_sessions};保存计划 {saved_plans_total};待执行计划 {saved_plans_pending};已执行计划 {saved_plans_executed}".format(**(data.get("maintenance") or {})), + "下一步:优先按 recovery 建议执行;没有待恢复任务时使用 workflow 或 smart_entry", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + maintenance = data.get("maintenance") or {} + recommended_actions = maintenance.get("recommended_actions") or [] + if recommended_actions: + lines.append("维护建议:" + " / ".join(str(item) for item in recommended_actions if item)) + return "\n".join(lines) + + def _assistant_toolbox_public_data(self) -> Dict[str, Any]: + workflows = [dict(item or {}) for item in (self._assistant_workflow_catalog().get("workflows") or []) if isinstance(item, dict)] + return { + "protocol_version": "assistant.v1", + "action": "toolbox", + "ok": True, + "version": self.plugin_version, + "defaults": { + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "hdhive_path": self._hdhive_default_path, + "p115_client_type": self._p115_client_type, + }, + "startup_order": [ + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_selfcheck", + "agent_resource_officer_request_templates", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + ], + "endpoints": { + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "request_templates": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "action": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + "route": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "pick": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + }, + "tools": { + "startup": "agent_resource_officer_startup", + "maintain": "agent_resource_officer_maintain", + "pulse": "agent_resource_officer_pulse", + "toolbox": "agent_resource_officer_toolbox", + "request_templates": "agent_resource_officer_request_templates", + "selfcheck": "agent_resource_officer_selfcheck", + "recover": "agent_resource_officer_recover", + "workflow": "agent_resource_officer_run_workflow", + "route": "agent_resource_officer_smart_entry", + "pick": "agent_resource_officer_smart_pick", + "execute_action": "agent_resource_officer_execute_action", + "execute_actions": "agent_resource_officer_execute_actions", + }, + "workflows": [ + { + "name": item.get("name"), + "fields": item.get("fields") or [], + } + for item in workflows + ], + "actions": [ + "start_pansou_search", + "start_smart_resource_search", + "start_smart_resource_decision", + "start_smart_resource_plan", + "start_smart_resource_execute", + "pick_pansou_result", + "start_hdhive_search", + "pick_hdhive_candidate", + "candidate_detail", + "candidate_next_page", + "pick_hdhive_resource", + "route_share", + "start_115_login", + "check_115_login", + "show_115_status", + "resume_pending_115", + "execute_latest_plan", + "execute_session_latest_plan", + "query_mp_download_tasks", + "query_mp_download_history", + "query_mp_lifecycle_status", + "query_mp_ingest_status", + "query_execution_followup", + "query_mp_media_detail", + "query_mp_search_result_detail", + "query_mp_best_result_detail", + "pick_mp_best_download", + "query_mp_downloaders", + "query_mp_sites", + "query_mp_subscribes", + "query_mp_transfer_history", + "query_mp_ingest_failures", + "query_mp_recent_activity", + "query_mp_local_diagnose", + "clear_stale_sessions", + "clear_executed_plans", + ], + "command_examples": [ + "盘搜搜索 大君夫人", + "智能搜索 蜘蛛侠", + "影巢搜索 蜘蛛侠", + "1大君夫人", + "2蜘蛛侠", + "链接 https://pan.quark.cn/s/xxxx path=/飞书", + "选择 1", + "详情", + "下一页", + "识别 蜘蛛侠", + "115登录", + ], + "request_templates": self._assistant_request_templates_public_data(limit=100), + "request_templates_schema_version": self.request_templates_schema_version, + } + + def _assistant_request_templates_public_data(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + return { + "startup_probe": { + "description": "读取启动聚合包,适合外部智能体开场获取状态、端点、工具和恢复建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "tool": "agent_resource_officer_startup", + "tool_args": {}, + "query": {}, + }, + "selfcheck_probe": { + "description": "执行协议自检,确认模板、compact、布尔解析和核心入口是否健康。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "tool": "agent_resource_officer_selfcheck", + "tool_args": {}, + "query": {}, + }, + "maintain_preview": { + "description": "预览低风险维护建议,不执行清理;适合高频探测。", + "side_effect": "dry_run", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": False, "limit": max_limit}, + "query": {"execute": True, "limit": max_limit}, + }, + "maintain_execute": { + "description": "执行低风险维护,清理过期会话和已执行计划;会写入 assistant/history。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": True, "limit": max_limit}, + "body": {"execute": True, "limit": max_limit}, + }, + "preferences_get": { + "description": "读取智能体片源偏好画像;未初始化时应先询问用户偏好。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": {"session": "assistant", "compact": True}, + "query": {"session": "assistant", "compact": True}, + }, + "preferences_save": { + "description": "保存智能体片源偏好画像;影响云盘与 PT 评分、自动化建议和安全阈值。", + "side_effect": "state", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + "body": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + }, + "scoring_policy": { + "description": "读取插件内置云盘/PT 评分策略、硬门槛和 score_summary 使用约定;只读,可缓存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "medium_lived", + "cache_ttl_seconds": 3600, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "tool": "agent_resource_officer_capabilities", + "tool_args": {"compact": True}, + "query": {"compact": True}, + "response_field": "scoring_policy", + }, + "workflow_dry_run": { + "description": "生成并保存工作流计划,不实际执行;适合先让用户确认。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "static_template", + "cache_ttl_seconds": 3600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": { + "name": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + "body": { + "workflow": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + }, + "mp_search": { + "description": "执行 MP 原生搜索,返回 PT 候选与 PT 评分摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "smart_search": { + "description": "按用户偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只返回推荐意见,不直接下载或转存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_decision": { + "description": "按用户偏好统一执行盘搜 -> 影巢 -> MP/PT 决策,并给出查看详情、生成计划或直接执行的首选建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_plan": { + "description": "按用户偏好自动搜索并为当前首选生成待确认 plan_id,不直接执行下载、解锁或转存。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_execute": { + "description": "按用户偏好自动搜索当前首选并立即执行写入动作;仅在用户已明确要求直接执行时使用。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_media_detail": { + "description": "使用 MoviePilot 原生识别确认片名、年份、类型和 TMDB/Douban/IMDB ID;适合搜索前消歧。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_search_detail": { + "description": "执行 MP 原生搜索并查看指定编号的 PT 详情和评分理由;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + }, + "mp_search_best": { + "description": "执行 MP 原生搜索并展示当前评分最高的 PT 候选详情;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "mp_best_download_plan": { + "description": "在已有 MP 搜索会话中按当前最高分候选生成下载计划;不会直接下载。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + "body": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + }, + "mp_search_download_plan": { + "description": "MP 原生搜索并选择编号下载;写入动作默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_tasks": { + "description": "查询 MP 下载任务状态,可按下载中、等待、已暂停等状态过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_download_control_plan": { + "description": "暂停、恢复或删除 MP 下载任务;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_history": { + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_lifecycle_status": { + "description": "一次查询 MP 下载任务、下载历史和整理/入库历史,适合追踪资源当前卡在哪一步。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_ingest_status": { + "description": "按片名或 hash 输出下载到入库的当前阶段,并附带结构化 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_downloaders": { + "description": "查询 MP 下载器配置摘要,不返回密码、Cookie 或 Token。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_downloaders", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_downloaders", "session": "assistant", "compact": True}, + }, + "mp_sites": { + "description": "查询 MP PT 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + }, + "mp_subscribe_plan": { + "description": "按关键词创建 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribe_search_plan": { + "description": "创建 MP 订阅并立即触发搜索;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribes": { + "description": "查询 MP 订阅列表,可按状态、类型和关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_subscribe_control_plan": { + "description": "搜索、暂停、恢复或删除 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_transfer_history": { + "description": "查询 MP 最近整理/入库历史,判断下载后是否已经落库;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_ingest_failures": { + "description": "聚合最近整理/入库失败记录,并返回失败态 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_failed_samples": { + "description": "读取 AI 识别增强插件保存的失败样本,可按关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_worklist": { + "description": "读取 AI 失败样本工作清单,适合先挑需要二次识别重放的样本;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_insights": { + "description": "读取 AI 失败样本洞察,查看主要失败原因、重复样本组和优先处理项。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + }, + "ai_replay_failed_sample_plan": { + "description": "对指定 AI 失败样本生成二次识别重放计划;确认后才会真正执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_recent_activity": { + "description": "查看最近下载和最近入库活动,适合先发现目标再进入单资源追踪。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_local_diagnose": { + "description": "面向“为什么没入库/卡在哪”的一站式只读诊断入口。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_recommend": { + "description": "读取 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_recommend_search": { + "description": "读取 MP 原生推荐并按编号继续搜索;mode 可选 smart_decision、smart_plan、smart_execute、mp、hdhive、pansou。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + }, + "smart_discovery": { + "description": "读取 MP 原生热门推荐,并优先引导到统一资源决策链。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "saved_plan_execute": { + "description": "执行已保存的 dry_run 工作流计划,可按 session 自动选择未执行计划。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "tool": "agent_resource_officer_execute_plan", + "tool_args": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + "body": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + }, + "execution_followup": { + "description": "按最近已执行计划自动追踪下载、订阅或入库后续状态,由插件决定先查哪个只读动作。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + }, + "smart_followup": { + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "action_execute": { + "description": "按动作名执行单个 action template,适合无映射继续执行。", + "side_effect": "depends_on_action", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + }, + "route_text": { + "description": "统一自然语言入口,适合 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体直接转发用户文本。", + "side_effect": "depends_on_text", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + "body": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + }, + "pick_continue": { + "description": "按编号继续当前会话,适合盘搜、影巢候选或资源列表选择。", + "side_effect": "depends_on_session", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + "tool": "agent_resource_officer_smart_pick", + "tool_args": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + "body": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + }, + } + + def _assistant_request_template_names(self, value: Any) -> List[str]: + if isinstance(value, (list, tuple, set)): + rows = value + else: + rows = re.split(r"[,,\s]+", self._clean_text(value)) + names: List[str] = [] + for item in rows: + name = self._clean_text(item) + if name and name not in names: + names.append(name) + return names + + def _assistant_request_templates_response_data( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> Dict[str, Any]: + all_templates = self._assistant_request_templates_public_data(limit=limit) + recipe_templates_map = { + "safe_bootstrap": ["startup_probe", "selfcheck_probe", "maintain_preview"], + "plan_then_confirm": ["workflow_dry_run", "saved_plan_execute"], + "post_execute_followup": ["smart_followup", "execution_followup", "mp_download_history", "mp_lifecycle_status", "mp_subscribes", "mp_transfer_history"], + "continue_existing_session": ["pick_continue"], + "maintenance_cycle": ["maintain_preview", "maintain_execute"], + "external_agent_quickstart": ["startup_probe", "route_text", "pick_continue"], + "preferences_onboarding": ["preferences_get", "scoring_policy", "preferences_save"], + "smart_search": ["smart_search", "preferences_get", "scoring_policy"], + "smart_decision": ["smart_decision", "preferences_get", "scoring_policy"], + "smart_search_plan": ["smart_search_plan", "preferences_get", "scoring_policy", "saved_plan_execute"], + "smart_search_execute": ["smart_search_execute", "preferences_get", "scoring_policy", "post_execute_followup"], + "mp_pt_mainline": [ + "mp_media_detail", + "mp_search", + "mp_search_detail", + "mp_search_best", + "mp_search_download_plan", + "mp_best_download_plan", + "mp_download_tasks", + "mp_download_control_plan", + "mp_download_history", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_downloaders", + "mp_sites", + "mp_subscribe_plan", + "mp_subscribe_search_plan", + "mp_subscribes", + "mp_subscribe_control_plan", + "mp_transfer_history", + "mp_ingest_failures", + "mp_recent_activity", + "mp_local_diagnose", + "saved_plan_execute", + ], + "mp_recommendation": [ + "smart_discovery", + "mp_recommend", + "mp_recommend_search", + "mp_search", + "mp_search_best", + "mp_search_download_plan", + "saved_plan_execute", + ], + "ai_reingest_readonly": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + ], + "ai_reingest": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "ai_replay_failed_sample_plan", + "saved_plan_execute", + "execution_followup", + ], + "local_ingest": [ + "smart_followup", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_download_history", + "mp_transfer_history", + "mp_ingest_failures", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "mp_recent_activity", + "mp_local_diagnose", + ], + } + recipe_aliases = { + "bootstrap": "safe_bootstrap", + "safe": "safe_bootstrap", + "start": "safe_bootstrap", + "启动": "safe_bootstrap", + "plan": "plan_then_confirm", + "dry_run": "plan_then_confirm", + "confirm": "plan_then_confirm", + "计划": "plan_then_confirm", + "followup": "post_execute_followup", + "follow": "post_execute_followup", + "post_execute": "post_execute_followup", + "post-execute": "post_execute_followup", + "after_execute": "post_execute_followup", + "after-execute": "post_execute_followup", + "执行后": "post_execute_followup", + "执行后追踪": "post_execute_followup", + "后续追踪": "post_execute_followup", + "跟进": "post_execute_followup", + "进展": "post_execute_followup", + "continue": "continue_existing_session", + "pick": "continue_existing_session", + "resume": "continue_existing_session", + "继续": "continue_existing_session", + "选择": "continue_existing_session", + "maintain": "maintenance_cycle", + "maintenance": "maintenance_cycle", + "cleanup": "maintenance_cycle", + "维护": "maintenance_cycle", + "external_agent": "external_agent_quickstart", + "external-agent": "external_agent_quickstart", + "agent": "external_agent_quickstart", + "外部智能体": "external_agent_quickstart", + "微信智能体": "external_agent_quickstart", + "workbuddy": "external_agent_quickstart", + "work_buddy": "external_agent_quickstart", + "workbody": "external_agent_quickstart", + "work_body": "external_agent_quickstart", + "preferences": "preferences_onboarding", + "preference": "preferences_onboarding", + "prefs": "preferences_onboarding", + "pref": "preferences_onboarding", + "片源偏好": "preferences_onboarding", + "偏好画像": "preferences_onboarding", + "评分偏好": "preferences_onboarding", + "mp": "mp_pt_mainline", + "pt": "mp_pt_mainline", + "mp_native": "mp_pt_mainline", + "mp-native": "mp_pt_mainline", + "mp_pt": "mp_pt_mainline", + "mp-pt": "mp_pt_mainline", + "pt_mainline": "mp_pt_mainline", + "pt-mainline": "mp_pt_mainline", + "原生mp": "mp_pt_mainline", + "mp原生": "mp_pt_mainline", + "原生搜索": "mp_pt_mainline", + "pt下载": "mp_pt_mainline", + "下载订阅": "mp_pt_mainline", + "local_ingest": "local_ingest", + "local-ingest": "local_ingest", + "ingest": "local_ingest", + "local": "local_ingest", + "本地入库": "local_ingest", + "入库诊断": "local_ingest", + "ai_reingest": "ai_reingest", + "ai-reingest": "ai_reingest", + "失败样本": "ai_reingest_readonly", + "失败样本诊断": "ai_reingest_readonly", + "识别重放": "ai_reingest", + "recommend": "mp_recommendation", + "recommendation": "mp_recommendation", + "discover": "mp_recommendation", + "discovery": "mp_recommendation", + "智能发现": "mp_recommendation", + "热门发现": "mp_recommendation", + "mp_recommend": "mp_recommendation", + "mp-recommend": "mp_recommendation", + "推荐": "mp_recommendation", + "热门": "mp_recommendation", + "smart_search": "smart_search", + "smart-search": "smart_search", + "smart": "smart_search", + "智能搜索": "smart_search", + "smart_decision": "smart_decision", + "smart-decision": "smart_decision", + "decision": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart_search_plan": "smart_search_plan", + "smart-search-plan": "smart_search_plan", + "smartplan": "smart_search_plan", + "智能计划": "smart_search_plan", + "smart_search_execute": "smart_search_execute", + "smart-search-execute": "smart_search_execute", + "smartexecute": "smart_search_execute", + "智能执行": "smart_search_execute", + } + requested_recipe = self._clean_text(recipe) + selected_recipe = recipe_aliases.get(requested_recipe, requested_recipe) + invalid_recipe = requested_recipe if requested_recipe and selected_recipe not in recipe_templates_map else "" + selected_names = self._assistant_request_template_names(names) + if not selected_names and selected_recipe in recipe_templates_map: + selected_names = list(recipe_templates_map[selected_recipe]) + invalid_names = [name for name in selected_names if name not in all_templates] + templates = { + name: all_templates[name] + for name in selected_names + if name in all_templates + } if selected_names else all_templates + confirmation_required = [ + name + for name, item in templates.items() + if bool((item or {}).get("requires_confirmation")) + ] + safe_without_confirmation = [ + name + for name, item in templates.items() + if not bool((item or {}).get("requires_confirmation")) + ] + write_side_effects = [ + name + for name, item in templates.items() + if self._clean_text((item or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ] + cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) > 0 + ] + non_cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) <= 0 + ] + auth = { + "mode": "query_apikey", + "query_param": "apikey", + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}", + "description": "调用插件 HTTP 接口时推荐使用 ?apikey=你的MP_API_TOKEN;MP Tool 调用不需要此参数。", + } + external_agent_execution_policy_contract = { + "auto_continue": "直接执行 auto_run_command。", + "auto_continue_then_wait_confirmation": "先执行 auto_run_command,再停止并向用户展示 confirm_command。", + "wait_user_confirmation": "不要自动执行;先向用户展示 confirm_command 或 display_command。", + "show_only": "只展示 display_command,不要自动继续。", + "stop": "当前没有适合自动继续的命令,不要继续执行。", + } + external_agent_execution_loop_contract = [ + { + "step": "startup", + "template": "startup_probe", + "when": "新会话开始时先读取启动聚合包。", + }, + { + "step": "decide", + "template": "startup_probe", + "when": "结合恢复信息和推荐 recipe,确定先继续会话还是开始新流程。", + }, + { + "step": "route", + "template": "route_text", + "when": "处理用户的自然语言资源指令。", + }, + { + "step": "policy", + "template": "", + "when": "读取 recommended_agent_behavior、auto_run_command、confirm_command 决定自动继续、确认或停止。", + }, + { + "step": "followup", + "template": "execution_followup", + "when": "执行计划后继续追踪下载、入库或失败诊断。", + }, + ] + entry_patterns = { + "external_agent": { + "label": "外部智能体", + "client_role": "客户端调度层", + "start_with": "startup", + "decide_with": "decide --summary-only", + "route_with": "route --summary-only", + "followup_with": "followup --summary-only", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "notes": "适用于 WorkBuddy、Hermes、OpenClaw(小龙虾)等外部智能体;优先使用 Skill/helper。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体", + "client_role": "客户端调度层", + "start_with": "assistant/request_templates", + "decide_with": "agent_resource_officer_request_templates", + "route_with": "agent_resource_officer_smart_entry", + "followup_with": "agent_resource_officer_execution_followup", + "read_fields": [ + "recommended_recipe_detail", + "recommended_agent_behavior", + "preferred_command", + "compact_commands", + ], + "notes": "优先走 Agent Tool / request_templates,不在模型侧直拼底层资源 API。", + }, + "feishu_channel": { + "label": "飞书入口", + "client_role": "客户端消息入口", + "start_with": "feishu message -> route", + "decide_with": "插件内置命令解析", + "route_with": "route/pick/followup", + "followup_with": "followup / smart_followup", + "read_fields": [ + "recommended_agent_behavior", + "preferred_command", + "fallback_command", + "compact_commands", + ], + "notes": "飞书只负责把消息送进同一套 assistant 协议;确认策略与外部智能体保持一致。", + }, + } + orchestration_contract = { + "service_role": "Agent影视助手 / AgentResourceOfficer 负责服务端能力执行。", + "client_role": "外部智能体、MP 内置智能体、飞书入口负责客户端调度与展示。", + "recommended_first_call": "startup", + "recommended_decision_call": "decide --summary-only", + "recommended_route_call": "route --summary-only", + "recommended_followup_call": "followup --summary-only", + "recommended_read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "confirmation_rule": "写入动作默认确认制;只有明确标记可自动继续的只读步骤才自动续跑。", + } + entry_playbooks = { + "external_agent": { + "label": "外部智能体最小执行流", + "transport": "skill_helper_or_http", + "steps": [ + { + "step": "startup", + "helper_command": "python3 scripts/aro_request.py startup", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "读取启动状态、恢复建议和推荐 recipe。", + "read_fields": ["recommended_request_templates", "recovery", "services"], + }, + { + "step": "decide", + "helper_command": "python3 scripts/aro_request.py decide --summary-only", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "决定继续会话、初始化偏好还是直接进入 route。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "confirm_command"], + }, + { + "step": "route", + "helper_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/route?apikey={MP_API_TOKEN}", + }, + "purpose": "执行自然语言主入口。", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "preferred_command", + "compact_commands", + ], + }, + { + "step": "followup", + "helper_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/action?apikey={MP_API_TOKEN}", + }, + "purpose": "执行计划后继续追踪下载、入库或失败诊断。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "followup_summary"], + }, + ], + "auto_rule": "优先读取 recommended_agent_behavior;只读步骤可自动续跑,写入步骤默认确认。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体最小执行流", + "transport": "moviepilot_agent_tool", + "steps": [ + { + "step": "request_templates", + "tool": "agent_resource_officer_request_templates", + "tool_args": {"recipe": "external_agent", "compact": True}, + "purpose": "读取最小流程、确认策略和推荐入口。", + "read_fields": ["orchestration_contract", "entry_patterns", "recommended_recipe_detail"], + }, + { + "step": "route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": {"text": "<用户原始指令>"}, + "purpose": "处理搜索、链接、登录状态等主入口。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "followup", + "tool": "agent_resource_officer_execution_followup", + "tool_args": {}, + "purpose": "执行计划后继续查看下载、入库和失败状态。", + "read_fields": ["followup_summary", "preferred_command", "compact_commands"], + }, + ], + "auto_rule": "依然遵守服务端 compact 协议;不要在模型侧拼底层资源站点 API。", + }, + "feishu_channel": { + "label": "飞书入口最小执行流", + "transport": "feishu_channel", + "steps": [ + { + "step": "message_in", + "channel": "feishu", + "purpose": "用户消息进入内置 Channel。", + "read_fields": ["session", "text", "reply_target"], + }, + { + "step": "route", + "internal": "route / pick / followup", + "purpose": "复用同一套 assistant 协议,不维护单独状态机。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "reply", + "channel": "feishu", + "purpose": "按确认策略回消息、展示编号或提示下一步。", + "read_fields": ["display_command", "confirm_command", "message"], + }, + ], + "auto_rule": "飞书只负责消息承载;写入动作依然由插件服务端统一确认。", + }, + } + recommended_sequence = [ + { + "step": "bootstrap", + "template": "startup_probe", + "when": "每次新会话开始时先读取启动聚合包。", + }, + { + "step": "healthcheck", + "template": "selfcheck_probe", + "when": "当外部智能体需要确认协议健康或怀疑环境变化时执行。", + }, + { + "step": "maintenance_preview", + "template": "maintain_preview", + "when": "长会话或多轮执行前先看是否有低风险维护建议。", + }, + { + "step": "plan", + "template": "workflow_dry_run", + "when": "需要先生成计划、等待用户确认或减少重复大 JSON 时使用。", + }, + { + "step": "execute_saved_plan", + "template": "saved_plan_execute", + "when": "已确认 dry_run 计划后执行。", + }, + { + "step": "post_execute_followup", + "template": "execution_followup", + "when": "计划执行成功后,自动决定先查下载历史、生命周期、订阅或入库历史。", + }, + { + "step": "continue_session", + "template": "pick_continue", + "when": "盘搜、影巢候选或资源列表需要按编号继续时使用。", + }, + { + "step": "preferences_onboarding", + "template": "preferences_get", + "when": "外部智能体首次接入时,先读取偏好和评分策略,再保存用户片源偏好。", + }, + { + "step": "mp_pt_mainline", + "template": "mp_search", + "when": "需要 MP 原生 PT 搜索、评分、下载计划、订阅或任务追踪时使用。", + }, + { + "step": "mp_recommendation", + "template": "mp_recommend", + "when": "需要 TMDB、豆瓣、Bangumi 等 MP 原生推荐,并继续搜索时使用。", + }, + ] + recipes = [ + { + "name": "safe_bootstrap", + "description": "新会话安全启动:先拿启动聚合包,再自检,再看维护建议。", + "templates": recipe_templates_map["safe_bootstrap"], + }, + { + "name": "plan_then_confirm", + "description": "先生成计划,等待用户确认后再执行保存计划。", + "templates": recipe_templates_map["plan_then_confirm"], + }, + { + "name": "post_execute_followup", + "description": "执行计划后的统一只读跟踪:由插件自动选择最合适的后续查询动作。", + "templates": recipe_templates_map["post_execute_followup"], + }, + { + "name": "continue_existing_session", + "description": "已有盘搜、影巢或资源会话时,直接按编号继续。", + "templates": recipe_templates_map["continue_existing_session"], + }, + { + "name": "maintenance_cycle", + "description": "先预览维护建议,再在确认后执行低风险维护。", + "templates": recipe_templates_map["maintenance_cycle"], + }, + { + "name": "external_agent_quickstart", + "description": "外部智能体接入:启动探测后,把用户文本交给统一入口,再按编号继续。", + "templates": recipe_templates_map["external_agent_quickstart"], + }, + { + "name": "preferences_onboarding", + "description": "首次接入时先读取偏好、评分策略,再保存用户的片源偏好画像。", + "templates": recipe_templates_map["preferences_onboarding"], + }, + { + "name": "smart_decision", + "description": "统一搜索并返回明确的下一步决策:查看详情、生成计划或直接执行。", + "templates": recipe_templates_map["smart_decision"], + }, + { + "name": "smart_search_plan", + "description": "统一搜索决策后,直接为当前首选生成待确认计划;仍需后续执行计划才会真正写入。", + "templates": recipe_templates_map["smart_search_plan"], + }, + { + "name": "smart_search_execute", + "description": "统一搜索决策后,直接执行当前首选写入动作;只适用于用户明确要求立即执行的场景。", + "templates": recipe_templates_map["smart_search_execute"], + }, + { + "name": "mp_pt_mainline", + "description": "MP 原生 PT 主线:识别、搜索、评分、下载计划、任务、订阅、站点和入库追踪。", + "templates": recipe_templates_map["mp_pt_mainline"], + }, + { + "name": "mp_recommendation", + "description": "MP 原生推荐主线:读取热门推荐,再按编号进入 MP、影巢或盘搜搜索。", + "templates": recipe_templates_map["mp_recommendation"], + }, + ] + recipe_summaries: List[Dict[str, Any]] = [] + for recipe in recipes: + template_names = [ + self._clean_text(item) + for item in (recipe.get("templates") or []) + if self._clean_text(item) + ] + current_templates = [ + templates[name] + for name in template_names + if isinstance(templates.get(name), dict) + ] + recipe_summaries.append({ + **recipe, + "requires_confirmation": any(bool(item.get("requires_confirmation")) for item in current_templates), + "has_write_effect": any( + self._clean_text(item.get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + for item in current_templates + ), + "cache_ttl_seconds": min( + [self._safe_int(item.get("cache_ttl_seconds"), 0) for item in current_templates] or [0] + ), + }) + recommended_recipe = "safe_bootstrap" + recommended_recipe_reason = "默认优先安全启动,先读取启动聚合包、自检并查看维护建议。" + selected_set = set(selected_names or []) + if selected_recipe in recipe_templates_map: + recommended_recipe = selected_recipe + recommended_recipe_reason = f"当前请求显式指定 recipe={selected_recipe}。" + elif {"workflow_dry_run", "saved_plan_execute"} & selected_set: + recommended_recipe = "plan_then_confirm" + recommended_recipe_reason = "当前模板集合包含 dry_run 或执行保存计划,更适合先计划后确认。" + elif "pick_continue" in selected_set: + recommended_recipe = "continue_existing_session" + recommended_recipe_reason = "当前模板集合包含 pick_continue,说明更像继续既有会话。" + elif {"preferences_get", "preferences_save", "scoring_policy"} & selected_set: + recommended_recipe = "preferences_onboarding" + recommended_recipe_reason = "当前模板集合包含偏好或评分策略模板,优先推荐偏好初始化流程。" + elif {"maintain_preview", "maintain_execute"} & selected_set: + recommended_recipe = "maintenance_cycle" + recommended_recipe_reason = "当前模板集合包含维护模板,优先推荐维护流程。" + recommended_recipe_detail = next( + (item for item in recipe_summaries if item.get("name") == recommended_recipe), + {}, + ) + recommended_recipe_templates = [ + name + for name in (recommended_recipe_detail.get("templates") or []) + if name in all_templates + ] + first_template = recommended_recipe_templates[0] if recommended_recipe_templates else "" + first_template_data = all_templates.get(first_template) or {} + recommended_recipe_calls = [] + for template_name in recommended_recipe_templates: + template_data = all_templates.get(template_name) or {} + if not template_data: + continue + recommended_recipe_calls.append({ + "template": template_name, + "auth": auth, + "method": template_data.get("method"), + "endpoint": template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(template_data.get("endpoint")), + ), + "query": template_data.get("query") or {}, + "body": template_data.get("body") or {}, + "tool": template_data.get("tool"), + "tool_args": template_data.get("tool_args") or {}, + "requires_confirmation": bool(template_data.get("requires_confirmation")), + "side_effect": template_data.get("side_effect"), + }) + recommended_recipe_detail = { + **recommended_recipe_detail, + "templates": recommended_recipe_templates, + "first_template": first_template, + "confirmation_required_templates": [ + name + for name in recommended_recipe_templates + if bool((all_templates.get(name) or {}).get("requires_confirmation")) + ], + "write_templates": [ + name + for name in recommended_recipe_templates + if self._clean_text((all_templates.get(name) or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ], + "first_call": { + "template": first_template, + "auth": auth, + "method": first_template_data.get("method"), + "endpoint": first_template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(first_template_data.get("endpoint")), + ), + "query": first_template_data.get("query") or {}, + "body": first_template_data.get("body") or {}, + "tool": first_template_data.get("tool"), + "tool_args": first_template_data.get("tool_args") or {}, + "requires_confirmation": bool(first_template_data.get("requires_confirmation")), + "side_effect": first_template_data.get("side_effect"), + } if first_template_data else {}, + "calls": recommended_recipe_calls, + } + if recommended_recipe == "external_agent_quickstart": + recommended_recipe_detail["execution_policy_contract"] = external_agent_execution_policy_contract + recommended_recipe_detail["execution_loop_contract"] = external_agent_execution_loop_contract + recommended_recipe_detail["entry_patterns"] = entry_patterns + recommended_recipe_detail["orchestration_contract"] = orchestration_contract + recommended_recipe_detail["entry_playbooks"] = entry_playbooks + confirmation_templates = recommended_recipe_detail.get("confirmation_required_templates") or [] + recommended_recipe_detail["first_confirmation_template"] = confirmation_templates[0] if confirmation_templates else "" + recommended_recipe_detail["confirmation_message"] = ( + f"执行 {recommended_recipe} 的 {recommended_recipe_detail['first_confirmation_template']} 前需要用户确认。" + if confirmation_templates + else f"{recommended_recipe} 当前推荐流程无需用户确认。" + ) + return { + "protocol_version": "assistant.v1", + "action": "request_templates", + "ok": True, + "compact": True, + "version": self.plugin_version, + "schema_version": self.request_templates_schema_version, + "auth": auth, + "templates_included": bool(include_templates), + "request_templates": templates if include_templates else {}, + "available_names": list(all_templates.keys()), + "available_recipes": list(recipe_templates_map.keys()), + "recipe_aliases": recipe_aliases, + "selected_names": selected_names, + "invalid_names": invalid_names, + "requested_recipe": requested_recipe, + "selected_recipe": selected_recipe if selected_recipe in recipe_templates_map else "", + "invalid_recipe": invalid_recipe, + "execution_policy": { + "safe_without_confirmation": safe_without_confirmation, + "confirmation_required": confirmation_required, + "write_side_effects": write_side_effects, + "cacheable_templates": cacheable_templates, + "non_cacheable_templates": non_cacheable_templates, + }, + "recommended_sequence": recommended_sequence, + "recipes": recipe_summaries, + "recommended_recipe": recommended_recipe, + "recommended_recipe_reason": recommended_recipe_reason, + "recommended_recipe_detail": recommended_recipe_detail, + "external_agent_execution_policy_contract": external_agent_execution_policy_contract, + "external_agent_execution_loop_contract": external_agent_execution_loop_contract, + "entry_patterns": entry_patterns, + "orchestration_contract": orchestration_contract, + "entry_playbooks": entry_playbooks, + } + + def _format_assistant_request_templates_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_request_templates_response_data() + templates = payload.get("request_templates") or {} + lines = [ + "Agent影视助手 请求模板", + f"版本:{payload.get('version')}", + ] + detail = payload.get("recommended_recipe_detail") or {} + first_call = detail.get("first_call") or {} + if payload.get("recommended_recipe"): + lines.append(f"推荐流程:{payload.get('recommended_recipe')}") + if detail.get("first_template"): + lines.append( + "首步:{template} -> {method} {endpoint}".format( + template=detail.get("first_template"), + method=first_call.get("method") or "", + endpoint=first_call.get("endpoint") or "", + ).strip() + ) + if detail.get("confirmation_message"): + lines.append(f"确认提示:{detail.get('confirmation_message')}") + if detail.get("execution_policy_contract"): + lines.append("外部智能体执行分支:" + " / ".join(str(key) for key in (detail.get("execution_policy_contract") or {}).keys())) + if detail.get("execution_loop_contract"): + lines.append( + "外部智能体最小循环:" + + " -> ".join(self._clean_text(item.get("step")) for item in (detail.get("execution_loop_contract") or []) if self._clean_text(item.get("step"))) + ) + orchestration_contract = payload.get("orchestration_contract") or detail.get("orchestration_contract") or {} + if orchestration_contract: + lines.append( + "最小执行流:{startup} -> {decide} -> {route} -> policy -> {followup}".format( + startup=self._clean_text(orchestration_contract.get("recommended_first_call")) or "startup", + decide=self._clean_text(orchestration_contract.get("recommended_decision_call")) or "decide --summary-only", + route=self._clean_text(orchestration_contract.get("recommended_route_call")) or "route --summary-only", + followup=self._clean_text(orchestration_contract.get("recommended_followup_call")) or "followup --summary-only", + ) + ) + read_fields = [ + self._clean_text(item) + for item in (orchestration_contract.get("recommended_read_fields") or []) + if self._clean_text(item) + ] + if read_fields: + lines.append("优先读取字段:" + " / ".join(read_fields)) + entry_patterns = payload.get("entry_patterns") or detail.get("entry_patterns") or {} + for key in ["external_agent", "mp_builtin_agent", "feishu_channel"]: + item = entry_patterns.get(key) or {} + if not item: + continue + lines.append( + "{label}:{start} -> {route}".format( + label=self._clean_text(item.get("label")) or key, + start=self._clean_text(item.get("start_with")) or "", + route=self._clean_text(item.get("route_with")) or "", + ) + ) + entry_playbooks = payload.get("entry_playbooks") or detail.get("entry_playbooks") or {} + external_playbook = (entry_playbooks.get("external_agent") or {}).get("steps") or [] + if external_playbook: + lines.append( + "外部智能体脚手架:" + + " -> ".join( + self._clean_text(item.get("step")) + for item in external_playbook + if self._clean_text(item.get("step")) + ) + ) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "pick_continue", + ]: + item = templates.get(name) or {} + if item: + tool = self._clean_text(item.get("tool")) + suffix = f" -> {tool}" if tool else "" + lines.append(f"{name}: {item.get('method')} {item.get('endpoint')}{suffix}") + return "\n".join(lines) + + def _format_assistant_toolbox_text(self) -> str: + data = self._assistant_toolbox_public_data() + workflows = data.get("workflows") or [] + lines = [ + "Agent影视助手 轻量工具清单", + f"版本:{data.get('version')}", + "推荐启动顺序:" + " -> ".join(str(item) for item in (data.get("startup_order") or [])[:5]), + "常用工作流:" + " / ".join(str(item.get("name")) for item in workflows if item.get("name")), + "默认目录:115={p115_path};夸克={quark_path};影巢={hdhive_path}".format(**(data.get("defaults") or {})), + ] + return "\n".join(lines) + + def _assistant_selfcheck_public_data(self) -> Dict[str, Any]: + action_template = self._assistant_action_template( + name="show_115_status", + description="自检模板:查看 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={"session": "selfcheck"}, + ) + route_template = self._assistant_action_template( + name="start_hdhive_search", + description="自检模板:发起影巢搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={"session": "selfcheck", "keyword": "蜘蛛侠", "media_type": "movie"}, + ) + bool_cases = { + "true": self._parse_bool_value("true", False), + "false": self._parse_bool_value("false", True), + "one": self._parse_bool_value("1", False), + "zero": self._parse_bool_value("0", True), + "off": self._parse_bool_value("off", True), + "default_true": self._parse_bool_value(None, True), + "default_false": self._parse_bool_value(None, False), + } + compact_templates_ok = all([ + (action_template.get("body") or {}).get("compact") is True, + (action_template.get("action_body") or {}).get("compact") is True, + (route_template.get("body") or {}).get("compact") is True, + (route_template.get("action_body") or {}).get("compact") is True, + ]) + bool_parse_ok = ( + bool_cases["true"] is True + and bool_cases["false"] is False + and bool_cases["one"] is True + and bool_cases["zero"] is False + and bool_cases["off"] is False + and bool_cases["default_true"] is True + and bool_cases["default_false"] is False + ) + pulse = self._assistant_pulse_public_data() + toolbox = self._assistant_toolbox_public_data() + startup_request_templates = self._assistant_recommended_request_templates_data() + startup_continue_request_templates = self._assistant_recommended_request_templates_data( + recipe="continue", + reason="selfcheck", + ) + maintain = self._assistant_maintain_public_data(execute=False) + request_templates = self._assistant_request_templates_public_data(limit=100) + filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute,missing_template", + ) + recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="startup_probe,maintain_preview,workflow_dry_run,saved_plan_execute,pick_continue,maintain_execute", + include_templates=False, + ) + recipe_filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="plan", + include_templates=False, + ) + external_recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="external_agent", + include_templates=False, + ) + policy_only_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute", + include_templates=False, + ) + maintenance_templates = maintain.get("action_templates") or [] + maintenance_template_names = { + self._clean_text(item.get("name")) + for item in maintenance_templates + if isinstance(item, dict) + } + maintenance_templates_compact_ok = all( + (item.get("body") or {}).get("compact") is True + and (item.get("action_body") or {}).get("compact") is True + for item in maintenance_templates + if isinstance(item, dict) + ) + maintain_dry_run_ok = ( + maintain.get("action") == "maintain" + and maintain.get("execute_requested") is False + and maintain.get("executed") is False + and {"clear_stale_sessions", "clear_executed_plans"}.issubset(maintenance_template_names) + ) + protocol_ok = ( + pulse.get("protocol_version") == "assistant.v1" + and toolbox.get("protocol_version") == "assistant.v1" + and pulse.get("action") == "pulse" + and toolbox.get("action") == "toolbox" + and maintain.get("protocol_version") == "assistant.v1" + ) + startup_request_templates_ok = ( + startup_request_templates.get("recipe") == "bootstrap" + and startup_request_templates.get("include_templates") is False + and startup_request_templates.get("tool") == "agent_resource_officer_request_templates" + and "{MP_API_TOKEN}" in self._clean_text(startup_request_templates.get("url_template")) + and startup_continue_request_templates.get("recipe") == "continue" + and ((startup_continue_request_templates.get("tool_args") or {}).get("recipe")) == "continue" + and bool(self._clean_text(startup_continue_request_templates.get("reason"))) + ) + request_templates_ok = all( + isinstance(request_templates.get(name), dict) + and self._clean_text((request_templates.get(name) or {}).get("endpoint")) + and self._clean_text((request_templates.get(name) or {}).get("method")) + and self._clean_text((request_templates.get(name) or {}).get("tool")) + and self._clean_text((request_templates.get(name) or {}).get("description")) + and self._clean_text((request_templates.get(name) or {}).get("side_effect")) + and isinstance((request_templates.get(name) or {}).get("requires_confirmation"), bool) + and self._clean_text((request_templates.get(name) or {}).get("cache_scope")) + and isinstance((request_templates.get(name) or {}).get("cache_ttl_seconds"), int) + and isinstance((request_templates.get(name) or {}).get("tool_args"), dict) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "route_text", + "pick_continue", + ] + ) + request_templates_filter_ok = ( + list((filtered_request_templates.get("request_templates") or {}).keys()) == ["maintain_execute"] + and filtered_request_templates.get("selected_names") == ["maintain_execute", "missing_template"] + and filtered_request_templates.get("invalid_names") == ["missing_template"] + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("body") or {}).get("limit") == 5 + ) + request_templates_policy_ok = ( + "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("write_side_effects") or []) + ) + request_templates_schema_ok = filtered_request_templates.get("schema_version") == self.request_templates_schema_version + request_templates_cache_ok = ( + ((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_scope") == "no_cache" + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_ttl_seconds")) == 0 + ) + request_templates_sequence_ok = ( + isinstance(filtered_request_templates.get("recommended_sequence"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "startup_probe" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "saved_plan_execute" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + ) + request_templates_recipes_ok = ( + isinstance(recipe_request_templates.get("recipes"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "safe_bootstrap" + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "plan_then_confirm" + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recipe_summary_ok = ( + any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "safe_bootstrap" + and item.get("requires_confirmation") is False + and item.get("has_write_effect") is False + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "plan_then_confirm" + and item.get("requires_confirmation") is True + and item.get("has_write_effect") is True + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recommended_recipe_ok = ( + filtered_request_templates.get("recommended_recipe") == "maintenance_cycle" + and bool(self._clean_text(filtered_request_templates.get("recommended_recipe_reason"))) + and recipe_request_templates.get("recommended_recipe") == "plan_then_confirm" + ) + request_templates_recipe_filter_ok = ( + recipe_filtered_request_templates.get("requested_recipe") == "plan" + and recipe_filtered_request_templates.get("selected_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("selected_names") == ["workflow_dry_run", "saved_plan_execute"] + and recipe_filtered_request_templates.get("recommended_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("templates_included") is False + and "plan_then_confirm" in (recipe_filtered_request_templates.get("available_recipes") or []) + and ((recipe_filtered_request_templates.get("recipe_aliases") or {}).get("plan")) == "plan_then_confirm" + ) + recommended_recipe_detail = filtered_request_templates.get("recommended_recipe_detail") or {} + request_templates_recommended_recipe_detail_ok = ( + recommended_recipe_detail.get("name") == "maintenance_cycle" + and recommended_recipe_detail.get("first_template") == "maintain_preview" + and "maintain_execute" in (recommended_recipe_detail.get("confirmation_required_templates") or []) + and "maintain_execute" in (recommended_recipe_detail.get("write_templates") or []) + and recommended_recipe_detail.get("first_confirmation_template") == "maintain_execute" + and "maintain_execute" in self._clean_text(recommended_recipe_detail.get("confirmation_message")) + and ((recommended_recipe_detail.get("first_call") or {}).get("template")) == "maintain_preview" + and (((recommended_recipe_detail.get("first_call") or {}).get("auth") or {}).get("mode")) == "query_apikey" + and self._clean_text((recommended_recipe_detail.get("first_call") or {}).get("url_template")).endswith( + "/assistant/maintain?apikey={MP_API_TOKEN}" + ) + and ((recommended_recipe_detail.get("first_call") or {}).get("method")) == "GET" + and ((recommended_recipe_detail.get("first_call") or {}).get("tool")) == "agent_resource_officer_maintain" + and [ + (item or {}).get("template") + for item in (recommended_recipe_detail.get("calls") or []) + ] == ["maintain_preview", "maintain_execute"] + and all( + (((item or {}).get("auth") or {}).get("query_param")) == "apikey" + for item in (recommended_recipe_detail.get("calls") or []) + ) + and all( + "{MP_API_TOKEN}" in self._clean_text((item or {}).get("url_template")) + for item in (recommended_recipe_detail.get("calls") or []) + ) + ) + request_templates_policy_only_ok = ( + policy_only_request_templates.get("templates_included") is False + and (policy_only_request_templates.get("request_templates") or {}) == {} + and policy_only_request_templates.get("selected_names") == ["maintain_execute"] + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("non_cacheable_templates") or []) + ) + external_recipe_detail = external_recipe_request_templates.get("recommended_recipe_detail") or {} + request_templates_external_agent_contract_ok = ( + external_recipe_request_templates.get("selected_recipe") == "external_agent_quickstart" + and external_recipe_request_templates.get("recommended_recipe") == "external_agent_quickstart" + and bool((external_recipe_request_templates.get("external_agent_execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_request_templates.get("external_agent_execution_loop_contract") or []) >= 5 + and ((external_recipe_request_templates.get("orchestration_contract") or {}).get("recommended_first_call")) == "startup" + and bool(((external_recipe_request_templates.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with")) + and len((((external_recipe_request_templates.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or [])) >= 4 + and bool((external_recipe_detail.get("execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_detail.get("execution_loop_contract") or []) >= 5 + and ((external_recipe_detail.get("orchestration_contract") or {}).get("recommended_route_call")) == "route --summary-only" + and bool(((external_recipe_detail.get("entry_patterns") or {}).get("feishu_channel") or {}).get("route_with")) + and bool(((((external_recipe_detail.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool")) + and any( + self._clean_text(item.get("step")) == "policy" + for item in (external_recipe_detail.get("execution_loop_contract") or []) + if isinstance(item, dict) + ) + ) + start_new_recovery = self._assistant_recovery_public_data( + session_state={"has_session": False}, + action_templates=[{"name": "start_pansou_search", "tool": "agent_resource_officer_smart_entry"}], + ) + start_new_recovery_ok = ( + start_new_recovery.get("mode") == "start_new" + and start_new_recovery.get("can_resume") is False + and start_new_recovery.get("recommended_action") == "start_pansou_search" + ) + executed_plan_recovery = self._assistant_recovery_public_data( + session_state={ + "has_session": True, + "session": "selfcheck", + "session_id": "assistant::selfcheck", + "saved_plan": { + "has_pending": False, + "has_plan": True, + "latest": { + "plan_id": "plan-selfcheck-executed", + "executed": True, + }, + }, + }, + ) + executed_plan_recovery_ok = ( + executed_plan_recovery.get("mode") == "followup_executed_plan" + and executed_plan_recovery.get("can_resume") is True + and executed_plan_recovery.get("recommended_action") == "query_execution_followup" + ) + execute_plan_followup_samples = { + workflow: self._assistant_plan_execute_followup( + workflow=workflow, + session="selfcheck", + session_id="selfcheck-session", + session_state={ + "keyword": keyword, + "session": "selfcheck", + "session_id": "selfcheck-session", + }, + ok=True, + plan_id=f"plan-selfcheck-{workflow}", + ) + for workflow, keyword in [ + ("mp_best_download", "蜘蛛侠"), + ("mp_subscribe", "钢铁侠"), + ("hdhive_unlock_selected", "复仇者联盟"), + ("ai_replay_failed_sample", "地狱乐"), + ] + } + execute_plan_followups_ok = ( + [item.get("name") for item in (execute_plan_followup_samples.get("mp_best_download") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_ingest_status", "query_mp_download_history", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("mp_best_download") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_best_download") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_best_download") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("mp_subscribe") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + and (execute_plan_followup_samples.get("mp_subscribe") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_subscribe") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_subscribe") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("action_templates") or []] + == ["query_mp_local_diagnose", "query_mp_ingest_status", "query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + and (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("recommended_action") == "query_mp_local_diagnose" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("preferred_command") == "诊断" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("fallback_command") == "入库状态" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("recommended_agent_behavior") == "auto_continue" + ) + ai_replay_execution_summary_resolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=True, + has_title=True, + ) + ai_replay_execution_summary_unresolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_failed = self._assistant_ai_replay_execution_decision_summary( + ok=False, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_ok = ( + ai_replay_execution_summary_resolved.get("preferred_command") == "诊断" + and ai_replay_execution_summary_resolved.get("recommended_agent_behavior") == "auto_continue" + and ai_replay_execution_summary_unresolved.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_unresolved.get("fallback_command") == "样本洞察" + and ai_replay_execution_summary_failed.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_failed.get("recommended_agent_behavior") == "show_only" + ) + checks = { + "compact_templates": compact_templates_ok, + "bool_parser": bool_parse_ok, + "protocol": protocol_ok, + "maintain_dry_run": maintain_dry_run_ok, + "maintenance_templates_compact": maintenance_templates_compact_ok, + "request_templates": request_templates_ok, + "request_templates_filter": request_templates_filter_ok, + "request_templates_policy": request_templates_policy_ok, + "request_templates_schema": request_templates_schema_ok, + "request_templates_cache": request_templates_cache_ok, + "request_templates_sequence": request_templates_sequence_ok, + "request_templates_recipes": request_templates_recipes_ok, + "request_templates_recipe_summary": request_templates_recipe_summary_ok, + "request_templates_recommended_recipe": request_templates_recommended_recipe_ok, + "request_templates_recipe_filter": request_templates_recipe_filter_ok, + "request_templates_recommended_recipe_detail": request_templates_recommended_recipe_detail_ok, + "request_templates_policy_only": request_templates_policy_only_ok, + "request_templates_external_agent_contract": request_templates_external_agent_contract_ok, + "startup_request_templates": startup_request_templates_ok, + "start_new_recovery_not_resumable": start_new_recovery_ok, + "executed_plan_recovery": executed_plan_recovery_ok, + "execute_plan_followups": execute_plan_followups_ok, + "ai_replay_execution_summary": ai_replay_execution_summary_ok, + "toolbox_startup_endpoint": bool((toolbox.get("endpoints") or {}).get("startup")), + "toolbox_maintain_endpoint": bool((toolbox.get("endpoints") or {}).get("maintain")), + "toolbox_request_templates_endpoint": bool((toolbox.get("endpoints") or {}).get("request_templates")), + "toolbox_maintain_tool": bool((toolbox.get("tools") or {}).get("maintain")), + "toolbox_request_templates_tool": bool((toolbox.get("tools") or {}).get("request_templates")), + "toolbox_selfcheck_endpoint": bool((toolbox.get("endpoints") or {}).get("selfcheck")), + } + ok = all(bool(value) for value in checks.values()) + return { + "protocol_version": "assistant.v1", + "action": "selfcheck", + "ok": ok, + "compact": True, + "version": self.plugin_version, + "checks": checks, + "bool_cases": bool_cases, + "template_samples": { + "action": { + "name": action_template.get("name"), + "body_compact": (action_template.get("body") or {}).get("compact"), + "action_body_compact": (action_template.get("action_body") or {}).get("compact"), + }, + "route": { + "name": route_template.get("name"), + "body_compact": (route_template.get("body") or {}).get("compact"), + "action_body_compact": (route_template.get("action_body") or {}).get("compact"), + }, + "request_templates": { + name: { + "method": (request_templates.get(name) or {}).get("method"), + "endpoint": (request_templates.get(name) or {}).get("endpoint"), + } + for name in ["maintain_execute", "workflow_dry_run", "saved_plan_execute"] + }, + "execute_plan_followups": { + workflow: { + "next_actions": (sample or {}).get("next_actions") or [], + "recommended_action": self._clean_text((sample or {}).get("recommended_action")), + "follow_up_hint": self._clean_text((sample or {}).get("follow_up_hint")), + "template_names": [ + self._clean_text(item.get("name")) + for item in ((sample or {}).get("action_templates") or []) + if isinstance(item, dict) and self._clean_text(item.get("name")) + ], + } + for workflow, sample in execute_plan_followup_samples.items() + }, + }, + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_pulse", "assistant_toolbox", "assistant_readiness"], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "readiness": "/api/v1/plugin/AgentResourceOfficer/assistant/readiness?compact=true", + }, + } + + def _format_assistant_selfcheck_text(self) -> str: + data = self._assistant_selfcheck_public_data() + checks = data.get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 协议自检", + f"版本:{data.get('version')}", + f"结果:{'通过' if data.get('ok') else '失败'}", + ] + if failed: + lines.append("失败项:" + " / ".join(failed)) + return "\n".join(lines) + + def _assistant_response_data( + self, + *, + session: str, + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload = dict(data or {}) + session_name = self._clean_text(session) or "default" + session_state = self._assistant_session_public_data(session=session_name) + payload["action"] = self._clean_text(payload.get("action")) or "assistant_response" + payload["ok"] = bool(payload.get("ok", True)) + payload["write_effect"] = payload.get("write_effect") or self._assistant_write_effect_for_action(payload["action"]) + payload["error_code"] = self._clean_text(payload.get("error_code")) or ("" if payload["ok"] else "assistant_error") + payload["protocol_version"] = "assistant.v1" + payload["session"] = session_name + payload["session_id"] = session_state.get("session_id") or self._assistant_session_id(session_name) + payload["session_state"] = session_state + payload["preference_status"] = payload.get("preference_status") or self._assistant_preferences_status_brief(session=session_name) + payload["next_actions"] = payload.get("next_actions") or session_state.get("suggested_actions") or [] + if payload["preference_status"].get("needs_onboarding") and "preferences.init" not in payload["next_actions"]: + payload["next_actions"] = ["preferences.init", *list(payload["next_actions"] or [])] + payload["action_templates"] = payload.get("action_templates") or session_state.get("action_templates") or [] + payload["recovery"] = payload.get("recovery") or session_state.get("recovery") or self._assistant_recovery_public_data(session_state=session_state) + if payload.get("action") == "mp_recommendations" or self._clean_text(session_state.get("kind")) == "assistant_mp_recommend": + payload["source"] = self._clean_text(payload.get("source") or session_state.get("source")) + payload["requested_source"] = self._clean_text(payload.get("requested_source") or session_state.get("requested_source")) + payload["fallback_source"] = self._clean_text(payload.get("fallback_source") or session_state.get("fallback_source")) + payload["media_type"] = self._clean_text(payload.get("media_type") or session_state.get("media_type")) + recommend_handoff = session_state.get("recommend_handoff") if isinstance(session_state.get("recommend_handoff"), dict) else {} + if recommend_handoff: + payload["recommend_handoff"] = recommend_handoff + payload["return_short_command"] = self._clean_text(payload.get("return_short_command") or recommend_handoff.get("return_short_command") or "回推荐") + return payload + + @staticmethod + def _assistant_write_effect_for_action(action: str) -> str: + action_name = str(action or "").strip() + if action_name in { + "share_route", + "hdhive_unlock", + "transfer_115", + "quark_transfer", + "quark_clear_default_dir", + "p115_clear_default_dir", + "p115_resume", + "p115_cancel", + "p115_qrcode_start", + "p115_qrcode_check", + "hdhive_checkin", + "mp_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_search", + "pick_mp_download", + "start_mp_subscribe", + "start_mp_subscribe_search", + "ai_replay_failed_sample", + "preferences_save", + "preferences_reset", + "execute_actions", + "execute_plan", + "maintain", + }: + return "write" + if action_name in {"workflow_plan", "plans_clear", "session_clear", "sessions_clear"}: + return "state" + return "read" + + def _merge_assistant_structured_input(self, body: Dict[str, Any], parsed: Dict[str, str]) -> Dict[str, str]: + merged = dict(parsed or {}) + body = dict(body or {}) + + mode = self._clean_text(body.get("mode")) + if mode in {"mp", "mp_download_title", "pansou", "hdhive", "smart", "smart_decision", "smart_plan", "smart_execute", "cloud_transfer_execute"}: + merged["mode"] = mode + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if keyword: + merged["keyword"] = keyword + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if share_url: + merged["url"] = share_url + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + if access_code: + merged["access_code"] = access_code + path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + if path: + merged["path"] = path + media_type = self._clean_text(body.get("media_type") or body.get("type")).lower() + if media_type: + merged["type"] = media_type + year = self._clean_text(body.get("year")) + if year: + merged["year"] = year + cloud_provider = self._clean_text(body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider in {"115", "quark"}: + merged["cloud_provider"] = cloud_provider + client_type = self._clean_text(body.get("client_type") or body.get("client")) + if client_type: + merged["client_type"] = P115TransferService.normalize_qrcode_client_type(client_type) + if "is_gambler" in body: + merged["is_gambler"] = "true" if self._parse_bool_value(body.get("is_gambler"), False) else "false" + action = self._clean_text(body.get("action")) + if action: + merged["action"] = action + result_filter = self._clean_text(body.get("result_filter") or body.get("filter")).lower() + if result_filter in {"latest_episode", "latest", "latest_episodes"}: + merged["result_filter"] = "latest_episode" + plan_id = self._clean_text(body.get("plan_id") or body.get("plan")) + if plan_id: + merged["plan_id"] = plan_id + download_control = self._clean_text(body.get("download_control") or body.get("control") or body.get("operation")) + if download_control: + merged["download_control"] = download_control + subscribe_control = self._clean_text(body.get("subscribe_control") or body.get("control") or body.get("operation")) + if subscribe_control: + merged["subscribe_control"] = subscribe_control + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index > 0: + merged["sample_index"] = str(sample_index) + if "remove_if_resolved" in body: + merged["remove_if_resolved"] = "true" if self._parse_bool_value(body.get("remove_if_resolved"), True) else "false" + return merged + + @staticmethod + def _parse_assistant_text(text: str) -> Dict[str, str]: + raw = str(text or "").strip() + compact = re.sub(r"\s+", "", raw).lower() + share_url = AgentResourceOfficer._extract_first_url(raw) + remain = raw.replace(share_url, " ").strip() if share_url else raw + mode, query = AgentResourceOfficer._normalize_search_prefix(remain) + plan_match = re.search(r"\bplan-[a-zA-Z0-9]+\b", raw) + options: Dict[str, str] = { + "text": raw, + "url": share_url, + "access_code": "", + "path": "", + "mode": mode, + "keyword": query or remain, + "result_filter": "", + "source_order_text": "", + "cloud_provider": "", + "type": "", + "year": "", + "action": "", + "client_type": "", + "status": "", + "hash": "", + "plan_id": plan_match.group(0) if plan_match else "", + "decision_intent": "", + "sample_index": "", + "remove_if_resolved": "true", + } + if options.get("mode") in {"smart", "smart_decision"} and options.get("keyword"): + cleaned_keyword, decision_intent = AgentResourceOfficer._extract_smart_decision_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if decision_intent: + options["decision_intent"] = decision_intent + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + if raw.startswith("云盘搜索") or raw.startswith("云盘搜"): + options["source_order_text"] = "pansou,hdhive" + transfer_provider_prefixes = [ + ("夸克转存资源", "quark"), + ("夸克转存", "quark"), + ("115转存资源", "115"), + ("115转存", "115"), + ] + for prefix, provider in transfer_provider_prefixes: + if raw == prefix: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = "" + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + replay_match = re.match(r"^\s*(重放样本|重识别样本|重跑样本)\s*(\d+)?(?:\s+(.*))?$", raw) + if replay_match: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if replay_match.group(2): + options["sample_index"] = replay_match.group(2) + remain_text = AgentResourceOfficer._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if options.get("plan_id") and compact.startswith(("执行plan-", "确认plan-", "executeplan-")): + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif options.get("plan_id") and compact.startswith(("取消plan-", "清理plan-", "删除plan-", "clearplan-", "cancelplan-")): + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "帮助", + "使用帮助", + "命令帮助", + "help", + "agenthelp", + "arohelp", + "插件帮助", + }: + options["action"] = "assistant_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "执行计划", + "执行最新计划", + "确认计划", + "确认执行计划", + "执行plan", + "执行最新plan", + "executeplan", + "executelatestplan", + }: + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "计划列表", + "查看计划", + "待执行计划", + "保存计划", + "plans", + "listplans", + }: + options["action"] = "plans_list" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "偏好", + "片源偏好", + "查看偏好", + "偏好设置", + "智能体偏好", + "preferences", + "getpreferences", + }: + options["action"] = "preferences_get" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重置偏好", + "清除偏好", + "重设偏好", + "恢复默认偏好", + "resetpreferences", + }: + options["action"] = "preferences_reset" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "评分策略", + "评分规则", + "自动化规则", + "scoringpolicy", + "scoringrules", + }: + options["action"] = "scoring_policy" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消计划", + "清理计划", + "删除计划", + "cancelplan", + "clearplan", + "deleteplan", + }: + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录", + "115扫码", + "扫码115", + "登录115", + "115login", + "115qrcode", + "p115login", + "p115qrcode", + }: + options["action"] = "p115_qrcode_start" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "检查115登录", + "检查115扫码", + "检查扫码", + "115check", + "check115login", + "p115check", + }: + options["action"] = "p115_qrcode_check" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录状态", + "115状态", + "查看115状态", + "115健康", + "115status", + "p115status", + }: + options["action"] = "p115_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115帮助", + "115命令", + "115使用", + "115help", + "p115help", + }: + options["action"] = "p115_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115任务", + "待处理115", + "待继续115", + "115pending", + "p115pending", + }: + options["action"] = "p115_pending" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空夸克默认目录", + "清空夸克默认转存目录", + "清空夸克转存目录", + "清空夸克目录", + }: + options["action"] = "quark_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空115默认目录", + "清空115默认转存目录", + "清空115转存目录", + "清空115目录", + "清空115网盘转存目录", + "清空115网盘目录", + }: + options["action"] = "p115_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续115任务", + "重试115任务", + "继续115转存", + "重试115转存", + "continue115", + "resume115", + }: + options["action"] = "p115_resume" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消115任务", + "取消115转存", + "清除115任务", + "cancel115", + "clear115", + }: + options["action"] = "p115_cancel" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到", + "签到", + "hdhivecheckin", + "hdhivesign", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到日志", + "签到日志", + "影巢日志", + "hdhivecheckinhistory", + "hdhivesignhistory", + }: + options["action"] = "hdhive_checkin_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢普通签到", + "普通签到", + "普通", + "hdhivenormalcheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "false" + elif compact in { + "影巢赌狗签到", + "赌狗签到", + "赌狗", + "hdhivegamblercheckin", + "gamblercheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "true" + elif compact in { + "资源决策", + "智能决策", + "decision", + "smartdecision", + }: + options["mode"] = "smart_decision" + options["keyword"] = "" + elif compact in { + "后续", + "执行后追踪", + "继续追踪", + "followup", + "postexecute", + }: + options["action"] = "execution_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "跟进", + "继续跟进", + "查看进展", + "进展", + "smartfollowup", + }: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续决策", + "继续资源决策", + "decisioncontinue", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_continue" + elif compact in { + "换影巢", + "切换影巢", + "走影巢", + "用影巢", + "decisionhdhive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_hdhive" + elif compact in { + "换盘搜", + "切换盘搜", + "走盘搜", + "用盘搜", + "decisionpansou", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_pansou" + elif compact in { + "换pt", + "换原生", + "换mp", + "切换pt", + "切换原生", + "切换mp", + "decisionmppt", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_mp_pt" + elif compact in { + "保守一点", + "更保守", + "保守模式", + "decisionconservative", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_conservative" + elif compact in { + "激进一点", + "更激进", + "激进模式", + "decisionaggressive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_aggressive" + elif compact in { + "下载任务", + "下载状态", + "正在下载", + "下载列表", + "查看下载", + "下载进度", + "downloadtasks", + "downloadstatus", + }: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载最佳", + "下载推荐", + "下载最好", + "下载最佳片源", + "下载推荐片源", + "downloadbest", + }: + options["action"] = "mp_download_best" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载历史", + "下载记录", + "记录", + "历史下载", + "downloadhistory", + }: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近下载", + "recentdownloads", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["download_only"] = "true" + elif compact in { + "最近", + "最近动态", + "动态", + "recentactivity", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "追踪", + "资源追踪", + "下载追踪", + "媒体状态", + "落库状态", + "状态", + "进度", + "lifecyclestatus", + }: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库状态", + "本地入库", + "入库", + "ingeststatus", + }: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "识别", + "媒体识别", + "媒体详情", + "mp识别", + "mp媒体识别", + "mpmediadetail", + }: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载器", + "下载器状态", + "下载器列表", + "查看下载器", + "downloaders", + }: + options["action"] = "mp_downloaders" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "站点", + "站点状态", + "站点列表", + "pt站点", + "pt站点状态", + "sites", + }: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "订阅列表", + "订阅状态", + "查看订阅", + "mp订阅", + "subscribes", + }: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库历史", + "整理历史", + "转移历史", + "入库记录", + "最近整理", + "transferhistory", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近入库", + "recentingest", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["transfer_only"] = "true" + elif compact in { + "入库失败", + "整理失败", + "失败入库", + "失败整理", + "transferfailed", + }: + options["action"] = "mp_ingest_failures" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "failed" + elif compact in { + "入库成功", + "整理成功", + "成功入库", + "成功整理", + "transfersuccess", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "success" + elif compact in { + "本地诊断", + "诊断", + "失败原因", + "localdiagnose", + }: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "失败样本", + "识别样本", + "ai失败样本", + "aifailedsamples", + }: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "工作清单", + "识别工作清单", + "样本清单", + "sampleworklist", + }: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "样本洞察", + "识别洞察", + "失败洞察", + "sampleinsights", + }: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重放样本", + "重识别样本", + "重跑样本", + "aireplay", + "replaysample", + }: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + else: + for prefix, action in [ + ("执行计划", "execute_plan"), + ("执行", "execute_plan"), + ("确认计划", "execute_plan"), + ("确认", "execute_plan"), + ("查看计划", "plans_list"), + ("计划列表", "plans_list"), + ("取消计划", "plans_clear"), + ("清理计划", "plans_clear"), + ("删除计划", "plans_clear"), + ("保存偏好", "preferences_save"), + ("设置偏好", "preferences_save"), + ("更新偏好", "preferences_save"), + ("偏好设置", "preferences_save"), + ("偏好", "preferences_save"), + ("查看偏好", "preferences_get"), + ("片源偏好", "preferences_get"), + ("重置偏好", "preferences_reset"), + ("清除偏好", "preferences_reset"), + ("评分策略", "scoring_policy"), + ("评分规则", "scoring_policy"), + ("自动化规则", "scoring_policy"), + ]: + if raw.startswith(prefix + " ") or raw.startswith(prefix + ":") or raw.startswith(prefix + ":"): + remain_text = raw[len(prefix):].lstrip(" ::").strip() + if action in {"plans_list", "preferences_get", "preferences_reset"} or options.get("plan_id") or remain_text: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + if remain_text and not options.get("plan_id"): + match = re.search(r"\bplan-[a-zA-Z0-9]+\b", remain_text) + if match: + options["plan_id"] = match.group(0) + break + for prefix, control in [ + ("暂停下载", "pause"), + ("停止下载", "pause"), + ("恢复下载", "resume"), + ("继续下载", "resume"), + ("开始下载", "resume"), + ("删除下载", "delete"), + ("移除下载", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_download_control" + options["mode"] = "" + options["keyword"] = target_text + options["download_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载任务", "下载状态", "下载列表", "查看下载", "下载进度"]) + if prefix_match: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源决策", "智能决策"]) + if prefix_match: + options["mode"] = "smart_decision" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["继续跟进", "查看进展", "跟进", "进展"]) + if prefix_match: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载历史", "下载记录", "记录", "最近下载", "历史下载"]) + if prefix_match: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源追踪", "下载追踪", "媒体状态", "落库状态", "状态", "进度", "追踪"]) + if prefix_match: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["入库状态", "本地入库"]) + if prefix_match: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["MP识别", "mp识别", "媒体识别", "媒体详情", "详情媒体", "识别"]) + if prefix_match: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["站点状态", "站点列表", "PT站点", "pt站点", "站点"]) + if prefix_match: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, control in [ + ("搜索订阅", "search"), + ("刷新订阅", "search"), + ("暂停订阅", "pause"), + ("恢复订阅", "resume"), + ("删除订阅", "delete"), + ("移除订阅", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_subscribe_control" + options["mode"] = "" + options["keyword"] = target_text + options["subscribe_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["订阅列表", "订阅状态", "查看订阅", "MP订阅", "mp订阅"]) + if prefix_match: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, status_name in [ + ("入库失败", "failed"), + ("整理失败", "failed"), + ("失败入库", "failed"), + ("失败整理", "failed"), + ("入库成功", "success"), + ("整理成功", "success"), + ("成功入库", "success"), + ("成功整理", "success"), + ("入库历史", "all"), + ("入库记录", "all"), + ("整理历史", "all"), + ("转移历史", "all"), + ("最近入库", "all"), + ("最近整理", "all"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + options["action"] = "mp_ingest_failures" if status_name == "failed" else "mp_transfer_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + options["status"] = status_name + break + if not options.get("action") and raw.startswith("入库"): + blocked_prefixes = ("入库失败", "入库成功", "入库历史", "入库记录", "入库状态") + if not any(raw.startswith(prefix) for prefix in blocked_prefixes): + remain_text = raw[len("入库"):].lstrip(" ::").strip() + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = remain_text + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["为什么没入库", "本地诊断", "诊断", "失败原因"]) + if prefix_match: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["失败样本", "识别样本", "AI失败样本"]) + if prefix_match: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["工作清单", "识别工作清单", "样本清单"]) + if prefix_match: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["样本洞察", "识别洞察", "失败洞察"]) + if prefix_match: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["重放样本", "重识别样本", "重跑样本"]) + if prefix_match: + remain_text = prefix_match[1] + match = re.search(r"\d+", remain_text) + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if match: + options["sample_index"] = match.group(0) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if not options.get("action"): + for prefix, action in [ + ("转存资源", "cloud_transfer"), + ("转存", "cloud_transfer"), + ("下载资源", "mp_download"), + ("下载", "mp_download"), + ("订阅并搜索", "mp_subscribe_search"), + ("订阅搜索", "mp_subscribe_search"), + ("订阅媒体", "mp_subscribe"), + ("订阅", "mp_subscribe"), + ("热门推荐", "mp_recommendations"), + ("推荐", "mp_recommendations"), + ("智能发现", "mp_recommendations"), + ("热门发现", "mp_recommendations"), + ]: + if raw == prefix: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + if action == "cloud_transfer": + if remain_text: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if download_match: + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + else: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + else: + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if raw.startswith(prefix): + remain_text = raw[len(prefix):].strip() + if not remain_text: + continue + if action == "cloud_transfer": + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if not download_match: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + break + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + break + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if not options.get("action") and any( + marker in compact + for marker in [ + "热门影视", + "热门电影", + "热门电视剧", + "热门剧集", + "最近热门", + "有什么热门", + "看看热门", + "影视推荐", + "电影推荐", + "剧集推荐", + "电视剧推荐", + "豆瓣热门", + "豆瓣top250", + "正在热映", + "今日番剧", + "每日放送", + "bangumi", + "tmdb热门", + ] + ): + options["action"] = "mp_recommendations" + options["mode"] = "" + options["keyword"] = raw + 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"] = AgentResourceOfficer._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 key in {"client_type", "client", "客户端"} and value: + options["client_type"] = P115TransferService.normalize_qrcode_client_type(value) + continue + if key in {"hash", "download_hash", "任务hash"} and value: + options["hash"] = value.strip() + continue + if item.startswith("/") and not options["path"]: + options["path"] = AgentResourceOfficer._resolve_pan_path_value(item) + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + return options + + 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 = [] + base_urls = [] + configured_base = self._clean_text(self._pansou_base_url).rstrip("/") + if configured_base: + base_urls.append(configured_base) + for fallback_base in ("http://host.docker.internal:805", "http://127.0.0.1:805"): + if fallback_base not in base_urls: + base_urls.append(fallback_base) + for query in queries: + for base_url in base_urls: + urls.append(f"{base_url}/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=self._pansou_timeout) 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 _normalize_pansou_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_pansou_channel_items( + self, + merged: Dict[str, Any], + channel_name: str, + limit: int = 6, + ) -> 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) + normalized = { + "channel": self._normalize_pansou_channel_name(channel_name), + "url": url, + "password": password, + "note": note, + "source": source, + "datetime": dt, + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + normalized[extra_key] = value + results.append(normalized) + if len(results) >= limit: + break + return results + + @staticmethod + def _extract_size_text(text: str) -> str: + match = re.search(r"(\d+(?:\.\d+)?)\s*(gb|g|mb|m)\b", str(text or ""), flags=re.IGNORECASE) + if not match: + return "" + value = match.group(1) + unit = match.group(2).upper() + if unit == "G": + unit = "GB" + if unit == "M": + unit = "MB" + return f"{value}{unit}" + + def _pansou_item_brief_summary(self, item: Dict[str, Any]) -> str: + text = self._score_text_blob(item) + parts: List[str] = [] + if "2160" in text or "4k" in text or "uhd" in text: + parts.append("4K") + elif "1080" in text: + parts.append("1080P") + elif "720" in text: + parts.append("720P") + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + parts.append("杜比视界") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + parts.append("HDR") + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "双语", "官中", "内封简繁"]): + parts.append("中字") + elif self._score_has_any(text, ["无中字", "无字幕"]): + parts.append("无中字") + progress = self._extract_series_progress(text) + if progress.get("episode_count", 0) >= max(3, progress.get("max_episode", 0)): + parts.append(f"E01-E{progress['max_episode']:02d}") + elif progress.get("max_episode", 0) > 0: + parts.append(f"更新到E{progress['max_episode']:02d}") + size_text = self._extract_size_text(text) or self._extract_size_text(self._list_text(item.get("share_size") or item.get("size"))) + if size_text: + parts.append(size_text) + return " | ".join(parts[:5]) + + @staticmethod + def _format_markdown_link(title: Any, url: Any) -> str: + clean_title = str(title or "").strip() + clean_url = str(url or "").strip() + if not clean_title: + return clean_url + if not clean_url: + return clean_title + safe_title = clean_title.replace("[", "[").replace("]", "]") + return f"[{safe_title}]({clean_url})" + + @staticmethod + def _parse_simple_cjk_number(text: str) -> Optional[int]: + raw = str(text or "").strip() + if not raw: + return None + if raw.isdigit(): + return int(raw) + mapping = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + if raw == "十": + return 10 + if "十" in raw: + left, right = raw.split("十", 1) + left_value = 1 if left == "" else mapping.get(left) + if left_value is None: + return None + right_value = 0 if right == "" else mapping.get(right) + if right_value is None: + return None + return left_value * 10 + right_value + total = 0 + for ch in raw: + if ch not in mapping: + return None + total = total * 10 + mapping[ch] + return total if total >= 0 else None + + @classmethod + def _normalize_simple_cjk_numbers(cls, text: str) -> str: + raw = str(text or "") + if not raw: + return "" + pattern = re.compile(r"[零〇一二两三四五六七八九十]{1,3}") + def repl(match: re.Match[str]) -> str: + value = cls._parse_simple_cjk_number(match.group(0)) + return str(value) if value is not None else match.group(0) + return pattern.sub(repl, raw) + + @classmethod + def _build_keyword_variants(cls, keyword: str) -> List[str]: + raw = cls._clean_text(keyword) + if not raw: + return [] + variants: List[str] = [] + + def add(value: Any) -> None: + text = cls._clean_text(value) + if not text: + return + if text not in variants: + variants.append(text) + + add(raw) + trimmed = re.split(r"[::((【\[\-_/|]", raw, 1)[0].strip() + add(trimmed) + if "的" in raw: + add(raw.split("的", 1)[0].strip()) + base_candidates = list(variants) + for base in base_candidates: + numeric = cls._normalize_simple_cjk_numbers(base) + add(numeric) + add(numeric.replace("个月", "ヶ月").replace("個月", "ヶ月")) + add(numeric.replace("个月", "个月").replace("個月", "个月")) + add(base.replace("三个月", "3个月").replace("三個月", "3个月")) + add(base.replace("三个月", "3ヶ月").replace("三個月", "3ヶ月")) + add(re.sub(r"\s+", "", base)) + return variants + + def _pansou_payload_item_count(self, payload: Dict[str, Any]) -> int: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + total = 0 + for channel in ("115", "quark"): + items = merged.get(channel) or [] + if isinstance(items, list): + total += len(items) + return total + + def _call_pansou_search_with_variants(self, keyword: str) -> Tuple[bool, Dict[str, Any], str, str]: + variants = self._build_keyword_variants(keyword) + last_error = "" + last_payload: Dict[str, Any] = {} + for variant in variants: + ok, payload, message = self._call_pansou_search(variant) + if ok and self._pansou_payload_item_count(payload) > 0: + return True, payload, message, variant + last_error = message + last_payload = payload if isinstance(payload, dict) else {} + return False, last_payload, last_error or "盘搜暂无结果", self._clean_text(keyword) + + def _assistant_pansou_entry_summary(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(items) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "pansou_result", + "label": "先看条目再选编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "pansou_result", + "label": "盘搜列表已返回", + "decision_hint": "默认直接回编号即可转存;想先确认可回复“选择 编号 详情”。只有明确要生成计划时才发“计划选择 编号”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _best_series_progress_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return {} + candidates.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("max_episode")), 0), + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + ), + reverse=True, + ) + return candidates[0] + + def _latest_series_progress_items(self, items: List[Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return [] + best_episode = max(self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) for item in candidates) + if best_episode <= 0: + return [] + matches = [ + item for item in candidates + if self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) == best_episode + ] + matches.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + self._safe_int(value.get("updated_at"), 0), + self._clean_text(value.get("datetime")), + ), + reverse=True, + ) + return matches[: max(1, limit)] + + def _latest_episode_mp_items(self, items: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: + candidates: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + if max_episode <= 0: + continue + enriched = dict(item) + enriched["_series_progress"] = progress + candidates.append(enriched) + if not candidates: + return [], 0 + latest_episode = max(self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) for item in candidates) + if latest_episode <= 0: + return [], 0 + latest_items = [ + item for item in candidates + if self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) == latest_episode + ] + return latest_items, latest_episode + + def _episode_filter_mp_items(self, items: List[Dict[str, Any]], episode: int) -> List[Dict[str, Any]]: + target = self._safe_int(episode, 0) + if target <= 0: + return [] + matches: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + episode_count = self._safe_int(progress.get("episode_count"), 0) + if max_episode == target: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + continue + if episode_count > 0 and max_episode >= target: + start_episode = max(1, max_episode - episode_count + 1) + if start_episode <= target <= max_episode: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + return matches + + def _latest_resource_date_text(self, items: List[Dict[str, Any]]) -> str: + latest_text = "" + latest_unix = 0 + for item in items or []: + if not isinstance(item, dict): + continue + text = self._clean_text(item.get("datetime") or item.get("updated_at_text")) + if text and text > latest_text: + latest_text = text + unix_value = self._safe_int(item.get("updated_at"), 0) + if unix_value > latest_unix: + latest_unix = unix_value + if latest_text: + return latest_text + if latest_unix > 0: + return self._format_unix_time(latest_unix) + return "" + + def _format_update_resource_choice(self, item: Dict[str, Any], source_type: str) -> str: + current = dict(item or {}) + index = self._safe_int(current.get("_index") or current.get("index"), 0) + provider = self._clean_text(current.get("channel") or current.get("pan_type")).upper() + if provider == "QUARK": + provider = "夸克" + elif provider == "115": + provider = "115" + provider_emoji = "🗄" if provider == "夸克" else "📺" if provider == "115" else "🔗" + label = f"{index}. {provider_emoji}" if index > 0 else f"?. {provider_emoji}" + if provider: + label = f"{label} {provider}" + if source_type == "pansou": + brief = self._pansou_item_brief_summary(current) + date_text = self._format_pansou_display_datetime(current.get("datetime")) + title_text = self._clean_text(current.get("note") or current.get("title") or "盘搜资源") + parts = [label] + if date_text: + parts.append(date_text) + if brief: + parts.append(brief) + if title_text and title_text != brief: + parts.append(self._truncate_text(title_text, 80)) + return " · ".join(parts) + progress = self._format_update_progress_label(current.get("_series_progress")) + subtitle = self._resource_subtitle_text(current) + resolution = "/".join(current.get("video_resolution") or []) or "" + date_text = self._clean_text(current.get("updated_at_text")) + parts = [label] + if date_text: + parts.append(self._format_pansou_display_datetime(date_text)) + if resolution: + parts.append(f"✨{resolution}" if "4" in resolution or "2160" in resolution else resolution) + if subtitle: + parts.append(f"字幕:{subtitle}") + if progress and progress != "未识别到集数": + parts.append(f"📌 {progress}") + detail = self._clean_text(current.get("title") or current.get("remark") or current.get("description")) + if detail: + parts.append(self._truncate_text(detail, 70)) + return " · ".join(parts) + + def _format_update_progress_label(self, progress: Optional[Dict[str, Any]] = None) -> str: + current = dict(progress or {}) + max_episode = self._safe_int(current.get("max_episode"), 0) + episode_count = self._safe_int(current.get("episode_count"), 0) + if max_episode <= 0: + return "未识别到集数" + if episode_count >= max_episode >= 2: + return f"E01-E{max_episode:02d}" + return f"更新到 E{max_episode:02d}" + + async def _assistant_update_check( + self, + *, + keyword: str, + session: str, + cache_key: str, + year: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + if not clean_keyword: + return { + "success": False, + "message": "用法:更新检查 片名", + "data": self._assistant_response_data(session=session, data={"action": "update_check", "ok": False, "error_code": "missing_keyword"}), + } + official = self._tmdb_latest_episode_progress(clean_keyword, year) + official_episode = self._safe_int(official.get("episode"), 0) + + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + + pansou_best: Dict[str, Any] = {} + pansou_latest_items: List[Dict[str, Any]] = [] + pansou_recent_date = "" + pansou_total = 0 + search_ok, payload, _search_message = self._call_pansou_search(clean_keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + pansou_total = self._safe_int(data.get("total"), 0) + raw_items = self._collect_pansou_channel_items(merged, "115", 20) + self._collect_pansou_channel_items(merged, "quark", 20) + scored_items = self._attach_cloud_scores(raw_items, preferences=preferences, source_type="pansou", target_path=self._hdhive_default_path) + pansou_best = self._best_series_progress_item(scored_items) + pansou_latest_items = self._latest_series_progress_items(scored_items, limit=5) + pansou_recent_date = self._latest_resource_date_text(scored_items) + + hdhive_best: Dict[str, Any] = {} + hdhive_latest_items: List[Dict[str, Any]] = [] + hdhive_recent_date = "" + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + candidate_ok, candidate_result, _candidate_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type="tv", + year=self._clean_text(year), + candidate_limit=max(10, self._hdhive_candidate_page_size), + ) + if candidate_ok: + candidates = candidate_result.get("candidates") or [] + chosen = next((item for item in candidates if self._clean_text(item.get("media_type")).lower() in {"tv", "series"}), None) + if chosen is None and candidates: + chosen = candidates[0] + if isinstance(chosen, dict): + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen.get("media_type") or "tv", + tmdb_id=str(chosen.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=self._hdhive_default_path, + ) + hdhive_best = self._best_series_progress_item(preview) + hdhive_latest_items = self._latest_series_progress_items(preview, limit=5) + hdhive_recent_date = self._latest_resource_date_text(preview) + + pansou_progress = dict(pansou_best.get("_series_progress") or {}) + hdhive_progress = dict(hdhive_best.get("_series_progress") or {}) + lines = [f"更新检查:{clean_keyword}"] + if official_episode > 0: + official_title = self._clean_text(official.get("title")) or clean_keyword + lines.append(f"📺 TMDB 进度:{official_title} S{self._safe_int(official.get('season'), 1):02d}E{official_episode:02d}") + else: + lines.append("📺 TMDB 进度:未稳定识别到最新集数") + if pansou_best: + lines.append( + f"\n🟨 盘搜结果:{self._format_update_progress_label(pansou_progress)}" + f" · 最佳 #{self._safe_int(pansou_best.get('_index'), 0)}" + ) + if pansou_latest_items: + for item in pansou_latest_items: + lines.append(self._format_update_resource_choice(item, "pansou")) + elif pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟨 盘搜结果:暂无可识别更新结果") + if pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + if hdhive_best: + lines.append( + f"\n🟦 影巢结果:{self._format_update_progress_label(hdhive_progress)}" + f" · 最佳 #{self._safe_int(hdhive_best.get('_index'), 0)}" + ) + if hdhive_latest_items: + for item in hdhive_latest_items: + lines.append(self._format_update_resource_choice(item, "hdhive")) + elif hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟦 影巢结果:暂无可识别更新结果") + if hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + + latest_seen = max( + official_episode, + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + downloadable_latest = max( + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + if latest_seen > 0: + lines.append(f"\n🔎 当前观察到最高更新:E{latest_seen:02d}") + if downloadable_latest > 0: + lines.append(f"✅ 已可下载最新集:E{downloadable_latest:02d}") + else: + lines.append("⚠️ 已可下载最新集:暂未稳定识别") + if official_episode > 0: + pansou_ok = self._safe_int(pansou_progress.get("max_episode"), 0) >= official_episode + hdhive_ok = self._safe_int(hdhive_progress.get("max_episode"), 0) >= official_episode + if pansou_ok and hdhive_ok: + lines.append("✅ 盘搜和影巢都已跟上 TMDB 进度。") + else: + lines.append(f"{'✅' if pansou_ok else '⏳'} 盘搜:{'已跟上' if pansou_ok else '还没稳定跟上'}TMDB 进度。") + lines.append(f"{'✅' if hdhive_ok else '⏳'} 影巢:{'已跟上' if hdhive_ok else '还没稳定跟上'}TMDB 进度。") + pt_search_needed = latest_seen <= 0 and official_episode <= 0 + if pt_search_needed: + lines.append(f"\n下一步:官方和云盘侧都还没看到明确新集;如果要继续扩搜,可以回复:PT搜索 {clean_keyword}。") + else: + lines.append("\n下一步:直接回编号可继续处理;想先确认可发“选择 编号 详情”。") + lines.append(f"也可以发“云盘搜索 {clean_keyword}”查看更多资源,或“影巢搜索 {clean_keyword}”只看影巢。") + update_items: List[Dict[str, Any]] = [] + for item in pansou_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "pansou"}) + for item in hdhive_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "hdhive"}) + self._save_session(cache_key, { + "kind": "assistant_update_check", + "stage": "result", + "keyword": clean_keyword, + "year": self._clean_text(year), + "items": update_items, + "pansou_items": pansou_latest_items, + "hdhive_items": hdhive_latest_items, + "target_path": self._hdhive_default_path, + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check", + "ok": True, + "keyword": clean_keyword, + "official_progress": official, + "pansou_best": pansou_best, + "pansou_latest_items": pansou_latest_items, + "pansou_recent_date": pansou_recent_date, + "hdhive_best": hdhive_best, + "hdhive_latest_items": hdhive_latest_items, + "hdhive_recent_date": hdhive_recent_date, + "latest_seen_episode": latest_seen, + "downloadable_latest_episode": downloadable_latest, + "decision_summary": { + "stage": "update_check", + "label": "更新检查已完成", + "preferred_command": f"PT搜索 {clean_keyword}" if pt_search_needed else f"云盘搜索 {clean_keyword}", + "fallback_command": f"云盘搜索 {clean_keyword}" if pt_search_needed else f"影巢搜索 {clean_keyword}", + "compact_commands": [f"PT搜索 {clean_keyword}", f"云盘搜索 {clean_keyword}"] if pt_search_needed else [f"云盘搜索 {clean_keyword}", f"影巢搜索 {clean_keyword}"], + "recommended_agent_behavior": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + }, + }), + } + + @classmethod + def _read_tmdb_api_key(cls) -> str: + for value in [ + os.environ.get("TMDB_API_KEY", ""), + os.environ.get("TMDB_KEY", ""), + cls._clean_text(getattr(settings, "TMDB_API_KEY", "") if settings is not None else ""), + ]: + if cls._clean_text(value): + return cls._clean_text(value) + 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_text(value.strip().strip("'\"")) + if key: + return key + return "" + + @classmethod + def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: + clean_tmdb_id = cls._clean_text(tmdb_id) + clean_media_type = cls._clean_text(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 [] + endpoint = "movie" if clean_media_type == "movie" else "tv" + url = ( + f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?" + f"{urlencode({'api_key': tmdb_api_key, 'language': 'zh-CN', 'append_to_response': 'credits'})}" + ) + 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_text((member or {}).get("name")) + department = cls._clean_text((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 + + @classmethod + def _tmdb_latest_episode_progress(cls, keyword: str, year: str = "") -> Dict[str, Any]: + title = cls._clean_text(keyword) + if not title: + return {} + tmdb_api_key = cls._read_tmdb_api_key() + if not tmdb_api_key: + return {} + params = { + "api_key": tmdb_api_key, + "language": "zh-CN", + "query": title, + } + try: + search_url = "https://api.themoviedb.org/3/search/tv?" + urlencode(params) + request = UrlRequest(url=search_url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8", "ignore")) + results = payload.get("results") or [] + if not isinstance(results, list) or not results: + return {} + target_year = cls._clean_text(year)[:4] + picked = None + for item in results: + first_air = cls._clean_text((item or {}).get("first_air_date"))[:4] + if target_year and first_air and first_air == target_year: + picked = item + break + if picked is None: + picked = results[0] + tv_id = cls._clean_text((picked or {}).get("id")) + if not tv_id: + return {} + detail_url = ( + f"https://api.themoviedb.org/3/tv/{tv_id}?" + + urlencode({"api_key": tmdb_api_key, "language": "zh-CN"}) + ) + detail_request = UrlRequest(url=detail_url, headers={"Accept": "application/json"}) + with urlopen(detail_request, timeout=20) as response: + detail = json.loads(response.read().decode("utf-8", "ignore")) + last_episode = (detail.get("last_episode_to_air") or {}) if isinstance(detail, dict) else {} + season_number = cls._safe_int(last_episode.get("season_number"), 0) + episode_number = cls._safe_int(last_episode.get("episode_number"), 0) + return { + "tmdb_id": tv_id, + "title": cls._clean_text(detail.get("name") or picked.get("name") or title), + "year": cls._clean_text(detail.get("first_air_date") or picked.get("first_air_date"))[:4], + "season": season_number, + "episode": episode_number, + "status": cls._clean_text(detail.get("status")), + } + except Exception: + return {} + + def _maybe_enrich_hdhive_candidate_with_actors(self, candidate: Dict[str, Any]) -> Dict[str, Any]: + enriched = dict(candidate or {}) + if enriched.get("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]]) -> List[Dict[str, Any]]: + indexed = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] + pending = [(idx, item) for idx, item in indexed if not (item.get("actors") or [])] + enriched_map: Dict[int, Dict[str, Any]] = {idx: item for idx, item in indexed} + if pending: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as executor: + future_map = { + executor.submit(self._maybe_enrich_hdhive_candidate_with_actors, item): idx + for idx, item 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[idx][1]) + return [enriched_map[idx] for idx, _ in indexed] + + @staticmethod + def _format_candidate_label(item: Dict[str, Any]) -> str: + title = str(item.get("title") or "未命名") + year = str(item.get("year") or "?") + raw_media_type = str(item.get("media_type") or item.get("type") or "?") + media_type = { + "movie": "电影", + "tv": "剧集", + "series": "剧集", + }.get(raw_media_type.lower(), raw_media_type) + actors = item.get("actors") or [] + parts = [year, media_type] + actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) + if actor_text: + parts.append(f"主演:{actor_text}") + return f"{title} ({' | '.join([part for part in parts if part])})" + + @staticmethod + def _format_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "候选影片:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"候选影片:{len(candidates)} 个,请先选择影片:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") + lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @staticmethod + def _format_mp_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "MP 搜索候选:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"MP 搜索候选:{len(candidates)} 个,请先选择正确的电影/剧集:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复编号或“选择 编号”,选定后再搜索 PT 资源。") + lines.append("如需补充当前候选页主演,可回复:详情 或 审查。") + lines.append("如果已经知道年份,也可以直接发:MP搜索 片名 年份。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _keyword_has_explicit_year(cls, keyword: Any, year: Any = "") -> bool: + return bool(cls._clean_text(year) or re.search(r"(?:19|20)\d{2}", cls._clean_text(keyword))) + + @staticmethod + def _list_text(value: Any, separator: str = "/") -> str: + if value is None: + return "" + if isinstance(value, (list, tuple, set)): + parts = [str(item).strip() for item in value if str(item).strip()] + return separator.join(parts) + return str(value).strip() + + @staticmethod + def _truncate_text(value: Any, limit: int = 140) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 1)]}…" + + @staticmethod + def _resource_points_text(item: Dict[str, Any]) -> str: + points = item.get("unlock_points") + if points is None: + points = item.get("cost") + if points is None: + points = item.get("points") + if points in (0, "0"): + return "免费" + if points in (None, "", "未知"): + return "免费" if AgentResourceOfficer._resource_has_free_marker(item) else "未标积分" + return f"{points}分" + + @staticmethod + def _resource_subtitle_text(item: Dict[str, Any]) -> str: + language = AgentResourceOfficer._list_text( + item.get("subtitle_language") + or item.get("subtitle_languages") + or item.get("subtitles") + or item.get("subtitle") + ) + subtitle_type = AgentResourceOfficer._list_text(item.get("subtitle_type") or item.get("subtitle_types")) + if language and subtitle_type: + return f"{language} · {subtitle_type}" + return language or subtitle_type + + @staticmethod + def _resource_episode_text(item: Dict[str, Any]) -> str: + explicit_keys = [ + "episode_range", + "episodes_range", + "episode_info", + "episodes", + "episode", + "update_status", + "update_info", + "season_episode", + ] + for key in explicit_keys: + text = AgentResourceOfficer._list_text(item.get(key)) + if text: + return AgentResourceOfficer._truncate_text(text, 40) + + source_text = " ".join( + str(item.get(key) or "") + for key in ["title", "remark", "description", "desc", "detail", "note"] + ) + patterns = [ + r"(全\s*\d+\s*集)", + r"(全集)", + r"(更新至\s*第?\s*\d+\s*集)", + r"(更\s*\d+\s*集)", + r"(第?\s*\d+\s*[-~到至]\s*\d+\s*集)", + r"(\d+\s*-\s*\d+\s*集)", + r"(S\d{1,2}E\d{1,3}(?:\s*[-~]\s*E?\d{1,3})?)", + r"(EP?\s*\d{1,3}(?:\s*[-~]\s*EP?\s*\d{1,3})?)", + ] + for pattern in patterns: + match = re.search(pattern, source_text, flags=re.IGNORECASE) + if match: + return re.sub(r"\s+", "", match.group(1)) + return "" + + @staticmethod + def _resource_remark_text(item: Dict[str, Any]) -> str: + for key in ["remark", "description", "desc", "detail", "details", "summary", "note"]: + text = AgentResourceOfficer._truncate_text(item.get(key), 160) + if text: + return text + return "" + + def _format_resource_lines( + self, + resources: List[Dict[str, Any]], + candidate: Optional[Dict[str, Any]] = None, + *, + page: int = 1, + page_size: int = 20, + total_resources: Optional[int] = None, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(resources), page=page, page_size=page_size) + page_items = resources[start:end] + total_count = max(0, self._safe_int(total_resources if total_resources is not None else len(resources), len(resources))) + lines = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {total_count} 条") + if total_pages > 1 and page_items: + first_visible = self._safe_int((page_items[0] or {}).get("pick_index"), start + 1) + last_visible = self._safe_int((page_items[-1] or {}).get("pick_index"), min(len(resources), end)) + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {first_visible}-{last_visible} / 共 {total_count} 条:") + current_provider = "" + for local_idx, item in enumerate(page_items, start=1): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + global_index = self._safe_int(item.get("pick_index"), start + local_idx) + lines.append(self._format_hdhive_resource_summary_line(item, global_index)) + lines.append("") + summary = self._score_summary(page_items, limit=5) + best_index = self._safe_int((((summary or {}).get("best") or {}).get("index")), 0) + detail_hint = f"选择 {best_index} 详情" if best_index > 0 else "选择 1 详情" + suggestion_lines = self._format_hdhive_selection_suggestion(page_items) + if suggestion_lines: + lines.append("") + lines.extend(suggestion_lines) + lines.append(f"下一步:直接回编号即可转存;想先确认可发“{detail_hint}”。") + lines.append("如需保留计划确认链,可再发“计划选择 编号”。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(line for line in lines if line is not None) + + def _assistant_hdhive_resource_entry_summary(self, resources: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(resources) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "hdhive_resource", + "label": "先查看条目再选择编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "hdhive_resource", + "label": "影巢资源列表已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + @classmethod + def _normalize_hdhive_resource_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_hdhive" or cls._clean_text(current_state.get("stage")) != "resource": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + return {} + + @staticmethod + def _format_route_result(result: Dict[str, Any]) -> str: + lines = ["已执行资源路由"] + route = result.get("route") or {} + provider = str(route.get("provider") or route.get("pan_type") or "") + if provider: + lines.append(f"网盘:{provider}") + if route.get("message"): + lines.append(f"结果:{route.get('message')}") + if route.get("target_path"): + lines.append(f"目录:{route.get('target_path')}") + return "\n".join(lines) + + async def _unlock_and_route( + self, + slug: str, + target_path: str = "", + resource: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Dict[str, Any], str]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return False, disabled.get("data") or {}, disabled.get("message") or "影巢资源入口已关闭" + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(resource) + if not points_ok: + return False, {"resource_guard": points_data, "resource": resource or {}}, points_message + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return False, result, unlock_message + + unlock_data = result.get("data") or {} + share_url = self._clean_text(unlock_data.get("full_url") or unlock_data.get("url")) + access_code = self._clean_text(unlock_data.get("access_code")) + pan_type = self._clean_text(unlock_data.get("pan_type")).lower() + + route_result: Dict[str, Any] = { + "unlock": result, + "route": { + "pan_type": pan_type or "unknown", + "share_url": share_url, + "access_code": access_code, + "executed": False, + "message": "", + }, + } + + if share_url and (pan_type == "quark" or self._is_quark_url(share_url)): + quark_service = self._ensure_quark_service() + transfer_ok, transfer_result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "quark", + "target_path": target_path or self._quark_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, ( + "影巢解锁成功,但" + + self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ) + ) + return True, route_result, "success" + + if share_url and (pan_type == "115" or self._is_115_url(share_url)): + p115_service = self._ensure_p115_service() + transfer_ok, transfer_result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "115", + "target_path": target_path or self._p115_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + title="影巢解锁成功,但 115 转存失败", + ) + return True, route_result, "success" + + route_result["route"]["message"] = "当前解锁结果未识别到可自动路由的 115 / 夸克链接" + return True, route_result, "success" + + @staticmethod + def _is_quark_url(value: str) -> bool: + return QuarkTransferService.is_quark_share_url(value) + + @staticmethod + def _is_115_url(value: str) -> bool: + host = urlparse(value or "").netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + @staticmethod + def _run_coroutine_sync(coro): + try: + return asyncio.run(coro) + except RuntimeError: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def feishu_assistant_route(self, text: str, session: str) -> Dict[str, Any]: + return self._run_coroutine_sync( + self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "feishu", + "compact": False, + }, + ) + ) + ) + + def feishu_assistant_pick(self, arg: str, session: str) -> Dict[str, Any]: + index, target_path, action, mode = self._parse_feishu_pick_arg(arg) + return self._run_coroutine_sync( + self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "feishu", + "index": index, + "action": action, + "mode": mode, + "path": target_path, + "compact": False, + }, + ) + ) + ) + + @classmethod + def _parse_feishu_pick_arg(cls, arg: str) -> Tuple[int, str, str, str]: + return cls._parse_pick_text(arg) + + async def tool_hdhive_search_session( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + target_path: str = "", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=self._clean_text(keyword), + media_type=self._clean_text(media_type or "auto").lower(), + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return f"影巢搜索失败:{search_message}" + + candidates = result.get("candidates") or [] + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type or "auto").lower(), + "year": self._clean_text(year), + "target_path": self._clean_text(target_path), + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return ( + f"{self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size)}\n" + f"session_id: {session_id}\n" + "下一步:调用 agent_resource_officer_hdhive_pick,并传入 session_id 与 choice" + ) + + async def tool_hdhive_pick_session(self, session_id: str, index: int, target_path: str = "", action: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + session = self._load_session(self._clean_text(session_id)) + if not session: + return "会话不存在或已过期" + + stage = session.get("stage") + service = self._ensure_hdhive_service() + action = self._normalize_pick_action(action) + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(self._clean_text(session_id), {**session, "candidates": enriched}) + return self._format_candidate_lines(enriched, page=current_page, page_size=page_size) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_candidate_lines(candidates, page=next_page, page_size=page_size) + if index <= 0 or index > len(candidates): + return "候选编号超出范围" + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return f"影巢资源查询失败:{resource_message}" + preferences = self._normalize_assistant_preferences( + (self._assistant_preferences or {}).get(self._normalize_preference_key(session=self._clean_text(session.get("keyword")) or "default")) + ) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path or session.get("target_path") or "", + ) + self._save_session( + self._clean_text(session_id), + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": self._clean_text(target_path) or session.get("target_path") or "", + }, + ) + return self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)) + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)) + if index <= 0 or index > len(resources): + return "资源编号超出范围" + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=self._clean_text(target_path) or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return f"资源处理失败:{route_message}" + return self._format_route_result(route_result) + + return f"当前会话阶段不支持继续选择:{stage}" + + async def tool_route_share(self, share_url: str, access_code: str = "", target_path: str = "") -> str: + share_url = self._clean_text(share_url) + if not share_url: + return "缺少分享链接" + + if self._is_quark_url(share_url): + service = self._ensure_quark_service() + ok, result, message = service.transfer_share( + share_url, + access_code=self._clean_text(access_code), + target_path=self._clean_text(target_path) or self._quark_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_quark_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._quark_default_path, + ) + return f"夸克转存成功\n目录:{result.get('target_path') or self._quark_default_path}" + + if self._is_115_url(share_url): + ok, result, message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(access_code), + path=self._clean_text(target_path) or self._p115_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_p115_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._p115_default_path, + ) + return f"115 转存成功\n目录:{result.get('path') or self._p115_default_path}" + + return "当前链接不是可识别的 115 / 夸克分享链接" + + async def tool_assistant_route( + self, + text: str = "", + session: str = "default", + session_id: str = "", + target_path: str = "", + mode: str = "", + keyword: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + action: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "path": self._clean_text(target_path), + "mode": self._clean_text(mode), + "keyword": self._clean_text(keyword), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "action": self._clean_text(action), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "处理完成") + + async def tool_assistant_pick( + self, + session: str = "default", + session_id: str = "", + index: int = 0, + action: str = "", + mode: str = "", + target_path: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": index, + "action": self._clean_text(action), + "mode": self._clean_text(mode), + "path": self._clean_text(target_path), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "继续处理完成") + + async def tool_assistant_help(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + return self._format_assistant_help_text(session=session_name) + + async def tool_assistant_capabilities(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_capabilities_compact_data(self._assistant_capabilities_public_data()) + return ( + f"Agent影视助手:{data.get('version')};" + f"工作流 {len(data.get('workflows') or [])} 个;" + f"Tool {len(data.get('agent_tools') or [])} 个" + ) + return self._format_assistant_capabilities_text() + + async def tool_assistant_readiness(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_readiness_compact_data(self._assistant_readiness_public_data()) + services = data.get("services") or {} + return ( + f"就绪:{'是' if data.get('can_start') else '否'};" + f"115:{'可用' if services.get('p115_ready') else '不可用'};" + f"影巢:{'已配' if services.get('hdhive_configured') else '未配'};" + f"夸克:{'已配' if services.get('quark_configured') else '未配'};" + f"待计划:{data.get('saved_plans_pending') or 0}" + ) + return self._format_assistant_readiness_text() + + async def tool_feishu_health(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + channel = self._ensure_feishu_channel() + data = { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + } + if not compact: + return json.dumps(data, ensure_ascii=False, indent=2) + return ( + f"飞书入口:{'已开启' if data.get('enabled') else '未开启'};" + f"长连接:{'运行中' if data.get('running') else '未运行'};" + f"SDK:{'可用' if data.get('sdk_available') else '缺失'};" + f"AppID:{'已填' if data.get('app_id_configured') else '未填'};" + f"AppSecret:{'已填' if data.get('app_secret_configured') else '未填'};" + f"白名单:chat {data.get('allowed_chat_count') or 0} / user {data.get('allowed_user_count') or 0};" + f"其他飞书入口:{'检测到运行中' if data.get('legacy_bridge_running') else '未检测到'}" + ) + + async def tool_assistant_pulse(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_pulse_text() + + async def tool_assistant_startup(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_startup_text() + + async def tool_assistant_maintain(self, execute: bool = False, limit: int = 100) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + return self._format_assistant_maintain_text(data) + + async def tool_assistant_toolbox(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_toolbox_text() + + async def tool_assistant_request_templates( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return self._format_assistant_request_templates_text(data) + + async def tool_assistant_selfcheck(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_selfcheck_text() + + async def tool_assistant_history( + self, + session: str = "", + session_id: str = "", + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_history_compact_data( + self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + ) + failed = sum(1 for item in (data.get("items") or []) if not item.get("success")) + return f"最近执行历史:{len(data.get('items') or [])} 条;失败 {failed} 条" + return self._format_assistant_history_text(session=session, session_id=session_id, limit=limit) + + async def tool_assistant_execute_action( + self, + name: str, + session: str = "default", + session_id: str = "", + choice: Optional[int] = None, + target_path: str = "", + keyword: str = "", + media_type: str = "", + year: str = "", + share_url: str = "", + access_code: str = "", + client_type: str = "", + source: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = "", + prefer_unexecuted: bool = True, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_action( + _JsonRequestShim( + _RequestContextShim(), + { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": choice, + "path": self._clean_text(target_path), + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + "plan_id": self._clean_text(plan_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "动作执行完成") + + async def tool_assistant_execute_actions( + self, + actions: List[Dict[str, Any]], + session: str = "default", + session_id: str = "", + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_actions( + _JsonRequestShim( + _RequestContextShim(), + { + "actions": actions or [], + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "批量动作执行完成") + + async def tool_assistant_workflow( + self, + name: str, + session: str = "default", + session_id: str = "", + keyword: str = "", + choice: Optional[int] = None, + candidate_choice: Optional[int] = None, + resource_choice: Optional[int] = None, + target_path: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + source: str = "", + limit: int = 20, + dry_run: Optional[bool] = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + payload = { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "keyword": self._clean_text(keyword), + "choice": choice, + "candidate_choice": candidate_choice, + "resource_choice": resource_choice, + "path": self._clean_text(target_path), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "limit": self._safe_int(limit, 20), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + } + if dry_run is not None: + payload["dry_run"] = bool(dry_run) + result = await self.api_assistant_workflow( + _JsonRequestShim(_RequestContextShim(), payload) + ) + return str(result.get("message") or "工作流执行完成") + + async def tool_assistant_preferences( + self, + session: str = "default", + session_id: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + reset: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + method = "DELETE" if reset else "POST" if preferences else "GET" + result = await self.api_assistant_preferences( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "user_key": self._clean_text(user_key), + "preferences": preferences or {}, + "compact": bool(compact), + }, + method=method, + ) + ) + return str(result.get("message") or "偏好画像处理完成") + + async def tool_assistant_execute_plan( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_plan_execute( + _JsonRequestShim( + _RequestContextShim(), + { + "plan_id": self._clean_text(plan_id), + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "计划执行完成") + + async def tool_assistant_plans( + self, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_plans_compact_data( + self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=False, + limit=limit, + ) + ) + pending = sum(1 for item in (data.get("items") or []) if not item.get("executed")) + return f"已保存计划:{len(data.get('items') or [])} 条;待执行 {pending} 条" + return self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + + async def tool_assistant_plans_clear( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = self._clear_workflow_plans( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + return str(result.get("message") or "计划清理完成") + + async def tool_assistant_recover( + self, + session: str = "", + session_id: str = "", + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_recover( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "execute": bool(execute), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + "limit": self._safe_int(limit, 20), + }, + ) + ) + return str(result.get("message") or "恢复检查完成") + + async def tool_assistant_session_state(self, session: str = "default", session_id: str = "", compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if compact: + state = self._assistant_session_compact_data(self._assistant_session_public_data(session=session_name)) + recovery = state.get("recovery") or {} + parts = [ + f"会话:{state.get('session')}", + f"阶段:{state.get('kind') or '-'} / {state.get('stage') or '-'}", + f"恢复:{recovery.get('mode') or '-'}", + ] + if recovery.get("recommended_action"): + parts.append(f"推荐动作:{recovery.get('recommended_action')}") + return "\n".join(parts) + return self._format_assistant_session_summary(session=session_name) + + async def tool_assistant_sessions( + self, + kind: str = "", + has_pending_p115: Optional[bool] = None, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_sessions_compact_data( + self._assistant_sessions_public_data(kind=kind, has_pending_p115=has_pending_p115, limit=limit) + ) + return f"活跃会话:{data.get('total') or 0} 个;展示 {len(data.get('items') or [])} 个" + return self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + + async def tool_assistant_session_clear(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + existing = self._load_session(cache_key) + if not existing: + return "当前没有需要清理的会话。" + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return f"已清理会话:{session_name}" + + async def tool_assistant_sessions_clear( + self, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_sessions_clear( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + }, + ) + ) + return str(result.get("message") or "会话清理完成") + + async def tool_p115_qrcode_start(self, client_type: str = "alipaymini") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + final_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=final_client_type) + if not qr_ok: + return f"115 扫码二维码生成失败:{qr_message}" + return ( + "115 扫码二维码已生成\n" + f"client_type: {data.get('client_type')}\n" + f"uid: {data.get('uid')}\n" + f"time: {data.get('time')}\n" + f"sign: {data.get('sign')}\n" + f"qrcode: {data.get('qrcode')}\n" + "下一步:调用 agent_resource_officer_p115_qrcode_check,并传入 uid、time、sign 和 client_type" + ) + + async def tool_p115_qrcode_check( + self, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(uid), + time_value=self._clean_text(time_value), + sign=self._clean_text(sign), + client_type=P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type), + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": self._p115_client_type, + }) + data["cookie_saved"] = True + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"status: {status or 'unknown'}", + f"message: {qr_message}", + ] + if data.get("cookie_saved"): + lines.append("cookie_saved: true") + lines.append(self._format_p115_status_summary(title="115 登录完成")) + if data.get("cookie_keys"): + lines.append(f"cookie_keys: {', '.join(data.get('cookie_keys') or [])}") + return "\n".join(lines) + + async def tool_p115_status(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_p115_status_summary() + + async def tool_p115_pending(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + return summary or "当前没有待继续的 115 任务。" + + async def tool_p115_resume(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return "当前没有待继续的 115 任务。" + if not self._p115_status_snapshot().get("ready"): + return f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。" + resume_ok, resume_message, _ = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 Agent Tool 手动继续 115 任务", + ) + lines = ["已手动继续 115 任务", resume_message] + if not resume_ok: + lines.append("任务仍未成功,保留待继续状态。") + return "\n".join(line for line in lines if line) + + async def tool_p115_cancel(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + if not summary: + return "当前没有待取消的 115 任务。" + self._clear_pending_p115_share(session_id) + return f"{summary}\n已取消并清除这次待继续的 115 任务。" + + async def api_p115_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_p115_service() + health_ok, result, health_message = service.health() + cookie_state = result.get("cookie_state") or {} + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "p115_ready": health_ok, + "p115_direct_ready": bool(result.get("direct_ready")), + "p115_direct_source": result.get("direct_source") or "", + "p115_helper_ready": bool(result.get("helper_ready")), + "default_target_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie_configured": bool(cookie_state.get("configured")), + "p115_cookie_valid": bool(cookie_state.get("valid")), + "p115_cookie_mode": cookie_state.get("mode") or "none", + "p115_cookie_keys": cookie_state.get("cookie_keys") or [], + "message": "" if health_ok else health_message, + "raw": result, + }, + } + + async def api_p115_qrcode(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": qr_message} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_qrcode_check(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + uid = self._clean_text(request.query_params.get("uid")) + time_value = self._clean_text(request.query_params.get("time")) + sign = self._clean_text(request.query_params.get("sign")) + if not uid or not time_value or not sign: + return {"success": False, "message": "缺少 uid/time/sign,无法检查扫码状态"} + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=uid, + time_value=time_value, + sign=sign, + client_type=client_type, + ) + if qr_ok and (data.get("status") == "success"): + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + data["status_summary"] = self._format_p115_status_summary(title="115 登录完成") + if not qr_ok: + return {"success": False, "message": qr_message, "data": data} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + url = self._clean_text(body.get("url") or body.get("share_url")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_p115_service() + transfer_ok, result, transfer_message = service.transfer_share( + url=url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_p115_pending(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + data = self._pending_p115_public_data(state) + data["session_id"] = session_id + return { + "success": True, + "message": summary or "当前没有待继续的 115 任务。", + "data": data, + } + + async def api_p115_pending_resume(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return { + "success": False, + "message": "当前没有待继续的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。", + "data": {"session_id": session_id, **self._pending_p115_public_data(state)}, + } + resume_ok, resume_message, resume_data = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 API 手动继续 115 任务", + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": { + "session_id": session_id, + "result": resume_data, + "pending": self._pending_p115_public_data(self._load_session(session_id) or {}), + }, + } + + async def api_p115_pending_cancel(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + if not summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + pending_data = self._pending_p115_public_data(state) + self._clear_pending_p115_share(session_id) + return { + "success": True, + "message": f"{summary}\n已取消并清除这次待继续的 115 任务。", + "data": {"session_id": session_id, "cancelled": True, "pending": pending_data}, + } + + async def api_hdhive_unlock_and_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + route_ok, route_result, route_message = await self._unlock_and_route(slug, target_path=target_path, resource=body) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return {"success": True, "message": route_message, "data": route_result} + + async def api_share_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_url = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 自动路由") + + if self._is_quark_url(share_url): + quark_service = self._ensure_quark_service() + transfer_ok, result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": { + "provider": "quark", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "quark", + "result": result, + }, + } + + if self._is_115_url(share_url): + p115_service = self._ensure_p115_service() + transfer_ok, result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": { + "provider": "115", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "115", + "result": result, + }, + } + + return { + "success": False, + "message": "当前链接不是可识别的 115 / 夸克分享链接", + "data": {"provider": "unknown", "url": share_url}, + } + + async def api_assistant_preferences(self, request: Request): + body: Dict[str, Any] = {} + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session") or "default") + user_key = self._clean_text(body.get("user_key") or request.query_params.get("user_key")) + if request.method.upper() == "DELETE" or self._parse_bool_value(body.get("reset"), False): + preferences_data = self._reset_assistant_preferences(session=session, user_key=user_key) + return { + "success": True, + "message": "智能体片源偏好已重置,下一次启动会重新进入偏好询问。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_reset", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + if request.method.upper() == "POST": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else { + key: value + for key, value in body.items() + if key not in {"apikey", "session", "session_id", "user_key", "compact", "reset"} + } + preferences_data = self._save_assistant_preferences(session=session, user_key=user_key, preferences=preferences) + return { + "success": True, + "message": "智能体片源偏好已保存。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + + preferences_data = self._assistant_preferences_public_data(session=session, user_key=user_key) + message_text = "智能体片源偏好未初始化,请先询问用户偏好。" if preferences_data.get("needs_onboarding") else "智能体片源偏好已初始化。" + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "preferences", + "ok": True, + "preferences": preferences_data, + }), + } + + async def api_assistant_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + text = self._clean_text(body.get("text") or body.get("query") or body.get("message") or "") + state = self._load_session(cache_key) or {} + compact = self._parse_bool_value(body.get("compact"), False) + saved_plan = self._session_workflow_plan_public_data(session=session, session_id=cache_key) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + pending_plan_group = self._clean_text(state.get("pending_plan_group")) + numeric_confirmation = self._parse_pending_plan_numeric_confirmation(text) + pending_multi_plan = None + if pending_plan_group and (numeric_confirmation > 0 or self._is_pending_plan_confirmation_text(text)): + pending_multi_plan = self._find_pending_multi_plan( + session=session, + session_id=cache_key, + rank=numeric_confirmation if numeric_confirmation > 0 else 1, + group_id=pending_plan_group, + ) + pending_plan = pending_multi_plan or self._find_workflow_plan(session=session, session_id=cache_key, executed=False) + if ( + pending_plan + and ( + self._is_pending_plan_confirmation_text(text) + or self._is_pending_plan_numeric_confirmation(text, pending_plan) + ) + and not self._clean_text(body.get("plan_id")) + ): + if isinstance(state.get("recommend_handoff"), dict) and state.get("recommend_handoff"): + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(pending_plan.get("plan_id")) if pending_multi_plan else "", + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_direct_intent = self._normalize_mp_recommend_direct_intent(text) + if recommend_direct_intent: + source_name, inferred_media_type = self._normalize_mp_recommend_request( + recommend_direct_intent.get("keyword") or "tmdb_trending" + ) + recommend_result = await self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + ) + if not recommend_result.get("success"): + return finish(recommend_result) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_direct_intent.get("index") or 1, + "action": recommend_direct_intent.get("action"), + "mode": recommend_direct_intent.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_short_direct = self._normalize_mp_recommend_short_action(text, state=state) + if recommend_short_direct: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_short_direct.get("index"), + "action": recommend_short_direct.get("action"), + "mode": recommend_short_direct.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_source_compound = self._normalize_recommend_source_compound_action(text, state=state) + if recommend_source_compound: + return finish(await self._assistant_run_recommend_source_compound( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(recommend_source_compound.get("mode")), + followup_action=self._clean_text(recommend_source_compound.get("followup_action")), + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + + hdhive_resource_short = self._normalize_hdhive_resource_short_action(text, state=state) + if hdhive_resource_short: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": hdhive_resource_short.get("index"), + "action": hdhive_resource_short.get("action"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + parsed = self._merge_assistant_structured_input(body, self._parse_assistant_text(text)) + target_path = parsed.get("path") or "" + + recommend_followup = self._normalize_mp_recommend_followup(text, state=state) + has_recommend_followup = bool(recommend_followup) + if self._clean_text((recommend_followup or {}).get("action")) == "mp_recommendations": + return finish(await self._assistant_mp_recommendations( + source=self._clean_text(recommend_followup.get("keyword") or state.get("requested_source") or state.get("source") or "tmdb_trending"), + media_type=self._clean_text(recommend_followup.get("type") or state.get("media_type") or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if recommend_followup: + for key, value in recommend_followup.items(): + if value not in {None, ""}: + parsed[key] = str(value) + preparsed_action = self._clean_text(parsed.get("action")) + recommend_handoff_short = self._normalize_recommend_handoff_short_action(text, state=state) + recommend_short = self._normalize_mp_recommend_short_action(text, state=state) + if preparsed_action not in {"ai_replay_failed_sample"} and recommend_short: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": recommend_short.get("index"), + "action": recommend_short.get("action"), + "mode": recommend_short.get("mode"), + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + pick_index, pick_path, pick_action, pick_mode = self._parse_pick_text(text) + handoff_route_action = self._clean_text(recommend_handoff_short.get("route_action")) + if not pick_index and handoff_route_action: + pick_action = handoff_route_action + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"} and (pick_index > 0 or pick_action): + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": pick_index, + "action": pick_action, + "mode": pick_mode, + "path": target_path or pick_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"}: + route_action = self._normalize_pick_action(text) + smart_route_action = self._normalize_smart_search_short_action(text, state_kind=state.get("kind")) + if smart_route_action: + route_action = smart_route_action + if handoff_route_action: + route_action = handoff_route_action + if route_action: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": 0, + "action": route_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not text and not any(parsed.get(key) for key in ["mode", "keyword", "url", "action"]): + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + + assistant_action = preparsed_action + ai_short_action = self._normalize_ai_reingest_short_action(text, state=state) + if not assistant_action and ai_short_action: + for key, value in ai_short_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + recommend_handoff_action = self._normalize_recommend_handoff_action(text, state=state) + if not assistant_action and recommend_handoff_action: + for key, value in recommend_handoff_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + if not assistant_action and self._clean_text(recommend_handoff_short.get("action")): + for key, value in recommend_handoff_short.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + keyword = self._clean_text(parsed.get("keyword")) + if assistant_action == "assistant_help": + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + if assistant_action == "execute_plan": + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(parsed.get("plan_id")), + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + if assistant_action == "plans_list": + include_actions = self._parse_bool_value(body.get("include_actions"), False) + executed = self._parse_optional_bool(body.get("executed")) + limit = self._safe_int(body.get("limit"), 10) + plans_data = self._assistant_plans_public_data( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + return finish({ + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_list", + "ok": True, + **plans_data, + }), + }) + if assistant_action == "plans_clear": + plan_id = self._clean_text(parsed.get("plan_id")) + if not plan_id: + return finish({ + "success": False, + "message": "取消或清理计划需要指定 plan_id,例如:取消计划 plan-xxxx。", + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": False, + "error_code": "missing_plan_id", + }), + }) + clear_result = self._clear_workflow_plans(plan_id=plan_id, limit=1) + return finish({ + "success": bool(clear_result.get("ok")), + "message": str(clear_result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": bool(clear_result.get("ok")), + **clear_result, + }), + }) + if assistant_action in {"preferences_get", "preferences_save", "preferences_reset"}: + if assistant_action == "preferences_save": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else self._parse_assistant_preferences_text(text) + if not preferences: + return finish({ + "success": False, + "message": ( + "保存偏好缺少可识别内容。示例:保存偏好 4K 杜比 HDR 中字 全集 " + "做种>=3 影巢积分20 不自动入库" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": False, + "error_code": "missing_preferences", + }), + }) + payload = { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "preferences": preferences, + "compact": compact, + "apikey": self._extract_apikey(request, body), + } + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, payload, method="POST"))) + if assistant_action == "preferences_reset": + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="DELETE"))) + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="GET"))) + if assistant_action == "scoring_policy": + return finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if assistant_action == "hdhive_checkin": + is_gambler = self._parse_bool_value(parsed.get("is_gambler"), self._hdhive_checkin_gambler_mode) + result = self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 智能入口") + data = result.get("data") if isinstance(result.get("data"), dict) else {} + status = data.get("status") or ("签到成功" if result.get("success") else "签到失败") + mode_text = "赌狗签到" if is_gambler else "普通签到" + summary = f"影巢{mode_text}:{status}\n{result.get('message') or ''}".strip() + return finish({ + "success": bool(result.get("success")), + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin", + "ok": bool(result.get("success")), + "is_gambler": is_gambler, + "status_summary": summary, + "result": data, + }), + }) + if assistant_action == "hdhive_checkin_history": + summary = self._format_hdhive_checkin_history_text(limit=10) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin_history", + "ok": True, + "status_summary": summary, + "history": self._hdhive_checkin_history_public_data(limit=10), + }), + }) + if assistant_action == "mp_media_detail": + if not keyword: + return finish({ + "success": False, + "message": "媒体识别失败:缺少片名。用法:识别 蜘蛛侠", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_mp_media_detail( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "auto"), + year=self._clean_text(body.get("year") or parsed.get("year")), + )) + if assistant_action == "mp_downloaders": + return finish(await self._assistant_mp_downloaders(session=session, cache_key=cache_key)) + if assistant_action == "mp_sites": + return finish(await self._assistant_mp_sites( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "active"), + name=keyword, + limit=self._safe_int(body.get("limit"), 30), + )) + if assistant_action == "mp_subscribes": + return finish(await self._assistant_mp_subscribes( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "all"), + name=keyword, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "mp_transfer_history": + return finish(await self._assistant_mp_transfer_history( + session=session, + cache_key=cache_key, + title=keyword, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_ingest_failures": + return finish(await self._assistant_mp_ingest_failures( + session=session, + cache_key=cache_key, + title=keyword, + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "ai_failed_samples": + return finish(await self._assistant_ai_failed_samples( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_worklist": + return finish(await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_insights": + return finish(await self._assistant_ai_sample_insights( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if assistant_action == "ai_replay_failed_sample": + sample_index = self._safe_int(parsed.get("sample_index") or body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(parsed.get("remove_if_resolved") or body.get("remove_if_resolved"), True) + return finish(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if assistant_action == "mp_subscribe_control": + control = self._clean_text(parsed.get("subscribe_control") or body.get("subscribe_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "搜索": "search", + "刷新": "search", + "search": "search", + "run": "search", + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + target = self._clean_text(keyword or body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if control not in {"search", "pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“订阅列表”,再发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”或“删除订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + }) + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id): + return finish({ + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return finish(await self._assistant_mp_subscribe_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if assistant_action == "mp_download_tasks": + return finish(await self._assistant_mp_download_tasks( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "downloading"), + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "mp_download_history": + return finish(await self._assistant_mp_download_history( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_lifecycle_status": + return finish(await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_ingest_status": + return finish(await self._assistant_mp_ingest_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_recent_activity": + return finish(await self._assistant_mp_recent_activity( + session=session, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only") or parsed.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only") or parsed.get("transfer_only"), False), + )) + if assistant_action == "smart_followup": + return finish(await self._assistant_smart_followup( + request, + session=session, + session_id=cache_key, + keyword=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "smart_decision_adjust": + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=self._clean_text(parsed.get("decision_adjust") or body.get("decision_adjust") or "decision_continue"), + )) + if assistant_action == "execution_followup": + return finish(await self._assistant_execution_followup( + request, + session=session, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id") or parsed.get("plan_id")), + )) + if assistant_action == "mp_local_diagnose": + return finish(await self._assistant_mp_local_diagnose( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_download_control": + control = self._clean_text(parsed.get("download_control") or body.get("download_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "开始": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + target = self._clean_text(keyword or body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if control not in {"pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“下载任务”,再发“暂停下载 1”“恢复下载 1”或“删除下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + }) + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return finish({ + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_task_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ))) + return finish(await self._assistant_mp_download_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + )) + if assistant_action == "mp_download_best": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + return finish(await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action == "mp_download": + choice = self._safe_int(parsed.get("keyword") or body.get("choice") or body.get("index"), 0) + if choice <= 0: + return finish({ + "success": False, + "message": "用法:下载资源 1", + "data": self._assistant_response_data(session=session, data={"action": "mp_download", "ok": False}), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ))) + return finish(await self._assistant_mp_download( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action in {"mp_subscribe", "mp_subscribe_search"}: + if not keyword: + return finish({ + "success": False, + "message": "用法:订阅媒体 片名 或 订阅并搜索 片名", + "data": self._assistant_response_data(session=session, data={"action": assistant_action, "ok": False}), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session, + cache_key=cache_key, + immediate_search=assistant_action == "mp_subscribe_search", + ))) + return finish(await self._assistant_mp_subscribe( + keyword=keyword, + session=session, + immediate_search=assistant_action == "mp_subscribe_search", + )) + if assistant_action == "mp_recommendations": + source, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or parsed.get("keyword") or text or "tmdb_trending" + ) + return finish(await self._assistant_mp_recommendations( + source=source, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if assistant_action == "return_to_recommend": + return finish(await self._assistant_restore_mp_recommendation_handoff( + session=session, + cache_key=cache_key, + state=state, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "switch_recommend_handoff_source": + return finish(await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(parsed.get("mode")), + )) + if assistant_action == "return_to_smart_decision": + return finish(await self._assistant_route_recommend_handoff_to_smart_decision( + request, + session=session, + cache_key=cache_key, + state=state, + )) + if assistant_action == "confirm_recommend_handoff": + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=target_path, + )) + if assistant_action == "p115_help": + summary = self._format_p115_help_text() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_help", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + } + if assistant_action == "p115_status": + summary = self._format_p115_status_summary() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_status", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }) + if assistant_action == "p115_pending": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + return { + "success": True, + "message": pending_summary, + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + if assistant_action == "quark_clear_default_dir": + bridge_result = self._assistant_quarkdisk_clear_directory(self._quark_default_path) + if bridge_result is None: + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + else: + clear_ok, clear_result, clear_message = bridge_result + if not clear_ok and isinstance(clear_result, dict) and clear_result.get("fallback_to_direct"): + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + target_path = self._clean_text((clear_result or {}).get("target_path")) or self._quark_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + failed_items = (clear_result or {}).get("failed_items") or [] + lines = [ + f"清空夸克默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if removed_count: + lines.append(f"已删除:{removed_count} 项") + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + if isinstance(failed_items, list) and failed_items: + names = [ + self._clean_text(item.get("name") if isinstance(item, dict) else item) + for item in failed_items[:5] + ] + names = [name for name in names if name] + if names: + lines.append("失败项:" + "、".join(names)) + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"夸克默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"夸克默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_clear_default_dir": + p115_service = self._ensure_p115_service() + clear_ok, clear_result, clear_message = p115_service.clear_directory(self._p115_default_path) + target_path = self._clean_text((clear_result or {}).get("path")) or self._p115_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + lines = [ + f"清空115默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"115 默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"115 默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_resume": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + summary = self._format_p115_status_summary() + return { + "success": False, + "message": f"当前没有待继续的 115 任务。\n{summary}", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{pending_summary}\n当前 115 还不可用,请先回复:115登录", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": resume_ok, "result": resume_data}), + } + if assistant_action == "p115_cancel": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + self._clear_pending_p115_share(cache_key) + return { + "success": True, + "message": f"{pending_summary}\n已取消并清除这次待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + if assistant_action == "p115_qrcode_start": + previous_state = state + client_type = P115TransferService.normalize_qrcode_client_type( + parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": f"115 扫码二维码生成失败:{qr_message}"} + self._save_session( + cache_key, + { + **previous_state, + "kind": "assistant_p115_login", + "stage": "qrcode", + "client_type": client_type, + "uid": self._clean_text(data.get("uid")), + "time": self._clean_text(data.get("time")), + "sign": self._clean_text(data.get("sign")), + }, + ) + pending_text = "" + if (previous_state.get("pending_p115") or {}).get("share_url"): + pending_text = "\n检测到有待继续的 115 任务,登录成功后我会自动继续执行。" + return { + "success": True, + "message": ( + "115 扫码二维码已生成\n" + f"客户端:{client_type}\n" + "请使用 115 App 扫码确认后,再回复:检查115登录" + f"{pending_text}" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_start", + "ok": True, + "qrcode": data.get("qrcode"), + "uid": data.get("uid"), + "time": data.get("time"), + "sign": data.get("sign"), + "client_type": client_type, + }), + } + if assistant_action == "p115_qrcode_check": + if not state or str(state.get("kind") or "").strip() != "assistant_p115_login": + pending_summary = self._pending_p115_summary(state) + if pending_summary and self._p115_status_snapshot().get("ready"): + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "没有待检查的扫码会话,但检测到待继续的 115 任务。" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,继续保留待处理状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": resume_ok, "result": resume_data}), + } + summary = self._format_p115_status_summary() + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": ( + "没有待检查的 115 登录会话。\n" + f"{summary}\n" + "如需重新扫码登录,请回复:115登录" + ), + "data": { + **self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_check", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }, + } + client_type = P115TransferService.normalize_qrcode_client_type( + state.get("client_type") or parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(state.get("uid")), + time_value=self._clean_text(state.get("time")), + sign=self._clean_text(state.get("sign")), + client_type=client_type, + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + self._save_session(cache_key, {**state, "stage": "success", "client_type": client_type}) + if not qr_ok: + return { + "success": False, + "message": f"115 扫码状态:{qr_message}", + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": False, **data}), + } + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"状态:{status or 'unknown'}", + f"结果:{qr_message}", + ] + if data.get("cookie_saved"): + lines.append(self._format_p115_status_summary(title="115 登录完成")) + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + if resume_message: + lines.append("已自动继续刚才未完成的 115 任务") + lines.append(resume_message) + data["resume_ok"] = resume_ok + data["resume_result"] = resume_data + if not resume_ok: + lines.append("任务仍未成功,已继续保留待处理状态。") + elif status in {"waiting", "scanned"}: + lines.append("如果还没确认登录,请在 115 App 里点确认后再次回复:检查115登录") + message_text = "\n".join(line for line in lines if line).strip() + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": True, **data}), + } + + if parsed.get("url"): + provider = "quark" if self._is_quark_url(parsed["url"]) else "115" if self._is_115_url(parsed["url"]) else "unknown" + result = await self.api_share_route( + _JsonRequestShim(request, { + "url": parsed["url"], + "access_code": parsed.get("access_code") or "", + "path": target_path, + "trigger": "Agent影视助手 智能入口", + "apikey": self._extract_apikey(request, body), + }) + ) + if provider == "115": + if result.get("success"): + self._clear_pending_p115_share(cache_key) + else: + self._save_pending_p115_share( + cache_key, + share_url=parsed["url"], + access_code=parsed.get("access_code") or "", + target_path=target_path or self._p115_default_path, + source="assistant_link", + last_error=str(result.get("message") or ""), + ) + return finish({ + "success": bool(result.get("success")), + "message": ( + f"{'夸克' if provider == 'quark' else '115' if provider == '115' else '分享'}转存已完成\n目录:" + f"{((result.get('data') or {}).get('result') or {}).get('target_path') or ((result.get('data') or {}).get('result') or {}).get('path') or target_path or '-'}" + if result.get("success") + else ( + f"{str(result.get('message') or '处理失败')}\n{self._format_p115_resume_hint()}" + if provider == "115" + else str(result.get("message") or "处理失败") + ) + ), + "data": self._assistant_response_data(session=session, data={ + "action": "share_route", + "ok": bool(result.get("success")), + "provider": provider, + "result": result.get("data") or {}, + }), + }) + + mode = parsed.get("mode") or "hdhive" + media_type = self._clean_text(parsed.get("type") or "auto").lower() or "auto" + year = self._clean_text(parsed.get("year")) + result_filter = self._clean_text(parsed.get("result_filter")).lower() + decision_intent = self._clean_text(parsed.get("decision_intent")).lower() + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else None + if not source_order: + parsed_source_order_text = self._clean_text(parsed.get("source_order_text")) + if parsed_source_order_text: + source_order = [ + self._clean_text(item).lower() + for item in parsed_source_order_text.split(",") + if self._clean_text(item) + ] + origin = self._clean_text(body.get("origin")) + recommend_handoff = body.get("recommend_handoff") if isinstance(body.get("recommend_handoff"), dict) else {} + apikey = self._extract_apikey(request, body) + session_preference_overrides: Dict[str, Any] = {} + cloud_provider = self._clean_text(parsed.get("cloud_provider") or body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider == "quark": + session_preference_overrides.update({ + "has_quark": True, + "has_115": False, + "prefer_cloud_provider": "quark", + }) + elif cloud_provider == "115": + session_preference_overrides.update({ + "has_quark": False, + "has_115": True, + "prefer_cloud_provider": "115", + }) + + if mode == "update": + return finish(await self._assistant_update_check( + keyword=keyword, + session=session, + cache_key=cache_key, + year=year, + )) + + if mode == "mp_download_title": + if not keyword: + return finish({ + "success": False, + "message": "用法:下载 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_title", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "mp_download_title", + "label": "生成待确认下载计划", + "result_filter": result_filter, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + + if mode == "smart": + if not keyword: + return finish({ + "success": False, + "message": "用法:智能搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_search", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + )) + + if mode == "cloud_transfer_execute": + if not keyword: + return finish({ + "success": False, + "message": "用法:转存 片名;也支持:夸克转存 片名 / 115转存 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_transfer_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + pending_label = "云盘转存" + if cloud_provider == "quark": + pending_label = "夸克转存" + elif cloud_provider == "115": + pending_label = "115转存" + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "cloud_transfer_execute", + "cloud_provider": cloud_provider, + "label": pending_label, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + return finish(await self._assistant_cloud_transfer_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin or cloud_provider or "cloud_transfer", + )) + + if mode == "smart_decision": + if not keyword: + return finish({ + "success": False, + "message": "用法:资源决策 片名;也支持:智能决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + origin=origin, + )) + + if mode == "smart_plan": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能计划 片名;也可以先做“智能搜索 片名”,再回复“计划最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "smart_execute": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能执行 片名;也可以先做“智能搜索 片名”,再回复“执行最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "mp": + if not keyword: + return finish({ + "success": False, + "message": "用法:MP搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "media_search", + "ok": False, + }), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={"mode": "mp", "result_filter": result_filter} if result_filter else None, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + if candidate_year and candidate_year not in candidate_title: + keyword = f"{candidate_title} {candidate_year}" + else: + keyword = candidate_title + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + mp_items = (result.get("data") or {}).get("items") or [] + if (not result.get("success") or not mp_items) and keyword: + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="mp_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("MP/PT 当前暂无可用结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + search_ok, hdhive_result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if search_ok: + candidates = hdhive_result.get("candidates") or [] + if candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="MP/PT 当前暂无可用结果,已自动补查影巢。", + )) + if result.get("success") and recommend_handoff: + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, {**current_state, "recommend_handoff": dict(recommend_handoff)}) + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(self._load_session(cache_key) or {})) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(self._load_session(cache_key) or {}) + result["data"] = self._assistant_response_data(session=session, data=result_data) + return finish(result) + + if mode == "pansou" or mode == "cloud": + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if not search_ok: + if mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜搜索失败:{keyword}\n错误:{search_message}"} + search_ok = False + payload = {} + search_message = search_message + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + if mode == "cloud" and not items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = local_index + hdhive_candidate = dict(chosen_candidate) + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=[], + pansou_total=int(data.get("total") or 0), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if not items and mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜暂无结果:{keyword}"} + if items and mode == "cloud": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_cloud_result_page_size) + + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = len(items) + local_index + hdhive_candidate = dict(chosen_candidate) + + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=items, + pansou_total=int(data.get("total") or len(items)), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=items, + total=int(data.get("total") or len(items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search" if mode == "pansou" else "cloud_search", + search_scope="pansou" if mode == "pansou" else "pansou_then_hdhive", + recommend_handoff=recommend_handoff, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + if mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return {"success": False, "message": f"影巢搜索失败:{search_message}", "data": result} + candidates = result.get("candidates") or [] + if not candidates and mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + )) + + async def api_assistant_action(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + name = self._clean_text(body.get("name") or body.get("action_name")) + if not name: + return {"success": False, "message": "缺少动作名 name"} + compact = self._parse_bool_value(body.get("compact"), False) + + async def finish(awaitable): + result = await awaitable + return self._assistant_single_action_compact_response(name, result) if compact else result + + async def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + route_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + pick_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + + if name == "start_pansou_search": + route_payload.update({ + "mode": "pansou", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_search": + route_payload.update({ + "mode": "smart", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_decision": + route_payload.update({ + "mode": "smart_decision", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + "decision_profile": body.get("decision_profile"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_plan": + route_payload.update({ + "mode": "smart_plan", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_execute": + route_payload.update({ + "mode": "smart_execute", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_hdhive_search": + route_payload.update({ + "mode": "hdhive", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_mp_media_search": + route_payload.update({ + "mode": "mp", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "query_mp_media_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_media_detail( + keyword=self._clean_text(body.get("keyword") or body.get("title")), + session=session_name, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or "auto"), + year=self._clean_text(body.get("year")), + )) + if name == "query_mp_search_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_result_detail( + choice=self._safe_int(body.get("choice") or body.get("index"), 0), + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_best_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_result_detail( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_best_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_download_plan( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + choice = self._safe_int(body.get("choice") or body.get("index"), 0) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + ))) + return await finish(self._assistant_mp_download( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_download_tasks": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_tasks( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "downloading"), + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_mp_download_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_lifecycle_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_ingest_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_downloaders": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_downloaders(session=session_name, cache_key=cache_key)) + if name == "query_mp_sites": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_sites( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "active"), + name=self._clean_text(body.get("site_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 30), + )) + if name == "query_mp_subscribes": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_subscribes( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or "all"), + name=self._clean_text(body.get("subscribe_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 20), + )) + if name == "query_mp_ingest_failures": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_failures( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_ai_failed_samples": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_failed_samples( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_worklist": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_worklist( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_insights": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_insights( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if name == "replay_ai_failed_sample": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(body.get("remove_if_resolved"), True) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + ))) + return await finish(self._assistant_ai_replay_failed_sample( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if name == "query_mp_transfer_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_transfer_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + status=self._clean_text(body.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_recent_activity": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only"), False), + )) + if name == "query_mp_local_diagnose": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_local_diagnose( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_execution_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id")), + )) + if name == "query_smart_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_smart_followup( + request, + session=session_name, + session_id=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "mp_subscribe_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return await finish(self._assistant_mp_subscribe_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if name == "mp_download_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + downloader = self._clean_text(body.get("downloader")) + delete_files = self._parse_bool_value(body.get("delete_files"), False) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + downloader=downloader, + delete_files=delete_files, + ))) + return await finish(self._assistant_mp_download_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + downloader=downloader, + delete_files=delete_files, + )) + if name in {"start_mp_subscribe", "start_mp_subscribe_search"}: + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if not keyword: + state = self._load_session(cache_key) or {} + keyword = self._clean_text(state.get("keyword")) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session_name, + cache_key=cache_key, + immediate_search=name == "start_mp_subscribe_search", + ))) + return await finish(self._assistant_mp_subscribe( + keyword=keyword, + session=session_name, + immediate_search=name == "start_mp_subscribe_search", + )) + if name == "start_mp_recommendations": + recommend_session, recommend_cache_key = self._normalize_assistant_session_ref( + session=self._clean_text(body.get("session")) or "default", + session_id=body.get("session_id"), + ) + source_name, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or "tmdb_trending" + ) + return await finish(self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type) or "all", + limit=self._safe_int(body.get("limit"), 20), + session=recommend_session, + cache_key=recommend_cache_key, + )) + if name == "pick_recommend_search": + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "mode": body.get("mode") or body.get("search_mode") or "mp", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"preferences_get", "preferences_save", "preferences_reset"}: + method = "GET" if name == "preferences_get" else "DELETE" if name == "preferences_reset" else "POST" + payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "user_key": body.get("user_key"), + "preferences": body.get("preferences") or {}, + "compact": body.get("compact", True), + "apikey": self._extract_apikey(request, body), + } + return await finish(self.api_assistant_preferences(_JsonRequestShim(request, payload, method=method))) + if name in {"scoring_policy", "query_scoring_policy"}: + session_name = self._clean_text(body.get("session")) or "default" + return await finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if name == "start_115_login": + route_payload.update({ + "action": "p115_qrcode_start", + "client_type": body.get("client_type"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "route_share": + route_payload.update({ + "url": body.get("url") or body.get("share_url"), + "access_code": body.get("access_code"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "unlock_hdhive_resource": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + resource = body.get("resource") if isinstance(body.get("resource"), dict) else {} + slug = self._clean_text(body.get("slug") or resource.get("slug")) + final_path = self._resolve_pan_path_value( + self._clean_text(body.get("path") or body.get("target_path")) + ) or self._hdhive_default_path + if not slug: + return await finish(immediate({ + "success": False, + "message": "影巢解锁动作缺少 slug", + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "error_code": "missing_slug", + }), + })) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_plan", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return await finish(immediate({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "selected_resource": resource, + "result": route_result, + }), + })) + return await finish(immediate({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + })) + if name == "inspect_session_state": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"execute_latest_plan", "execute_plan", "execute_session_latest_plan"}: + return await finish(self.api_assistant_plan_execute(_JsonRequestShim(request, { + "plan_id": body.get("plan_id"), + "session": body.get("session"), + "session_id": body.get("session_id"), + "prefer_unexecuted": body.get("prefer_unexecuted", True), + "stop_on_error": body.get("stop_on_error", True), + "include_raw_results": body.get("include_raw_results", False), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"pick_pansou_result", "pick_hdhive_candidate", "pick_hdhive_resource"}: + pick_payload.update({"choice": body.get("choice") or body.get("index")}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"plan_pansou_result", "plan_hdhive_resource", "plan_pick_result"}: + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "action": "plan", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_detail": + pick_payload.update({"action": "detail"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_next_page": + pick_payload.update({"action": "next_page"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "check_115_login": + route_payload.update({"action": "p115_qrcode_check"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "show_115_status": + route_payload.update({"action": "p115_status"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "resume_pending_115": + route_payload.update({"action": "p115_resume"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "cancel_pending_115": + route_payload.update({"action": "p115_cancel"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "clear_current_session": + return await finish(self.api_assistant_session_clear(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "inspect_session": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_session_by_id": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_stale_sessions": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "stale_only": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_executed_plans": + return await finish(self.api_assistant_plans_clear(_JsonRequestShim(request, { + "executed": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + + return {"success": False, "message": f"不支持的动作模板:{name}"} + + @staticmethod + def _assistant_result_message_head(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text.splitlines()[0][:200] + + def _assistant_action_result_summary( + self, + *, + index: int, + name: str, + result: Dict[str, Any], + ) -> Dict[str, Any]: + data = dict((result or {}).get("data") or {}) + session_state = dict(data.get("session_state") or {}) + if not session_state and ( + "has_session" in data + or "kind" in data + or "stage" in data + or "suggested_actions" in data + ): + session_state = dict(data) + summary = { + "index": index, + "name": self._clean_text(name), + "success": bool((result or {}).get("success")), + "action": self._clean_text(data.get("action")) or self._clean_text(name), + "ok": bool(data.get("ok")) if "ok" in data else bool((result or {}).get("success")), + "message_head": self._assistant_result_message_head((result or {}).get("message")), + "session": self._clean_text(data.get("session") or session_state.get("session")), + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "has_pending_p115": bool(((session_state.get("pending_p115") or {}).get("has_pending"))), + } + if isinstance(data.get("score_summary"), dict): + summary["score_summary"] = data.get("score_summary") + if isinstance(data.get("diagnosis_summary"), dict): + summary["diagnosis_summary"] = data.get("diagnosis_summary") + return summary + + async def api_assistant_actions(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + actions = body.get("actions") or [] + if not isinstance(actions, list) or not actions: + return {"success": False, "message": "缺少 actions 数组"} + + apikey = self._extract_apikey(request, body) + requested_count = min(len(actions), 20) + stop_on_error = self._parse_bool_value(body.get("stop_on_error"), True) + include_raw_results = self._parse_bool_value(body.get("include_raw_results"), False) + compact = self._parse_bool_value(body.get("compact"), False) + batch_session = self._clean_text(body.get("session")) or "default" + batch_session_id = self._clean_text(body.get("session_id")) + + summaries: List[Dict[str, Any]] = [] + raw_results: List[Dict[str, Any]] = [] + halted = False + halted_at = 0 + + for idx, item in enumerate(actions[:requested_count], 1): + payload = dict(item or {}) if isinstance(item, dict) else {"name": self._clean_text(item)} + if not payload.get("session") and batch_session: + payload["session"] = batch_session + if not payload.get("session_id") and batch_session_id: + payload["session_id"] = batch_session_id + if "execute" not in payload and "execute" in body: + payload["execute"] = body.get("execute") + if apikey and not payload.get("apikey") and not payload.get("api_key"): + payload["apikey"] = apikey + action_name = self._clean_text(payload.get("name") or payload.get("action_name")) + result = await self.api_assistant_action(_JsonRequestShim(request, payload)) + summaries.append(self._assistant_action_result_summary(index=idx, name=action_name, result=result)) + if include_raw_results: + raw_results.append(result) + if not result.get("success") and stop_on_error: + halted = True + halted_at = idx + break + + final_session = batch_session + final_session_id = batch_session_id + if summaries: + final_session = self._clean_text(summaries[-1].get("session")) or final_session + final_session_id = self._clean_text(summaries[-1].get("session_id")) or final_session_id + session_name, _ = self._normalize_assistant_session_ref(session=final_session, session_id=final_session_id) + + success = bool(summaries) and all(item.get("success") for item in summaries) + if halted: + success = False + + message_lines = [ + f"批量动作执行完成:{len(summaries)}/{requested_count} 步", + f"成功:{len([item for item in summaries if item.get('success')])} 步", + ] + if halted: + message_lines.append(f"已在第 {halted_at} 步停止") + else: + message_lines.append("已按顺序执行完毕") + if summaries: + last_head = self._clean_text(summaries[-1].get("message_head")) + if last_head: + message_lines.append(f"最后结果:{last_head}") + + data = { + "action": "execute_actions", + "ok": success, + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + } + if include_raw_results: + data["raw_results"] = raw_results + self._record_assistant_execution( + action=self._clean_text(body.get("workflow")) or "execute_actions", + session=session_name, + success=success, + message="\n".join(message_lines), + summary={ + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + }, + ) + full_data = self._assistant_response_data(session=session_name, data=data) + return { + "success": success, + "message": "\n".join(message_lines), + "data": self._assistant_actions_compact_data(full_data) if compact else full_data, + } + + @staticmethod + def _assistant_workflow_catalog() -> Dict[str, Any]: + return { + "workflows": [ + { + "name": "pansou_search", + "description": "按关键词执行盘搜,只返回候选结果并保留会话", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "smart_resource_search", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只读返回推荐结果", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_decision", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并返回查看详情、生成计划或直接执行的统一建议。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "decision_profile", "path", "compact"], + }, + { + "name": "smart_resource_plan", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并为当前首选生成待确认 plan_id", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_execute", + "description": "按偏好自动搜索当前首选,并立即执行对应写入动作;仅适用于用户已明确要求直接执行的场景。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "pansou_transfer", + "description": "按关键词盘搜并直接选择指定编号转存,choice 默认 1", + "fields": ["session", "keyword", "choice", "path", "compact"], + }, + { + "name": "hdhive_candidates", + "description": "按关键词搜索影巢候选影片,等待下一步选片", + "fields": ["session", "keyword", "media_type", "year", "path", "compact"], + }, + { + "name": "hdhive_unlock", + "description": "按关键词搜索影巢,选择候选影片,再选择资源解锁落盘", + "fields": ["session", "keyword", "candidate_choice", "resource_choice", "media_type", "year", "path", "compact"], + }, + { + "name": "mp_search", + "description": "执行 MP 原生搜索,返回 PT 候选结果和评分摘要", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_media_detail", + "description": "使用 MoviePilot 原生识别确认媒体详情、年份、类型和 TMDB/Douban/IMDB ID", + "fields": ["session", "keyword", "media_type", "year", "compact"], + }, + { + "name": "mp_search_detail", + "description": "执行 MP 原生搜索并按编号查看 PT 详情与评分理由,只读", + "fields": ["session", "keyword", "choice", "compact"], + }, + { + "name": "mp_search_best", + "description": "执行 MP 原生搜索并查看当前评分最高的 PT 候选详情,只读", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_search_download", + "description": "执行 MP 原生搜索并按编号下载,默认先生成 plan_id", + "fields": ["session", "keyword", "choice", "compact", "dry_run"], + }, + { + "name": "mp_download_tasks", + "description": "查询 MP 下载任务状态,可按 status/title/hash/downloader 过滤", + "fields": ["session", "status", "title", "hash", "downloader", "limit", "compact"], + }, + { + "name": "mp_download_history", + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态,只读", + "fields": ["session", "keyword", "hash", "limit", "page", "compact"], + }, + { + "name": "mp_lifecycle_status", + "description": "聚合查询 MP 下载任务、下载历史和整理/入库历史,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_ingest_status", + "description": "按片名或 hash 判断当前处于下载中、已下载、整理中、已入库还是失败阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_downloaders", + "description": "查询 MP 下载器配置摘要,不返回敏感字段", + "fields": ["session", "compact"], + }, + { + "name": "mp_sites", + "description": "查询 MP 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文", + "fields": ["session", "status", "keyword", "limit", "compact"], + }, + { + "name": "mp_download_control", + "description": "暂停、恢复或删除 MP 下载任务,默认先生成 plan_id", + "fields": ["session", "control", "target", "downloader", "delete_files", "compact", "dry_run"], + }, + { + "name": "mp_subscribe", + "description": "按关键词创建 MP 订阅,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribe_and_search", + "description": "创建订阅并立即触发搜索,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribes", + "description": "查询 MP 订阅列表,可按 status/media_type/keyword 过滤", + "fields": ["session", "status", "media_type", "keyword", "limit", "compact"], + }, + { + "name": "mp_subscribe_control", + "description": "触发订阅搜索、暂停、恢复或删除订阅,默认先生成 plan_id", + "fields": ["session", "control", "target", "compact", "dry_run"], + }, + { + "name": "mp_transfer_history", + "description": "查询 MP 最近整理/入库历史,可按标题和成功/失败状态过滤,只读", + "fields": ["session", "keyword", "status", "limit", "page", "compact"], + }, + { + "name": "mp_ingest_failures", + "description": "聚合查看最近整理/入库失败记录,只读", + "fields": ["session", "keyword", "limit", "page", "compact"], + }, + { + "name": "ai_failed_samples", + "description": "读取 AI 识别增强插件保存的失败样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_worklist", + "description": "读取 AI 失败样本工作清单,适合先挑需要复查的样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_insights", + "description": "读取 AI 失败样本洞察,查看主要失败原因与优先处理项,只读", + "fields": ["session", "keyword", "limit", "top", "compact"], + }, + { + "name": "ai_replay_failed_sample", + "description": "对指定 AI 失败样本执行二次识别重放;默认只生成 plan_id,确认后执行", + "fields": ["session", "sample_index", "remove_if_resolved", "compact", "confirmed"], + }, + { + "name": "mp_recent_activity", + "description": "查看最近下载和最近整理/入库活动,只读", + "fields": ["session", "limit", "download_only", "transfer_only", "compact"], + }, + { + "name": "mp_local_diagnose", + "description": "一站式诊断为什么还没入库或当前卡在什么阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "execution_followup", + "description": "按最近已执行计划自动查询对应的下载、订阅或入库后续状态,只读", + "fields": ["session", "session_id", "plan_id", "compact"], + }, + { + "name": "smart_followup", + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动,只读", + "fields": ["session", "session_id", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_recommend", + "description": "读取 MP 原生推荐,例如 TMDB、豆瓣、Bangumi", + "fields": ["session", "source", "media_type", "limit", "compact"], + }, + { + "name": "mp_recommend_search", + "description": "读取 MP 推荐并按编号继续搜索;mode 可选 mp / hdhive / pansou,传 keyword 时直接 MP 搜索", + "fields": ["session", "source", "keyword", "choice", "mode", "media_type", "limit", "compact"], + }, + { + "name": "share_transfer", + "description": "识别 115 或夸克分享链接并直接转存", + "fields": ["session", "url", "access_code", "path", "compact"], + }, + { + "name": "p115_login_start", + "description": "发起 115 扫码登录", + "fields": ["session", "client_type", "compact"], + }, + { + "name": "p115_status", + "description": "查看 115 当前可用状态", + "fields": ["session", "compact"], + }, + ] + } + + def _assistant_workflow_actions(self, name: str, body: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]: + workflow_name = self._clean_text(name).lower() + session = self._clean_text(body.get("session")) or "default" + session_id = self._clean_text(body.get("session_id")) + path = self._clean_text(body.get("path") or body.get("target_path")) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or "auto") + year = self._clean_text(body.get("year")) + source = self._clean_text(body.get("source")) or "tmdb_trending" + limit = self._safe_int(body.get("limit"), 20) + + def base(payload: Dict[str, Any]) -> Dict[str, Any]: + current = dict(payload) + current.setdefault("session", session) + if session_id: + current.setdefault("session_id", session_id) + if path and "path" not in current: + current["path"] = path + return current + + if workflow_name == "pansou_search": + if not keyword: + return [], "pansou_search 缺少 keyword" + return [base({"name": "start_pansou_search", "keyword": keyword})], "" + + if workflow_name == "smart_resource_search": + if not keyword: + return [], "smart_resource_search 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_decision": + if not keyword: + return [], "smart_resource_decision 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_decision", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + "decision_profile": body.get("decision_profile"), + })], "" + + if workflow_name == "smart_resource_plan": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_plan", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_execute": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_execute", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "pansou_transfer": + if not keyword: + return [], "pansou_transfer 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_pansou_search", "keyword": keyword}), + base({"name": "pick_pansou_result", "choice": max(1, choice)}), + ], "" + + if workflow_name == "hdhive_candidates": + if not keyword: + return [], "hdhive_candidates 缺少 keyword" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }) + ], "" + + if workflow_name == "hdhive_unlock": + if not keyword: + return [], "hdhive_unlock 缺少 keyword" + candidate_choice = self._safe_int(body.get("candidate_choice") or body.get("choice"), 0) + resource_choice = self._safe_int(body.get("resource_choice"), 0) + if candidate_choice <= 0 or resource_choice <= 0: + return [], "hdhive_unlock 需要 candidate_choice 和 resource_choice" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }), + base({"name": "pick_hdhive_candidate", "choice": candidate_choice}), + base({"name": "pick_hdhive_resource", "choice": resource_choice}), + ], "" + + if workflow_name == "mp_search": + if not keyword: + return [], "mp_search 缺少 keyword" + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + + if workflow_name == "mp_media_detail": + if not keyword: + return [], "mp_media_detail 缺少 keyword" + return [base({ + "name": "query_mp_media_detail", + "keyword": keyword, + "media_type": media_type, + "year": year, + })], "" + + if workflow_name == "mp_search_detail": + if not keyword: + return [], "mp_search_detail 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_search_result_detail", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_search_best": + if not keyword: + return [], "mp_search_best 缺少 keyword" + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_best_result_detail"}), + ], "" + + if workflow_name == "mp_search_download": + if not keyword: + return [], "mp_search_download 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "pick_mp_download", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_download_tasks": + return [base({ + "name": "query_mp_download_tasks", + "status": self._clean_text(body.get("status")) or "downloading", + "title": self._clean_text(body.get("title") or body.get("keyword")), + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "downloader": self._clean_text(body.get("downloader")), + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "mp_download_history": + return [base({ + "name": "query_mp_download_history", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_lifecycle_status": + return [base({ + "name": "query_mp_lifecycle_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_ingest_status": + return [base({ + "name": "query_mp_ingest_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_downloaders": + return [base({"name": "query_mp_downloaders"})], "" + + if workflow_name == "mp_sites": + return [base({ + "name": "query_mp_sites", + "status": self._clean_text(body.get("status")) or "active", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 30), + })], "" + + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_download_control 需要 control 和 target" + return [base({ + "name": "mp_download_control", + "control": control, + "target": target, + "downloader": self._clean_text(body.get("downloader")), + "delete_files": self._parse_bool_value(body.get("delete_files"), False), + })], "" + + if workflow_name == "mp_subscribe": + if not keyword: + return [], "mp_subscribe 缺少 keyword" + return [base({"name": "start_mp_subscribe", "keyword": keyword})], "" + + if workflow_name == "mp_subscribe_and_search": + if not keyword: + return [], "mp_subscribe_and_search 缺少 keyword" + return [base({"name": "start_mp_subscribe_search", "keyword": keyword})], "" + + if workflow_name == "mp_subscribes": + return [base({ + "name": "query_mp_subscribes", + "status": self._clean_text(body.get("status")) or "all", + "media_type": self._clean_text(body.get("media_type") or body.get("type")) or "all", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + })], "" + + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_subscribe_control 需要 control 和 target" + return [base({ + "name": "mp_subscribe_control", + "control": control, + "target": target, + })], "" + + if workflow_name == "mp_transfer_history": + return [base({ + "name": "query_mp_transfer_history", + "keyword": keyword, + "status": self._clean_text(body.get("status")) or "all", + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_ingest_failures": + return [base({ + "name": "query_mp_ingest_failures", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "ai_failed_samples": + return [base({ + "name": "query_ai_failed_samples", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_worklist": + return [base({ + "name": "query_ai_sample_worklist", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_insights": + return [base({ + "name": "query_ai_sample_insights", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + "top": self._safe_int(body.get("top"), 5), + })], "" + + if workflow_name == "ai_replay_failed_sample": + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index <= 0: + return [], "ai_replay_failed_sample 需要 sample_index" + return [base({ + "name": "replay_ai_failed_sample", + "sample_index": sample_index, + "remove_if_resolved": self._parse_bool_value(body.get("remove_if_resolved"), True), + })], "" + + if workflow_name == "mp_recent_activity": + return [base({ + "name": "query_mp_recent_activity", + "limit": self._safe_int(body.get("limit"), 10), + "download_only": self._parse_bool_value(body.get("download_only"), False), + "transfer_only": self._parse_bool_value(body.get("transfer_only"), False), + })], "" + + if workflow_name == "mp_local_diagnose": + return [base({ + "name": "query_mp_local_diagnose", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "execution_followup": + return [base({ + "name": "query_execution_followup", + "plan_id": self._clean_text(body.get("plan_id")), + })], "" + + if workflow_name == "smart_followup": + return [base({ + "name": "query_smart_followup", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_recommend": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "smart_discovery": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "mp_recommend_search": + if keyword: + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + actions = [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ] + choice = self._safe_int(body.get("choice"), 0) + if choice > 0: + actions.append(base({ + "name": "pick_recommend_search", + "choice": choice, + "mode": self._clean_text(body.get("mode")) or "mp", + })) + return actions, "" + + if workflow_name == "share_transfer": + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if not share_url: + return [], "share_transfer 缺少 url" + return [ + base({ + "name": "route_share", + "url": share_url, + "access_code": self._clean_text(body.get("access_code")), + }) + ], "" + + if workflow_name == "p115_login_start": + return [ + base({ + "name": "start_115_login", + "client_type": self._clean_text(body.get("client_type")), + }) + ], "" + + if workflow_name == "p115_status": + return [base({"name": "show_115_status"})], "" + + return [], f"不支持的工作流:{name}" + + async def api_assistant_workflow(self, request: Request): + if request.method.upper() == "GET": + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + return { + "success": True, + "message": "Agent影视助手 预设工作流目录", + "data": self._assistant_response_data(session="default", data={ + "action": "workflow_catalog", + "ok": True, + **self._assistant_workflow_catalog(), + }), + } + + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + workflow_name = self._clean_text(body.get("name") or body.get("workflow")) + compact = self._parse_bool_value(body.get("compact"), False) + if not workflow_name: + return {"success": False, "message": "缺少工作流名 name"} + actions, build_error = self._assistant_workflow_actions(workflow_name, body) + if build_error: + return {"success": False, "message": build_error} + + session = self._clean_text(body.get("session")) or "default" + write_workflows = { + "pansou_transfer", + "hdhive_unlock", + "share_transfer", + "ai_replay_failed_sample", + "mp_search_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_and_search", + } + dry_run = self._parse_bool_value( + body.get("dry_run"), + self._clean_text(workflow_name).lower() in write_workflows, + ) + if dry_run: + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + result = self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ) + if not result.get("success"): + return result + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_id = self._parse_bool_value(body.get("allow_raw_id"), False) + result = self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + allow_raw_id=allow_raw_id, + ) + if not result.get("success"): + return result + if workflow_name == "ai_replay_failed_sample": + result = self._assistant_ai_replay_sample_plan_response( + sample_index=self._safe_int(body.get("sample_index") or body.get("index"), 0), + session=session, + cache_key=self._clean_text(body.get("session_id")), + remove_if_resolved=self._parse_bool_value(body.get("remove_if_resolved"), True), + ) + if not result.get("success"): + return result + return self._assistant_workflow_plan_response_compact(result) if compact else result + execute_body = { + **{key: value for key, value in body.items() if key not in {"apikey", "dry_run"}}, + "dry_run": False, + } + plan = self._save_workflow_plan( + workflow=workflow_name, + session=session, + session_id=self._clean_text(body.get("session_id")), + actions=actions, + execute_body=execute_body, + ) + full_data = self._assistant_response_data(session=session, data={ + "action": "workflow_plan", + "ok": True, + "plan_id": plan.get("plan_id"), + "workflow": workflow_name, + "dry_run": True, + "workflow_actions": actions, + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan.get("plan_id")}, + "execute_body": execute_body, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + }) + return { + "success": True, + "message": f"工作流 {workflow_name} 计划已生成:{plan.get('plan_id')},共 {len(actions)} 步,未实际执行。", + "data": self._assistant_workflow_plan_compact_data(full_data) if compact else full_data, + } + + result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": self._clean_text(body.get("session_id")), + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + data = dict(result.get("data") or {}) + data["workflow"] = workflow_name + if not compact: + data["workflow_actions"] = actions + return { + "success": bool(result.get("success")), + "message": f"工作流 {workflow_name} 执行完成\n{result.get('message') or ''}".strip(), + "data": data, + } + + async def api_assistant_plan_execute(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + plan_id = self._clean_text(body.get("plan_id")) + session = self._clean_text(body.get("session")) + session_id = self._clean_text(body.get("session_id")) + prefer_unexecuted = self._parse_bool_value(body.get("prefer_unexecuted"), True) + compact = self._parse_bool_value(body.get("compact"), False) + plan = self._find_workflow_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=False if prefer_unexecuted and not plan_id else None, + ) + if not plan and not plan_id and (session or session_id) and prefer_unexecuted: + plan = self._find_workflow_plan( + session=session, + session_id=session_id, + executed=None, + ) + if not plan: + result = { + "success": False, + "message": f"计划不存在或已过期:{plan_id}" if plan_id else "没有匹配到可执行计划,请先生成 dry_run 计划或改传 plan_id。", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_not_found", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + plan_id = self._clean_text(plan.get("plan_id")) + + actions = plan.get("actions") or [] + if not isinstance(actions, list) or not actions: + result = { + "success": False, + "message": f"计划没有可执行动作:{plan_id}", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_has_no_actions", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + workflow_name = self._clean_text(plan.get("workflow")) or "saved_plan" + session = self._clean_text(plan.get("session")) or "default" + session_id = self._clean_text(plan.get("session_id")) + action_result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": session_id, + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": False, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + + executed_at = int(time.time()) + plan.update({ + "executed": True, + "executed_at": executed_at, + "executed_at_text": self._format_unix_time(executed_at), + "last_success": bool(action_result.get("success")), + "last_message": self._assistant_result_message_head(action_result.get("message")), + }) + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + + data = dict(action_result.get("data") or {}) + data.update({ + "action": "execute_plan", + "plan_id": plan_id, + "workflow": workflow_name, + "plan_auto_selected": not bool(self._clean_text(body.get("plan_id"))), + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "plan_executed_at": executed_at, + "plan_executed_at_text": plan.get("executed_at_text"), + }) + followup_state = dict(data.get("session_state") or {}) if isinstance(data.get("session_state"), dict) else {} + if isinstance(plan.get("execute_body"), dict): + followup_state["plan_execute_body"] = dict(plan.get("execute_body") or {}) + followup = self._assistant_plan_execute_followup( + workflow=workflow_name, + session=session, + session_id=session_id, + session_state=followup_state, + ok=bool(action_result.get("success")), + plan_id=plan_id, + ) + data["recommended_action"] = self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")) + data["follow_up_hint"] = self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")) + data["followup_summary"] = followup.get("followup_summary") or {} + data["next_actions"] = self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or [], + ) + data["action_templates"] = self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ) + if not compact: + data["workflow_actions"] = actions + message_lines = [ + f"计划 {plan_id} 执行完成", + self._clean_text(action_result.get("message")), + ] + if data.get("recommended_action"): + message_lines.append(f"推荐动作:{data.get('recommended_action')}") + if data.get("follow_up_hint"): + message_lines.append(f"下一步:{data.get('follow_up_hint')}") + message_lines.extend(self._format_followup_summary_lines(data.get("followup_summary"))) + result = { + "success": bool(action_result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": data, + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + async def api_assistant_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + raw_action_text = self._clean_text(body.get("action") or body.get("pick_action")) + action = self._normalize_pick_action(raw_action_text) + target_path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + compact = self._parse_bool_value(body.get("compact"), False) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + state = self._load_session(cache_key) + if not state: + return {"success": False, "message": "没有可继续的缓存,请先发起搜索或发送分享链接。"} + if index <= 0 and not action: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + + kind = str(state.get("kind") or "").strip() + smart_short_action = self._normalize_smart_search_short_action(raw_action_text, state_kind=kind) + if smart_short_action: + action = smart_short_action + if kind == "assistant_cloud": + pansou_items = state.get("pansou_items") or [] + hdhive_resources = state.get("hdhive_resources") or [] + hdhive_candidate = dict(state.get("hdhive_candidate") or {}) + hdhive_candidates = state.get("hdhive_candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + pansou_count = len(pansou_items) + total_count = pansou_count + len(hdhive_resources) + if action == "next_page": + total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_cloud_text( + keyword=self._clean_text(state.get("keyword")), + pansou_items=pansou_items, + pansou_total=self._safe_int(state.get("pansou_total"), pansou_count), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best" and pansou_items: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + "total": self._safe_int(state.get("pansou_total"), len(pansou_items)), + "page": max(1, self._safe_int(state.get("page"), 1)), + "page_size": page_size, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + if index <= 0: + if action: + return {"success": False, "message": "云盘搜索结果需要编号,例如:选择 1 或 选择 12 详情。"} + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > total_count: + return {"success": False, "message": f"序号超出范围,请输入 1 到 {total_count} 之间的数字。"} + if index <= pansou_count: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + local_index = index - pansou_count + delegate_state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "selected_candidate": hdhive_candidate, + "resources": [ + {**dict(item or {}), "index": idx} + for idx, item in enumerate(hdhive_resources, start=1) + ], + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": local_index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + + if kind == "assistant_update_check": + items = [dict(item or {}) for item in (state.get("items") or []) if isinstance(item, dict)] + if action == "next_page": + return {"success": False, "message": "更新检查结果当前不分页,可以直接回复编号或“选择 编号 详情”。"} + if action in {"best", "best_execute", "best_plan"}: + best = self._best_scored_source_item(items) + if not best: + return {"success": False, "message": "当前更新检查结果没有可评分条目,请直接回复编号。"} + index = self._safe_int(best.get("_index") or best.get("index"), 0) + action = "plan" if action == "best_plan" else "" if action == "best_execute" else "detail" + if index <= 0: + return {"success": False, "message": "更新检查结果需要编号,例如:选择 25 详情。"} + matched_items = [ + item for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) == index + ] + if not matched_items and 1 <= index <= len(items): + matched_items = [items[index - 1]] + if not matched_items: + available = "、".join( + f"#{self._safe_int(item.get('_index') or item.get('index'), 0)}" + for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) > 0 + ) + return { + "success": False, + "message": f"序号不在当前更新检查结果里。可选编号:{available or '暂无'}", + } + selected = dict(matched_items[0]) + source_type = self._clean_text(selected.get("_update_source")).lower() + choice_index = self._safe_int(selected.get("_index") or selected.get("index"), index) + selected["index"] = choice_index + selected["_index"] = choice_index + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if action == "detail": + title = "盘搜资源详情" if source_type == "pansou" else "影巢资源详情" + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title=title), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_resource_detail", + "ok": True, + "choice": choice_index, + "source_type": source_type, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + }) + if source_type == "pansou": + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法继续处理;可先发“选择 编号 详情”查看。"} + access_code = self._clean_text(selected.get("password")) + route_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_pansou_transfer", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": route_path, + }], + execute_body={ + "workflow": "update_check_pansou_transfer", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": route_path, + }, + message="更新检查盘搜资源转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": route_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "target_path": route_path, + "apikey": self._extract_apikey(request, body), + }) + ) + return finish(route_result) + if source_type == "hdhive": + slug = self._clean_text(selected.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法继续处理;可先发“选择 编号 详情”查看。"} + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_hdhive_unlock", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": selected, + }], + execute_body={ + "workflow": "update_check_hdhive_unlock", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": final_path, + }, + message="更新检查影巢资源解锁/转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": final_path, + "selected_resource": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=selected, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_update_check_hdhive", + title=selected.get("title") or selected.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(selected.get('title') or selected.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_hdhive_unlock", + "ok": True, + "selected_resource": selected, + "result": route_result, + }), + }) + return {"success": False, "message": "当前更新检查条目缺少来源信息,请重新执行更新检查。"} + if kind == "assistant_pansou": + items = state.get("items") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total_items = len(items) + if action == "next_page": + total_pages = max(1, (total_items + page_size - 1) // page_size) if total_items else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_pansou_text( + self._clean_text(state.get("keyword")), + items, + self._safe_int(state.get("total"), total_items), + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法执行最佳。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + plan_result = self._save_assistant_pick_plan_response( + workflow="pansou_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }], + execute_body={ + "workflow": "pansou_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择盘搜最佳候选并执行:#{index}", + extra_data={"choice": index, "source_type": "pansou"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + result = self._save_assistant_pick_plan_response( + workflow="pansou_best_plan", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail": + if index <= 0: + return {"success": False, "message": "盘搜详情需要编号,例如:选择 1 详情。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "盘搜计划需要编号,例如:计划选择 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="pansou_transfer_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_transfer_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜转存计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "盘搜结果当前只支持:选择 编号、计划选择 编号、选择 编号 详情、最佳片源、计划最佳、执行最佳。"} + if index <= 0: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "path": final_path, + "trigger": "Agent影视助手 智能入口盘搜选择", + "apikey": self._extract_apikey(request, body), + }) + ) + if not route_result.get("success"): + if self._is_115_url(share_url): + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=access_code, + target_path=final_path, + source="assistant_pansou_pick", + title=selected.get("note") or "", + last_error=str(route_result.get("message") or ""), + ) + return finish({ + "success": False, + "message": ( + f"{str(route_result.get('message') or '转存失败')}\n" + f"{self._format_p115_resume_hint(selected.get('note') or '')}" + ), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_quark_url(share_url) and self._is_quark_share_restricted_message( + str(route_result.get("message") or "") + ): + recovered = await self._assistant_retry_pansou_quark_candidates( + request, + items=items, + selected_index=index, + selected_url=share_url, + session=session, + target_path=final_path, + apikey=self._extract_apikey(request, body), + ) + if recovered: + return finish(recovered) + return finish({ + "success": False, + "message": str(route_result.get('message') or '转存失败'), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_115_url(share_url): + self._clear_pending_p115_share(cache_key) + provider = ((route_result.get("data") or {}).get("provider") or "").lower() + result_payload = (route_result.get("data") or {}).get("result") or {} + directory = (result_payload.get("result") or {}).get("target_path") or (result_payload.get("result") or {}).get("path") or final_path + text_message = "\n".join([ + "盘搜结果已执行转存", + f"资源:{selected.get('note') or '未命名资源'}", + f"类型:{provider or selected.get('channel') or '-'}", + f"目录:{directory or '-'}", + ]) + return finish({ + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={"action": "share_route", "ok": True}), + }) + + if kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + pick_action = self._normalize_pick_action(body.get("action")) + next_mode = self._clean_text( + body.get("mode") + or body.get("search_mode") + or body.get("target") + or "" + ).lower() + if index <= 0: + index = selected_index + if index <= 0 and ( + pick_action in {"detail", "best", "plan", "best_execute"} + or next_mode + ): + index = 1 + if index <= 0: + return {"success": False, "message": "推荐结果需要先指定编号,例如:选择 1 决策,或先发:详情 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + title = self._clean_text(selected.get("title")) + if not title: + return {"success": False, "message": "选中的推荐条目缺少标题,无法继续搜索。"} + if pick_action == "detail": + self._save_session(cache_key, { + **state, + "selected_index": index, + "selected_item": selected, + }) + return finish({ + "success": True, + "message": self._format_mp_recommend_item_detail_text(selected), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendation_detail", + "ok": True, + "choice": index, + "selected_index": index, + "item": selected, + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + }), + }) + mode_aliases = { + "原生": "mp", + "mp搜索": "mp", + "影巢": "hdhive", + "yc": "hdhive", + "盘搜": "pansou", + "ps": "pansou", + "计划": "smart_plan", + "smart_plan": "smart_plan", + "确认": "smart_execute", + "执行": "smart_execute", + "直接执行": "smart_execute", + "smart_execute": "smart_execute", + "决策": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart": "smart_decision", + "smart_decision": "smart_decision", + } + if not next_mode: + if pick_action == "plan": + next_mode = "smart_plan" + elif pick_action == "best_execute": + next_mode = "smart_execute" + elif pick_action in {"best", "detail"}: + next_mode = "smart_decision" + next_mode = mode_aliases.get(next_mode, next_mode) + if not next_mode: + next_mode = "mp" + if next_mode not in {"mp", "hdhive", "pansou", "smart_decision", "smart_plan", "smart_execute"}: + return {"success": False, "message": "推荐选择只支持 mode=smart_decision、mode=smart_plan、mode=smart_execute、mode=mp、mode=hdhive 或 mode=pansou。"} + selected_media_type = self._clean_text(selected.get("type") or state.get("media_type") or "auto").lower() + media_type_aliases = { + "电影": "movie", + "movie": "movie", + "movies": "movie", + "电视剧": "tv", + "剧集": "tv", + "番剧": "tv", + "tv": "tv", + "series": "tv", + "all": "auto", + "全部": "auto", + } + selected_media_type = media_type_aliases.get(selected_media_type, "auto") + next_keyword = title + if next_mode == "smart_decision" and pick_action in {"best", "detail"}: + next_keyword = f"{title} 详情" + routed_body = { + "session": session, + "session_id": cache_key, + "mode": next_mode, + "keyword": next_keyword, + "media_type": selected_media_type, + "path": target_path, + "apikey": self._extract_apikey(request, body), + } + if next_mode in {"smart_decision", "smart_plan", "smart_execute"}: + routed_body["origin"] = "mp_recommend" + else: + routed_body["recommend_handoff"] = self._assistant_recommend_handoff_state( + source=self._clean_text(state.get("source")), + requested_source=self._clean_text(state.get("requested_source") or state.get("source")), + media_type=selected_media_type, + selected_index=index, + selected_item=selected, + ) + return finish(await self.api_assistant_route( + _JsonRequestShim(request, routed_body) + )) + + if kind == "assistant_smart_search": + if action in { + "decision_continue", + "decision_hdhive", + "decision_pansou", + "decision_mp_pt", + "decision_conservative", + "decision_aggressive", + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=action, + )) + if action == "best_execute": + return finish(await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best_plan": + return finish(self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best": + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + active_source = self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + try: + result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + finally: + self._save_session(cache_key, state) + return finish(result) + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + requested_mode = self._clean_text(body.get("mode") or body.get("search_mode")).lower() + mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp_pt", + "mp": "mp_pt", + "pt": "mp_pt", + } + requested_mode = mode_aliases.get(requested_mode, requested_mode) + active_source = requested_mode or self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + if kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action in {"best", "best_execute", "best_plan", "plan"}: + return {"success": False, "message": "MP 候选阶段还没有 PT 资源评分,请先回复编号选定电影/剧集。"} + if action: + return {"success": False, "message": "MP 候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效候选编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1] or {}) + candidate_title = self._clean_text(candidate.get("title")) or self._clean_text(state.get("keyword")) + candidate_year = self._clean_text(candidate.get("year")) + search_keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + pending_action = dict(state.get("pending_action") or {}) if isinstance(state.get("pending_action"), dict) else {} + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if pending_mode == "cloud_transfer_execute": + cloud_provider = self._clean_text(pending_action.get("cloud_provider")).lower() + overrides: Dict[str, Any] = {} + if cloud_provider == "quark": + overrides = {"has_quark": True, "has_115": False, "prefer_cloud_provider": "quark"} + elif cloud_provider == "115": + overrides = {"has_quark": False, "has_115": True, "prefer_cloud_provider": "115"} + result = await self._assistant_cloud_transfer_execute( + request, + keyword=search_keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(candidate.get("media_type") or state.get("media_type") or "auto").lower() or "auto", + year=candidate_year or self._clean_text(state.get("year")), + source_order=["pansou", "hdhive"], + target_path=self._clean_text(state.get("target_path")), + session_preference_overrides=overrides, + origin=cloud_provider or "cloud_transfer", + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(result) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + return finish(result) + + if kind == "assistant_mp": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total = self._safe_int(state.get("total"), 0) + if action == "next_page": + total_pages = max(1, (max(0, total) + page_size - 1) // page_size) if total else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号查看详情或下载。"} + next_page = current_page + 1 + all_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if all_items: + preview = self._slice_mp_preview_items(all_items, page=next_page, page_size=page_size) + else: + preview = self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=next_page, + page_size=page_size, + ) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + updated_state = { + **state, + "items": preview, + "all_items": all_items, + "page": next_page, + "page_size": page_size, + "total": total, + } + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_mp_search_text( + self._clean_text(state.get("keyword")), + f"{self._clean_text(state.get('keyword'))} — MP搜索", + preview, + total=total, + page=next_page, + page_size=page_size, + result_filter=self._clean_text(state.get("result_filter")), + latest_episode=self._safe_int(state.get("latest_episode"), 0), + episode_filter=self._safe_int(state.get("episode_filter"), 0), + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search_next_page", + "ok": True, + "keyword": self._clean_text(state.get("keyword")), + "items": preview, + "page": next_page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }), + }) + if action == "best": + result = await self._assistant_mp_best_result_detail( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + if action == "best_execute": + plan_result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix="已自动选择当前评分最高的 PT 候选并执行计划", + extra_data={"source_type": "mp_pt"}, + )) + if action == "best_plan": + result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail" and index <= 0: + return {"success": False, "message": "MP 搜索结果详情需要编号,例如:选择 1。"} + if action == "plan" and index <= 0: + return {"success": False, "message": "生成 PT 下载计划需要编号,例如:下载1。"} + if action == "plan" or (not action and index > 0): + result = self._assistant_mp_download_plan_response( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download", + message="PT 下载计划已生成", + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + result = await self._assistant_mp_result_detail( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + + if kind == "assistant_hdhive": + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_pick", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + stage = str(state.get("stage") or "").strip() + service = self._ensure_hdhive_service() + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if stage == "candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action == "best": + return {"success": False, "message": "影巢候选影片阶段没有评分,不能用“最佳片源”;请先回复编号选择影片,进入资源列表后再用“最佳片源”。"} + if action == "best_execute": + return {"success": False, "message": "影巢候选影片阶段还不能直接执行最佳;请先进入资源列表,或直接发送:智能执行 片名。"} + if action == "best_plan": + return {"success": False, "message": "影巢候选影片阶段还不能直接生成最佳计划;请先进入资源列表,或直接发送:智能计划 片名。"} + if action == "plan": + return {"success": False, "message": "影巢候选影片阶段不能生成资源计划;请先回复编号选择影片,进入资源列表后再发:计划选择 1。"} + if action: + return {"success": False, "message": "影巢候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效影片编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or state.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": f"影巢资源查询失败:{resource_message}", "data": resource_result} + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=final_path, + ) + self._save_session( + cache_key, + { + **state, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": final_path, + }, + ) + return finish({ + "success": True, + "message": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_search", + "ok": True, + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "total_pages": max(1, (len(preview) + self._assistant_result_page_size - 1) // self._assistant_result_page_size) if preview else 1, + "score_summary": self._score_summary(preview, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(preview), + }), + }) + resources = state.get("resources") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return finish({ + "success": False, + "message": "已经是最后一页了,可以直接回复编号继续选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": False, + "error_code": "already_last_page", + "page": current_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_resource_lines(resources, dict(state.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": True, + "selected_candidate": dict(state.get("selected_candidate") or {}), + "resources": resources, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + "score_summary": self._score_summary(resources, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(resources), + }), + }) + if action == "best": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="影巢当前最佳资源"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法执行最佳。"} + plan_result = self._save_assistant_pick_plan_response( + workflow="hdhive_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择影巢最佳资源并执行:#{index}", + extra_data={"choice": index, "source_type": "hdhive"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法生成计划。"} + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_best_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + )) + if action == "detail": + if index <= 0: + return {"success": False, "message": "影巢资源详情需要编号,例如:选择 1 详情。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(resource, title="影巢资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resource_detail", + "ok": True, + "choice": index, + "item": resource, + "score_summary": self._score_summary([resource], limit=1), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "影巢资源计划需要编号,例如:计划选择 1。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法生成计划。"} + actions = [{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": resource, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_unlock_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "hdhive_unlock_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢解锁/转存计划已生成", + score_items=[resource], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": resource, + }, + )) + if action: + return {"success": False, "message": "影巢资源阶段只支持:直接回编号、计划选择 编号、选择 编号 详情;也支持短命令“详情 3”“计划 3”。"} + if index <= 0: + return {"success": False, "message": "请选择有效资源编号,例如:选择 1"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_unlock", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(resource.get('title') or resource.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + }) + + return {"success": False, "message": f"当前会话阶段不支持继续选择:{kind or 'unknown'}"} + + async def api_assistant_capabilities(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_capabilities_public_data() + return { + "success": True, + "message": self._format_assistant_capabilities_text(), + "data": self._assistant_capabilities_compact_data(data) if compact else self._assistant_response_data( + session="default", + data=data, + ), + } + + async def api_assistant_readiness(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_readiness_public_data() + response_data = { + "action": "readiness", + "ok": bool(data.get("can_start")), + **data, + } + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_readiness_text(), + "data": self._assistant_readiness_compact_data(response_data) if compact else self._assistant_response_data( + session="default", + data=response_data, + ), + } + + async def api_assistant_pulse(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_pulse_public_data() + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_pulse_text(), + "data": data, + } + + async def api_assistant_startup(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_startup_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_startup_text(), + "data": data, + } + + async def api_assistant_maintain(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + requested_execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + execute = requested_execute and request.method.upper() != "GET" + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + if requested_execute and request.method.upper() == "GET": + data["execute_ignored"] = True + data["warning"] = "GET 请求只返回 dry-run 维护建议;如需执行维护请使用 POST execute=true。" + if execute: + executed_actions = data.get("executed_actions") or [] + self._record_assistant_execution( + action="maintain", + session=self._clean_text(body.get("session")) or "default", + session_id=self._clean_text(body.get("session_id")), + success=bool(data.get("ok")), + message=self._format_assistant_maintain_text(data), + summary={ + "executed": bool(data.get("executed")), + "executed_actions": [ + { + "name": self._clean_text(item.get("name")), + "removed": self._safe_int(item.get("removed"), 0), + } + for item in executed_actions + if isinstance(item, dict) + ], + "after": { + "stale_sessions": (data.get("after") or {}).get("stale_sessions"), + "saved_plans_executed": (data.get("after") or {}).get("saved_plans_executed"), + "saved_plans_pending": (data.get("after") or {}).get("saved_plans_pending"), + }, + }, + ) + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_maintain_text(data), + "data": data, + } + + async def api_assistant_toolbox(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_toolbox_public_data() + return { + "success": True, + "message": self._format_assistant_toolbox_text(), + "data": data, + } + + async def api_assistant_request_templates(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + names = ( + body.get("names") + or body.get("name") + or body.get("template") + or request.query_params.get("names") + or request.query_params.get("name") + or request.query_params.get("template") + ) + recipe = ( + body.get("recipe") + or body.get("recommended_recipe") + or request.query_params.get("recipe") + or request.query_params.get("recommended_recipe") + ) + include_templates = self._parse_bool_value( + body.get("include_templates") if "include_templates" in body else request.query_params.get("include_templates"), + True, + ) + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return { + "success": True, + "message": self._format_assistant_request_templates_text(data), + "data": data, + } + + async def api_assistant_selfcheck(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_selfcheck_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_selfcheck_text(), + "data": data, + } + + async def api_assistant_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + response_data = self._assistant_history_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "history", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_history_text(session=session, session_id=session_id, limit=limit), + "data": response_data, + } + + async def api_assistant_plans(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + executed = self._parse_optional_bool(request.query_params.get("executed")) + include_actions = bool(self._parse_optional_bool(request.query_params.get("include_actions")) or False) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + response_data = self._assistant_plans_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "plans", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": response_data, + } + + async def api_assistant_plans_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_workflow_plans( + plan_id=body.get("plan_id"), + session=body.get("session"), + session_id=body.get("session_id"), + executed=self._parse_optional_bool(body.get("executed")), + all_plans=self._parse_bool_value(body.get("all_plans"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + if not result.get("ok"): + return { + "success": False, + "message": str(result.get("message") or "计划清理参数无效"), + "data": result, + } + return { + "success": True, + "message": str(result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=body.get("session") or "default", data={ + "action": "plans_clear", + "ok": True, + **result, + }), + } + + async def api_assistant_recover(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session")) + session_id = self._clean_text(body.get("session_id") or request.query_params.get("session_id")) + execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + prefer_unexecuted = self._parse_bool_value( + body.get("prefer_unexecuted") if "prefer_unexecuted" in body else None, + True, + ) + stop_on_error = self._parse_bool_value( + body.get("stop_on_error") if "stop_on_error" in body else None, + True, + ) + include_raw_results = self._parse_bool_value( + body.get("include_raw_results") if "include_raw_results" in body else None, + False, + ) + compact = bool( + self._parse_optional_bool(body.get("compact")) + if "compact" in body + else self._parse_optional_bool(request.query_params.get("compact")) + ) + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 20) + data = self._assistant_recover_public_data( + session=session, + session_id=session_id, + limit=limit, + ) + data.update({ + "action": "recover", + "ok": True, + "execute_requested": execute, + "executed": False, + }) + + if not execute: + return { + "success": True, + "message": self._format_assistant_recover_text(data), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + recovery = dict(data.get("recovery") or {}) + if not recovery.get("can_resume"): + data["ok"] = False + data["execute_error"] = recovery.get("reason") or "当前没有可直接恢复的动作" + return { + "success": False, + "message": str(data["execute_error"]), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + template = dict(recovery.get("action_template") or {}) + action_body = dict(template.get("action_body") or {}) + if not action_body and template.get("name"): + action_body = {"name": template.get("name"), **dict(template.get("body") or {})} + if not self._clean_text(action_body.get("name")): + data["ok"] = False + data["execute_error"] = "恢复模板缺少可执行动作名" + return { + "success": False, + "message": data["execute_error"], + "data": self._assistant_recover_response_data(data, compact=compact), + } + + action_body.setdefault("session", data.get("session") or "default") + if data.get("session_id"): + action_body.setdefault("session_id", data.get("session_id")) + action_body.setdefault("prefer_unexecuted", prefer_unexecuted) + action_body.setdefault("stop_on_error", stop_on_error) + action_body.setdefault("include_raw_results", include_raw_results) + action_body["apikey"] = self._extract_apikey(request, body) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + result_data = dict(result.get("data") or {}) + data.update({ + "ok": bool(result.get("success")), + "executed": True, + "execute_success": bool(result.get("success")), + "execute_action": action_body.get("name"), + "execute_message": result.get("message") or "", + "execute_result": result if include_raw_results else { + "success": bool(result.get("success")), + "message": result.get("message") or "", + "data": { + "action": result_data.get("action"), + "ok": result_data.get("ok"), + "session": result_data.get("session"), + "session_id": result_data.get("session_id"), + "plan_id": result_data.get("plan_id"), + "workflow": result_data.get("workflow"), + }, + }, + }) + return { + "success": bool(result.get("success")), + "message": f"恢复动作 {action_body.get('name')} 执行完成\n{result.get('message') or ''}".strip(), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + async def api_assistant_session_state(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, _ = self._normalize_assistant_session_ref( + session=( + (body or {}).get("session") + or request.query_params.get("session") + or request.query_params.get("chat_id") + or request.query_params.get("user_id") + or "default" + ), + session_id=(body or {}).get("session_id") or request.query_params.get("session_id"), + ) + compact = bool( + self._parse_optional_bool((body or {}).get("compact")) + if "compact" in (body or {}) + else self._parse_optional_bool(request.query_params.get("compact")) + ) + summary = self._format_assistant_session_summary(session=session) + session_state = self._assistant_session_public_data(session=session) + if compact: + return { + "success": True, + "message": summary, + "data": self._assistant_session_compact_data(session_state), + } + data = self._assistant_response_data(session=session, data={ + "action": "session_state", + "ok": True, + "session_snapshot": session_state, + **session_state, + }) + return {"success": True, "message": summary, "data": data} + + async def api_assistant_session_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + existing = self._load_session(cache_key) + if not existing: + return { + "success": True, + "message": "当前没有需要清理的会话。", + "data": self._assistant_response_data(session=session, data={"cleared": False}), + } + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return { + "success": True, + "message": f"已清理会话:{session}", + "data": self._assistant_response_data(session=session, data={"cleared": True}), + } + + async def api_assistant_sessions(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + kind = self._clean_text(request.query_params.get("kind")) + has_pending_p115_raw = request.query_params.get("has_pending_p115") + has_pending_p115: Optional[bool] = None + if has_pending_p115_raw is not None: + has_pending_p115 = str(has_pending_p115_raw).strip().lower() in {"1", "true", "yes", "y"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact"))) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + if compact: + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_sessions_compact_data(data), + } + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_response_data(session="default", data={ + "action": "sessions", + "ok": True, + **data, + }), + } + + async def api_assistant_sessions_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_assistant_sessions( + session=body.get("session"), + session_id=body.get("session_id"), + kind=body.get("kind"), + has_pending_p115=body.get("has_pending_p115"), + stale_only=self._parse_bool_value(body.get("stale_only"), False), + all_sessions=self._parse_bool_value(body.get("all_sessions"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + cleared_count = self._safe_int(result.get("cleared_count"), 0) + if cleared_count <= 0: + return { + "success": True, + "message": "没有匹配到需要清理的 assistant 会话。", + "data": result, + } + return { + "success": True, + "message": f"已清理 {cleared_count} 个 assistant 会话。", + "data": result, + } + + async def api_session_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": result.get("candidates") or [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(result.get("candidates") or [], page=1, page_size=self._hdhive_candidate_page_size), + "session_id": session_id, + "stage": "candidate", + "keyword": keyword, + "candidates": result.get("candidates") or [], + "candidate_count": len(result.get("candidates") or []), + "meta": result.get("meta") or {}, + }, + } + + async def api_session_hdhive_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + session_id = self._clean_text(body.get("session_id")) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + action = self._normalize_pick_action(body.get("action") or body.get("pick_action")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + if not session_id or (index <= 0 and not action): + return {"success": False, "message": "session_id 和选择编号必填;详情/翻页动作可传 action"} + + session = self._load_session(session_id) + if not session: + return {"success": False, "message": "会话不存在或已过期"} + + stage = session.get("stage") + service = self._ensure_hdhive_service() + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(session_id, {**session, "candidates": enriched, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": current_page, + "candidates": enriched, + }, + } + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(candidates): + return {"success": False, "message": "候选编号超出范围"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": resource_message, "data": resource_result} + preview = self._group_resource_preview(resource_result.get("data") or [], per_group=None) + self._save_session( + session_id, + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": target_path or session.get("target_path") or "", + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "session_id": session_id, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "meta": { + "total": len(preview), + "count_115": len([x for x in preview if str(x.get("pan_type") or "").lower() == "115"]), + "count_quark": len([x for x in preview if str(x.get("pan_type") or "").lower() == "quark"]), + }, + }, + } + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "page_size": page_size, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "session_id": session_id, + "stage": "resource", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(resources): + return {"success": False, "message": "资源编号超出范围"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=target_path or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return { + "success": True, + "message": route_message, + "data": { + "text": self._format_route_result(route_result), + "session_id": session_id, + "selected_resource": resource, + "result": route_result, + }, + } + + return {"success": False, "message": f"当前会话阶段不支持继续选择: {stage}"} diff --git a/plugins.v2/agentresourceofficer/agenttool.py b/plugins.v2/agentresourceofficer/agenttool.py new file mode 100644 index 0000000..4af8355 --- /dev/null +++ b/plugins.v2/agentresourceofficer/agenttool.py @@ -0,0 +1,870 @@ +from typing import Optional, Type + +from pydantic import BaseModel + +from app.agent.tools.base import MoviePilotTool +from app.core.plugin import PluginManager + +from .schemas import ( + AssistantCapabilitiesToolInput, + AssistantExecuteActionToolInput, + AssistantExecuteActionsToolInput, + AssistantExecutePlanToolInput, + AssistantHistoryToolInput, + AssistantHelpToolInput, + AssistantMaintainToolInput, + AssistantPickToolInput, + AssistantPreferencesToolInput, + AssistantPlansClearToolInput, + AssistantPlansToolInput, + AssistantPulseToolInput, + AssistantReadinessToolInput, + AssistantRecoverToolInput, + AssistantRequestTemplatesToolInput, + AssistantRouteToolInput, + AssistantSessionClearToolInput, + AssistantSessionsClearToolInput, + AssistantSessionsToolInput, + AssistantSessionStateToolInput, + AssistantSelfcheckToolInput, + AssistantStartupToolInput, + AssistantToolboxToolInput, + AssistantWorkflowToolInput, + FeishuChannelHealthToolInput, + HDHiveSearchSessionToolInput, + HDHiveSessionPickToolInput, + P115CancelPendingToolInput, + P115PendingToolInput, + P115QRCodeCheckToolInput, + P115QRCodeStartToolInput, + P115ResumePendingToolInput, + P115StatusToolInput, + ShareRouteToolInput, +) + + +def _get_plugin(): + return PluginManager().running_plugins.get("AgentResourceOfficer") + + +class HDHiveSearchSessionTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_search" + description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step." + args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + keyword = kwargs.get("keyword", "") + return f"正在通过 Agent影视助手搜索影巢候选:{keyword}" + + async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_search_session( + keyword=keyword, + media_type=media_type, + year=year, + target_path=path, + ) + + +class HDHiveSessionPickTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_pick" + description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item." + args_schema: Type[BaseModel] = HDHiveSessionPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session_id = kwargs.get("session_id", "") + choice = kwargs.get("choice", "") + return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}" + + async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_pick_session( + session_id=session_id, + index=choice, + target_path=path, + action=action, + ) + + +class ShareRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_route_share" + description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path." + args_schema: Type[BaseModel] = ShareRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 路由分享链接" + + async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_route_share( + share_url=url, + access_code=access_code, + target_path=path, + ) + + +class AssistantRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_entry" + description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links." + args_schema: Type[BaseModel] = AssistantRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or "" + return f"正在通过 Agent影视助手 统一入口处理:{text}" + + async def run( + self, + text: str = None, + session: str = "default", + session_id: str = None, + path: str = None, + mode: str = None, + keyword: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + action: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_route( + text=text, + session=session, + session_id=session_id, + target_path=path, + mode=mode, + keyword=keyword, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + action=action, + compact=compact, + ) + + +class AssistantPickTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_pick" + description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page." + args_schema: Type[BaseModel] = AssistantPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + choice = kwargs.get("choice", 0) + action = kwargs.get("action", "") + tail = f"动作 {action}" if action else f"选择 {choice}" + return f"正在继续 Agent影视助手 统一会话:{session},{tail}" + + async def run( + self, + session: str = "default", + session_id: str = None, + choice: int = 0, + action: str = None, + mode: str = None, + path: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pick( + session=session, + session_id=session_id, + index=choice, + action=action, + mode=mode, + target_path=path, + compact=compact, + ) + + +class AssistantHelpTool(MoviePilotTool): + name: str = "agent_resource_officer_help" + description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance." + args_schema: Type[BaseModel] = AssistantHelpToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 使用帮助" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_help(session=session, session_id=session_id) + + +class AssistantCapabilitiesTool(MoviePilotTool): + name: str = "agent_resource_officer_capabilities" + description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents." + args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 能力说明" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_capabilities(compact=compact) + + +class AssistantReadinessTool(MoviePilotTool): + name: str = "agent_resource_officer_readiness" + description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings." + args_schema: Type[BaseModel] = AssistantReadinessToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 启动就绪状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_readiness(compact=compact) + + +class FeishuChannelHealthTool(MoviePilotTool): + name: str = "agent_resource_officer_feishu_health" + description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured." + args_schema: Type[BaseModel] = FeishuChannelHealthToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 内置飞书入口状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_feishu_health(compact=compact) + + +class AssistantPulseTool(MoviePilotTool): + name: str = "agent_resource_officer_pulse" + description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents." + args_schema: Type[BaseModel] = AssistantPulseToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 轻量启动状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pulse() + + +class AssistantStartupTool(MoviePilotTool): + name: str = "agent_resource_officer_startup" + description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint." + args_schema: Type[BaseModel] = AssistantStartupToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 启动聚合信息" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_startup() + + +class AssistantMaintainTool(MoviePilotTool): + name: str = "agent_resource_officer_maintain" + description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans." + args_schema: Type[BaseModel] = AssistantMaintainToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 维护建议" + + async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_maintain(execute=execute, limit=limit) + + +class AssistantToolboxTool(MoviePilotTool): + name: str = "agent_resource_officer_toolbox" + description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples." + args_schema: Type[BaseModel] = AssistantToolboxToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 轻量工具清单" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_toolbox() + + +class AssistantRequestTemplatesTool(MoviePilotTool): + name: str = "agent_resource_officer_request_templates" + description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies." + args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 请求模板" + + async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_request_templates( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + + +class AssistantSelfcheckTool(MoviePilotTool): + name: str = "agent_resource_officer_selfcheck" + description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health." + args_schema: Type[BaseModel] = AssistantSelfcheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在执行 Agent影视助手 协议自检" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_selfcheck() + + +class AssistantHistoryTool(MoviePilotTool): + name: str = "agent_resource_officer_history" + description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action." + args_schema: Type[BaseModel] = AssistantHistoryToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 最近执行历史" + + async def run( + self, + session: str = None, + session_id: str = None, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_history( + session=session, + session_id=session_id, + compact=compact, + limit=limit, + ) + + +class AssistantExecuteActionTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_action" + description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step." + args_schema: Type[BaseModel] = AssistantExecuteActionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + choice: int = None, + path: str = None, + keyword: str = None, + media_type: str = None, + year: str = None, + url: str = None, + access_code: str = None, + client_type: str = None, + source: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = None, + prefer_unexecuted: bool = True, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_action( + name=name, + session=session, + session_id=session_id, + choice=choice, + target_path=path, + keyword=keyword, + media_type=media_type, + year=year, + share_url=url, + access_code=access_code, + client_type=client_type, + source=source, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + plan_id=plan_id, + prefer_unexecuted=prefer_unexecuted, + compact=compact, + ) + + +class AssistantExecuteActionsTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_actions" + description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly." + args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + actions = kwargs.get("actions") or [] + return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步" + + async def run( + self, + actions: list, + session: str = "default", + session_id: str = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_actions( + actions=actions, + session=session, + session_id=session_id, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantWorkflowTool(MoviePilotTool): + name: str = "agent_resource_officer_run_workflow" + description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs." + args_schema: Type[BaseModel] = AssistantWorkflowToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + keyword: str = None, + choice: int = None, + candidate_choice: int = None, + resource_choice: int = None, + path: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + source: str = None, + limit: int = 20, + dry_run: bool = False, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_workflow( + name=name, + session=session, + session_id=session_id, + keyword=keyword, + choice=choice, + candidate_choice=candidate_choice, + resource_choice=resource_choice, + target_path=path, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + source=source, + limit=limit, + dry_run=dry_run, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPreferencesTool(MoviePilotTool): + name: str = "agent_resource_officer_preferences" + description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions." + args_schema: Type[BaseModel] = AssistantPreferencesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + if kwargs.get("reset"): + return "正在重置 Agent影视助手 智能体偏好画像" + if kwargs.get("preferences"): + return "正在保存 Agent影视助手 智能体偏好画像" + return "正在读取 Agent影视助手 智能体偏好画像" + + async def run( + self, + session: str = "default", + session_id: str = None, + user_key: str = None, + preferences: dict = None, + reset: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_preferences( + session=session, + session_id=session_id, + user_key=user_key, + preferences=preferences, + reset=reset, + compact=compact, + ) + + +class AssistantExecutePlanTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_plan" + description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id." + args_schema: Type[BaseModel] = AssistantExecutePlanToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPlansTool(MoviePilotTool): + name: str = "agent_resource_officer_plans" + description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id." + args_schema: Type[BaseModel] = AssistantPlansToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 已保存计划" + + async def run( + self, + session: str = None, + session_id: str = None, + executed: bool = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + compact=compact, + limit=limit, + ) + + +class AssistantPlansClearTool(MoviePilotTool): + name: str = "agent_resource_officer_plans_clear" + description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans." + args_schema: Type[BaseModel] = AssistantPlansClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 已保存计划" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + executed: bool = None, + all_plans: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans_clear( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + + +class AssistantRecoverTool(MoviePilotTool): + name: str = "agent_resource_officer_recover" + description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint." + args_schema: Type[BaseModel] = AssistantRecoverToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + target = kwargs.get("session_id") or kwargs.get("session") or "全局" + action = "并直接恢复" if kwargs.get("execute") else "恢复建议" + return f"正在查看 Agent影视助手 {target} 的{action}" + + async def run( + self, + session: str = None, + session_id: str = None, + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_recover( + session=session, + session_id=session_id, + execute=execute, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + limit=limit, + ) + + +class AssistantSessionStateTool(MoviePilotTool): + name: str = "agent_resource_officer_session_state" + description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task." + args_schema: Type[BaseModel] = AssistantSessionStateToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在查看 Agent影视助手 会话状态:{session}" + + async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact) + + +class AssistantSessionClearTool(MoviePilotTool): + name: str = "agent_resource_officer_session_clear" + description: str = "Clear the current Agent影视助手 assistant session cache." + args_schema: Type[BaseModel] = AssistantSessionClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在清理 Agent影视助手 会话:{session}" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_clear(session=session, session_id=session_id) + + +class AssistantSessionsTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions" + description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow." + args_schema: Type[BaseModel] = AssistantSessionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 活跃会话列表" + + async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions( + kind=kind, + has_pending_p115=has_pending_p115, + compact=compact, + limit=limit, + ) + + +class AssistantSessionsClearTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions_clear" + description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset." + args_schema: Type[BaseModel] = AssistantSessionsClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 活跃会话" + + async def run( + self, + session: str = None, + session_id: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions_clear( + session=session, + session_id=session_id, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + ) + + +class P115QRCodeStartTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_start" + description: str = "Generate a 115 login QR code using the p115client-compatible client session flow." + args_schema: Type[BaseModel] = P115QRCodeStartToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + client_type = kwargs.get("client_type", "alipaymini") + return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}" + + async def run(self, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_start(client_type=client_type) + + +class P115QRCodeCheckTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_check" + description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds." + args_schema: Type[BaseModel] = P115QRCodeCheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 检查 115 扫码状态" + + async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_check( + uid=uid, + time_value=time, + sign=sign, + client_type=client_type, + ) + + +class P115StatusTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_status" + description: str = "Show the current 115 transfer readiness, default target path, and current session source." + args_schema: Type[BaseModel] = P115StatusToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看 115 当前状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_status() + + +class P115PendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_pending" + description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error." + args_schema: Type[BaseModel] = P115PendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看待继续的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_pending(session=session) + + +class P115ResumePendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_resume_pending" + description: str = "Retry the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115ResumePendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 继续待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_resume(session=session) + + +class P115CancelPendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_cancel_pending" + description: str = "Cancel and clear the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115CancelPendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 取消待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_cancel(session=session) diff --git a/plugins.v2/agentresourceofficer/feishu_channel.py b/plugins.v2/agentresourceofficer/feishu_channel.py new file mode 100644 index 0000000..44a1c32 --- /dev/null +++ b/plugins.v2/agentresourceofficer/feishu_channel.py @@ -0,0 +1,1885 @@ +import asyncio +import copy +import fcntl +import importlib +import json +import re +import sqlite3 +import threading +import time +import traceback +from base64 import b64decode +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import jieba +except Exception: + jieba = None + +try: + import lark_oapi as lark +except Exception: + lark = None + +_LARK_IMPORT_LOCK = threading.Lock() + +try: + 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.core.event import eventmanager + from app.core.metainfo import MetaInfo + from app.db.downloadhistory_oper import DownloadHistoryOper + from app.db.models.downloadhistory import DownloadHistory + from app.db.models.transferhistory import TransferHistory + from app.db.site_oper import SiteOper + from app.db.subscribe_oper import SubscribeOper + from app.db.systemconfig_oper import SystemConfigOper + from app.helper.subscribe import SubscribeHelper + from app.core.plugin import PluginManager + from app.log import logger + from app.scheduler import Scheduler + from app.schemas.types import EventType, SystemConfigKey, TorrentStatus, media_type_to_agent + from app.utils.http import RequestUtils + from app.utils.string import StringUtils +except Exception: + DownloadChain = None + DownloadHistoryOper = None + DownloadHistory = None + TransferHistory = None + MediaChain = None + SearchChain = None + SiteOper = None + SubscribeChain = None + SubscribeHelper = None + SubscribeOper = None + SystemConfigOper = None + eventmanager = None + MetaInfo = None + PluginManager = None + Scheduler = None + EventType = None + SystemConfigKey = None + TorrentStatus = None + media_type_to_agent = None + RequestUtils = None + StringUtils = None + + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() + + +_EVENT_CACHE_FILE = Path(__file__).resolve().parent / ".feishu_event_cache.json" + + +def ensure_lark_sdk(auto_install: bool = False) -> tuple[bool, str]: + global lark + + if lark is not None: + return True, "" + + with _LARK_IMPORT_LOCK: + if lark is not None: + return True, "" + + try: + import lark_oapi as runtime_lark + + lark = runtime_lark + return True, "" + except Exception as exc: + first_error = str(exc) + + return False, f"缺少依赖 lark-oapi:{first_error}。请通过插件 requirements.txt 安装依赖后重启 MoviePilot。" + + +class _FeishuLongConnectionRuntime: + def __init__(self) -> None: + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._fingerprint = "" + self._channel: Optional["FeishuChannel"] = None + + def start(self, channel: "FeishuChannel") -> None: + ok, message = ensure_lark_sdk(auto_install=False) + if not ok: + logger.error(f"[AgentResourceOfficer][Feishu] {message}") + return + + if not channel.enabled or not channel.app_id or not channel.app_secret: + return + + fingerprint = channel.connection_fingerprint() + with self._lock: + self._channel = channel + if self._thread and self._thread.is_alive(): + if fingerprint != self._fingerprint: + logger.warning("[AgentResourceOfficer][Feishu] 长连接已在运行,飞书凭证变更需重启 MoviePilot 后生效") + return + self._fingerprint = fingerprint + self._thread = threading.Thread( + target=self._run, + name="agent-resource-officer-feishu", + daemon=True, + ) + self._thread.start() + + def _run(self) -> None: + channel = self._channel + if channel is None or lark is None: + return + + def _on_message(data) -> None: + current = self._channel + if current is not None: + current.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( + channel.app_id, + channel.app_secret, + log_level=lark.LogLevel.DEBUG if channel.debug else lark.LogLevel.INFO, + event_handler=event_handler, + ) + logger.info("[AgentResourceOfficer][Feishu] 正在启动飞书长连接") + ws_client.start() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 长连接退出:{exc}\n{traceback.format_exc()}") + + def is_running(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + def stop(self) -> None: + with self._lock: + self._channel = None + + +class FeishuChannel: + _LEGACY_DEFAULT_COMMANDS = { + "/p115_manual_transfer", + "/p115_inc_sync", + "/p115_full_sync", + "/p115_strm", + "/quark_save", + "/media_search", + "/media_download", + "/media_subscribe", + "/media_subscribe_search", + } + _LEGACY_DEFAULT_ALIAS_KEYS = { + "刮削", + "搜索", + "MP搜索", + "原生搜索", + "下载", + "订阅", + "订阅搜索", + "生成STRM", + "全量STRM", + "指定路径STRM", + "夸克转存", + "夸克", + "搜索资源", + "下载资源", + "订阅媒体", + "订阅并搜索", + } + + def __init__(self, plugin: Any) -> None: + self.plugin = plugin + self.runtime = _FeishuLongConnectionRuntime() + self.enabled = False + self.allow_all = False + self.reply_enabled = True + self.reply_receive_id_type = "chat_id" + self.app_id = "" + self.app_secret = "" + self.verification_token = "" + self.allowed_chat_ids: List[str] = [] + self.allowed_user_ids: List[str] = [] + self.command_whitelist: List[str] = [] + self.command_aliases = "" + self.command_mode = "resource_officer" + self.debug = False + self._token_cache: Dict[str, Any] = {} + self._token_lock = threading.Lock() + self._event_cache: Dict[str, float] = {} + self._event_lock = threading.Lock() + self._search_cache: Dict[str, Dict[str, Any]] = {} + self._search_cache_lock = threading.Lock() + self._search_cache_limit = 200 + + @classmethod + def default_command_whitelist(cls) -> List[str]: + return [ + "/pansou_search", + "/smart_entry", + "/smart_pick", + "/media_search", + "/version", + ] + + @classmethod + def default_command_aliases(cls) -> str: + return ( + "搜索=/smart_entry\n" + "找=/smart_entry\n" + "云盘搜索=/smart_entry\n" + "MP搜索=/smart_entry\n" + "PT搜索=/smart_entry\n" + "原生搜索=/smart_entry\n" + "盘搜搜索=/pansou_search\n" + "盘搜=/pansou_search\n" + "ps=/pansou_search\n" + "1=/pansou_search\n" + "影巢搜索=/smart_entry\n" + "影巢=/smart_entry\n" + "yc=/smart_entry\n" + "2=/smart_entry\n" + "转存=/smart_entry\n" + "115转存=/smart_entry\n" + "夸克转存=/smart_entry\n" + "夸克=/smart_entry\n" + "下载=/smart_entry\n" + "订阅=/smart_entry\n" + "订阅搜索=/smart_entry\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_entry\n" + "影巢普通签到=/smart_entry\n" + "普通签到=/smart_entry\n" + "签到=/smart_entry\n" + "赌狗签到=/smart_entry\n" + "签到日志=/smart_entry\n" + "影巢签到日志=/smart_entry\n" + "选择=/smart_pick\n" + "详情=/smart_pick\n" + "审查=/smart_pick\n" + "选=/smart_pick\n" + "继续=/smart_pick\n" + "搜索资源=/smart_entry\n" + "下载资源=/smart_entry\n" + "订阅媒体=/smart_entry\n" + "订阅并搜索=/smart_entry\n" + "版本=/version" + ) + + @staticmethod + def clean(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() + + @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()] + + @classmethod + def parse_alias_text(cls, 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 + + @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(): + if key in cls._LEGACY_DEFAULT_ALIAS_KEYS and value in cls._LEGACY_DEFAULT_COMMANDS: + continue + merged[key] = value + return "\n".join(f"{key}={value}" for key, value in merged.items()) + + @classmethod + def merge_command_whitelist(cls, configured: List[str]) -> List[str]: + merged: List[str] = [] + seen = set() + for cmd in configured or []: + if cmd in cls._LEGACY_DEFAULT_COMMANDS: + continue + 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 + + def configure(self, config: Dict[str, Any]) -> None: + self.enabled = bool(config.get("feishu_enabled", False)) + self.allow_all = bool(config.get("feishu_allow_all", False)) + self.reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self.reply_receive_id_type = self.clean(config.get("feishu_reply_receive_id_type") or "chat_id") + self.app_id = self.clean(config.get("feishu_app_id")) + self.app_secret = self.clean(config.get("feishu_app_secret")) + self.verification_token = self.clean(config.get("feishu_verification_token")) + self.allowed_chat_ids = self.split_lines(config.get("feishu_allowed_chat_ids")) + self.allowed_user_ids = self.split_lines(config.get("feishu_allowed_user_ids")) + self.command_whitelist = self.merge_command_whitelist(self.split_commands(config.get("feishu_command_whitelist"))) + self.command_aliases = self.merge_command_aliases(self.clean(config.get("feishu_command_aliases"))) + self.command_mode = self.clean(config.get("feishu_command_mode") or "resource_officer") + self.debug = bool(config.get("debug", False)) + + def start(self) -> None: + if self.enabled: + self.runtime.start(self) + + def stop(self) -> None: + self.runtime.stop() + + def is_running(self) -> bool: + return self.runtime.is_running() + + @staticmethod + def is_legacy_bridge_running() -> bool: + if PluginManager is None: + return False + try: + running_plugins = PluginManager().running_plugins or {} + plugin = ( + running_plugins.get("FeishuCommandBridgeLong") + or running_plugins.get("feishucommandbridgelong") + ) + if not plugin: + return False + config_db = Path("/config/user.db") + if config_db.exists(): + try: + with sqlite3.connect(str(config_db)) as conn: + row = conn.execute( + "select value from systemconfig where key=?", + ("plugin.FeishuCommandBridgeLong",), + ).fetchone() + if row and row[0]: + config = json.loads(row[0]) + if not bool(config.get("enabled")): + return False + except Exception: + pass + # MoviePilot may keep disabled plugins in running_plugins after loading. + # Treat the legacy bridge as a conflict only when it is actually enabled. + if hasattr(plugin, "health"): + try: + health = plugin.health() + if isinstance(health, dict): + return bool(health.get("enabled") and health.get("running")) + except Exception: + pass + if hasattr(plugin, "_enabled"): + return bool(getattr(plugin, "_enabled", False)) + if hasattr(plugin, "get_state"): + try: + return bool(plugin.get_state()) + except Exception: + return False + return False + except Exception: + return False + + def connection_fingerprint(self) -> str: + return "|".join([self.app_id, self.app_secret, self.verification_token]) + + def health(self) -> Dict[str, Any]: + sdk_available, sdk_message = ensure_lark_sdk(auto_install=False) + legacy_bridge_running = self.is_legacy_bridge_running() + app_id_configured = bool(self.app_id) + app_secret_configured = bool(self.app_secret) + verification_token_configured = bool(self.verification_token) + missing_requirements = [] + if not sdk_available: + missing_requirements.append("lark-oapi") + if not app_id_configured: + missing_requirements.append("feishu_app_id") + if not app_secret_configured: + missing_requirements.append("feishu_app_secret") + conflict_warning = bool(self.enabled and legacy_bridge_running) + ready_to_start = bool(self.enabled and sdk_available and app_id_configured and app_secret_configured and not conflict_warning) + safe_to_enable = bool((not legacy_bridge_running) and sdk_available and app_id_configured and app_secret_configured) + if conflict_warning: + recommended_action = "disable_legacy_bridge_or_use_different_app" + migration_hint = "内置飞书入口和旧飞书桥接同时运行,建议关闭旧桥接或使用不同飞书 App。" + elif not self.enabled and legacy_bridge_running: + recommended_action = "keep_legacy_or_disable_it_before_migration" + migration_hint = "内置飞书入口关闭,旧飞书桥接运行中;迁移前先关闭旧桥接。" + elif not self.enabled: + recommended_action = "configure_and_enable_feishu_channel" + migration_hint = "内置飞书入口关闭;配置飞书凭证后可开启。" + elif missing_requirements: + recommended_action = "complete_feishu_requirements" + migration_hint = "内置飞书入口已启用,但依赖或飞书凭证不完整。" + elif not self.is_running(): + recommended_action = "restart_moviepilot_or_resave_config" + migration_hint = "内置飞书入口已启用但长连接未运行,建议保存配置或重启 MoviePilot。" + else: + recommended_action = "none" + migration_hint = "内置飞书入口运行正常。" + return { + "enabled": self.enabled, + "running": self.is_running(), + "sdk_available": sdk_available, + "app_id_configured": app_id_configured, + "app_secret_configured": app_secret_configured, + "verification_token_configured": verification_token_configured, + "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_mode": self.command_mode, + "command_whitelist": self.command_whitelist, + "alias_count": len(self.parse_alias_text(self.command_aliases)), + "legacy_bridge_running": legacy_bridge_running, + "conflict_warning": conflict_warning, + "ready_to_start": ready_to_start, + "safe_to_enable": safe_to_enable, + "missing_requirements": missing_requirements, + "sdk_message": sdk_message, + "recommended_action": recommended_action, + "migration_hint": migration_hint, + } + + def handle_long_connection_event(self, data: Any) -> None: + if not self.enabled: + return + event = getattr(data, "event", None) + header = getattr(data, "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 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 self.debug: + logger.info(f"[AgentResourceOfficer][Feishu] event_id={event_id} chat_id={chat_id}") + + if not self._is_allowed(chat_id=chat_id, user_open_id=sender_open_id): + self.reply_text(chat_id, sender_open_id, "该会话未在白名单中,命令已拒绝。") + return + if self._is_help_request(raw_text): + self.reply_text(chat_id, sender_open_id, self._build_help_text()) + return + if self._is_menu_request(raw_text): + self.reply_text(chat_id, sender_open_id, 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_text(chat_id, sender_open_id, f"命令 {cmd} 不在白名单中。\n\n{self._build_help_text()}") + return + if not self._handle_builtin_command(command_text, chat_id, sender_open_id): + self._submit_moviepilot_command(command_text, chat_id, sender_open_id) + + def _handle_builtin_command(self, command_text: str, chat_id: str, open_id: str) -> bool: + parts = command_text.split(maxsplit=1) + cmd = parts[0].strip() + arg = parts[1].strip() if len(parts) > 1 else "" + cache_key = self._cache_key(chat_id, open_id) + + if cmd == "/version": + self.reply_text(chat_id, open_id, f"Agent影视助手 {getattr(self.plugin, 'plugin_version', '')}\n飞书入口:{'运行中' if self.is_running() else '未运行'}") + return True + + if cmd == "/media_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:MP搜索 片名") + return True + self.reply_text(chat_id, open_id, f"正在使用 MP 原生搜索:{arg}") + self._run_thread("feishu-media-search", self._run_media_search, arg, chat_id, open_id) + return True + + if cmd == "/media_download": + if not arg or not arg.isdigit(): + self.reply_text(chat_id, open_id, "用法:下载资源 序号\n示例:下载资源 1") + return True + self.reply_text(chat_id, open_id, f"正在生成第 {arg} 条资源的下载计划,请稍候。") + self._run_thread("feishu-media-download", self._run_media_download, int(arg), chat_id, open_id) + return True + + if cmd in {"/media_subscribe", "/media_subscribe_search"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:订阅媒体 片名\n示例:订阅媒体 流浪地球2") + return True + immediate = cmd == "/media_subscribe_search" + self.reply_text(chat_id, open_id, f"正在{'订阅并搜索' if immediate else '订阅'}:{arg}") + self._run_thread("feishu-media-subscribe", self._run_media_subscribe, arg, immediate, chat_id, open_id) + return True + + if cmd == "/pansou_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:盘搜搜索 片名\n示例:盘搜搜索 流浪地球2") + return True + self.reply_text(chat_id, open_id, f"正在使用盘搜搜索:{arg}") + self._run_thread("feishu-pansou-search", self._run_assistant_route, f"盘搜搜索 {arg}", cache_key, chat_id, open_id) + return True + + if cmd in {"/smart_entry", "/quark_save"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:处理 片名 或 处理 分享链接") + return True + self.reply_text(chat_id, open_id, f"正在智能处理:{arg}") + self._run_thread("feishu-smart-entry", self._run_assistant_route, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/smart_pick": + if not arg: + self.reply_text(chat_id, open_id, "用法:选择 序号\n示例:选择 1\n也支持:详情、审查、n 下一页") + return True + self.reply_text(chat_id, open_id, f"正在继续执行:{arg}") + self._run_thread("feishu-smart-pick", self._run_assistant_pick, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/p115_manual_transfer": + if not arg: + paths = self._get_p115_manual_transfer_paths() + if not paths: + self.reply_text(chat_id, open_id, "未配置待整理目录。请先在 P115StrmHelper 中配置 pan_transfer_paths,或发送:刮削 /待整理/") + return True + self.reply_text(chat_id, open_id, f"已开始刮削 {len(paths)} 个目录:\n" + "\n".join(f"- {path}" for path in paths)) + self._run_thread("feishu-p115-manual-transfer-batch", self._run_p115_manual_transfer_batch, paths, chat_id, open_id) + return True + self.reply_text(chat_id, open_id, f"已开始刮削:{arg}") + self._run_thread("feishu-p115-manual-transfer", self._run_p115_manual_transfer, arg, chat_id, open_id) + return True + + if cmd in {"/p115_inc_sync", "/p115_full_sync", "/p115_strm"}: + final_command = "/p115_full_sync" if cmd == "/p115_strm" and not arg else command_text + self._submit_p115_command(final_command, chat_id, open_id) + return True + + return False + + @staticmethod + def _run_thread(name: str, target: Any, *args: Any) -> None: + threading.Thread(target=target, args=args, name=name, daemon=True).start() + + def _run_assistant_route(self, text: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route(text=text, session=session) + self._reply_result(chat_id, open_id, result) + + def _run_assistant_pick(self, arg: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_pick(arg=arg, session=session) + self._reply_result(chat_id, open_id, result) + + def _reply_result(self, chat_id: str, open_id: str, result: Dict[str, Any]) -> None: + message = str(result.get("message") or "处理完成").strip() + self.reply_text(chat_id, open_id, message) + qrcode = self._find_nested_value(result.get("data"), "qrcode") + if isinstance(qrcode, str): + self.reply_qrcode_data_url(chat_id, open_id, qrcode) + + @classmethod + def _find_nested_value(cls, payload: Any, key: str) -> Any: + if isinstance(payload, dict): + if key in payload: + return payload.get(key) + for value in payload.values(): + found = cls._find_nested_value(value, key) + if found: + return found + elif isinstance(payload, list): + for value in payload: + found = cls._find_nested_value(value, key) + if found: + return found + return None + + def _run_media_search(self, keyword: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_search(keyword, self._cache_key(chat_id, open_id))) + + def _run_media_download(self, index: int, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route( + text=f"下载资源 {index}", + session=self._cache_key(chat_id, open_id), + ) + self._reply_result(chat_id, open_id, result) + + def _run_media_subscribe(self, keyword: str, immediate: bool, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_subscribe(keyword, immediate)) + + def _execute_media_search(self, keyword: str, cache_key: str) -> str: + if not all([MetaInfo, MediaChain, SearchChain, StringUtils]): + return "MP 原生搜索失败:当前环境缺少 MoviePilot 搜索依赖。" + 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) + preview_limit = 20 + preview_results = results[:preview_limit] + lines = [ + f"已识别:{self._format_media_label(mediainfo, season)}", + f"共找到 {len(results)} 条资源,展示前 {len(preview_results)} 条:", + ] + for idx, context in enumerate(preview_results, 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"[AgentResourceOfficer][Feishu] 搜索资源失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"搜索资源失败:{keyword}\n错误:{exc}" + + def _query_media_detail(self, keyword: str, media_type: str = "", year: str = "") -> Dict[str, Any]: + if not all([MetaInfo, MediaChain]): + return {"success": False, "message": "媒体识别失败:当前环境缺少 MoviePilot 媒体识别依赖。", "item": {}} + title_text = str(keyword or "").strip() + if not title_text: + return {"success": False, "message": "媒体识别失败:缺少片名。", "item": {}} + try: + meta = MetaInfo(title_text) + if year: + try: + meta.year = str(year) + except Exception: + pass + mediainfo = MediaChain().recognize_media(meta=meta) + if not mediainfo: + return {"success": False, "message": f"未识别到媒体信息:{title_text}", "item": {"keyword": title_text}} + season = meta.begin_season if meta.begin_season else getattr(mediainfo, "season", None) + media_type_value = getattr(mediainfo, "type", None) + media_type_name = getattr(media_type_value, "name", "") or str(media_type_value or "") + item = { + "keyword": title_text, + "title": str(getattr(mediainfo, "title", "") or ""), + "original_title": str(getattr(mediainfo, "original_title", "") or ""), + "year": str(getattr(mediainfo, "year", "") or ""), + "type": media_type_name, + "tmdb_id": getattr(mediainfo, "tmdb_id", None), + "douban_id": getattr(mediainfo, "douban_id", None), + "imdb_id": str(getattr(mediainfo, "imdb_id", "") or ""), + "season": season, + "category": str(getattr(mediainfo, "category", "") or ""), + "overview": str(getattr(mediainfo, "overview", "") or "")[:300], + } + lines = [ + f"媒体识别:{title_text}", + f"结果:{item.get('title') or '-'} ({item.get('year') or '-'})", + f"类型:{item.get('type') or '-'} | TMDB:{item.get('tmdb_id') or '-'} | 豆瓣:{item.get('douban_id') or '-'}", + ] + if item.get("original_title") and item.get("original_title") != item.get("title"): + lines.append(f"原标题:{item.get('original_title')}") + if season: + lines.append(f"季:S{int(season):02d}" if isinstance(season, int) else f"季:{season}") + if item.get("overview"): + lines.append(f"简介:{item.get('overview')}") + lines.append("说明:这是 MoviePilot 原生识别结果,后续 MP 搜索、订阅和 PT 评分会以它为准。") + return {"success": True, "message": "\n".join(lines), "item": item} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 媒体识别失败:{title_text} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"媒体识别失败:{exc}", "item": {"keyword": title_text}} + + def _execute_media_download(self, index: int, cache_key: str) -> str: + if DownloadChain is None: + return "下载资源失败:当前环境缺少 MoviePilot 下载依赖。" + cache = self._get_search_cache(cache_key) + if not cache: + return "没有可用的搜索缓存,请先发送:MP搜索 片名" + 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: + save_path = "" + if self.plugin is not None: + save_path = str(getattr(self.plugin, "_mp_download_save_path", "") or "").strip() + download_id = DownloadChain().download_single( + context=context, + username="agentresourceofficer-feishu", + source="AgentResourceOfficer", + save_path=save_path or None, + ) + if not download_id: + return f"下载提交失败:{torrent.title}" + path_line = f"\n保存路径:{save_path}" if save_path else "" + return f"已提交下载:{torrent.title}\n站点:{torrent.site_name or '未知站点'}{path_line}\n任务ID:{download_id}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 下载资源失败:{torrent.title} {exc}\n{traceback.format_exc()}") + return f"下载资源失败:{torrent.title}\n错误:{exc}" + + def _query_download_tasks( + self, + *, + downloader: str = "", + status: str = "downloading", + title: str = "", + hash_value: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "查询下载任务失败:当前环境缺少 MoviePilot 下载依赖。", "items": []} + try: + chain = DownloadChain() + status_name = str(status or "downloading").strip().lower() + downloader_name = str(downloader or "").strip() or None + tasks: List[Any] = [] + if hash_value: + tasks = chain.list_torrents(downloader=downloader_name, hashs=[hash_value]) or [] + elif status_name == "downloading": + tasks = chain.downloading(name=downloader_name) or [] + else: + for torrent_status in [TorrentStatus.DOWNLOADING, TorrentStatus.TRANSFER] if TorrentStatus else []: + tasks.extend(chain.list_torrents(downloader=downloader_name, status=torrent_status) or []) + if status_name == "completed": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() in {"seeding", "completed"}] + elif status_name == "paused": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() == "paused"] + if title: + title_lower = title.lower() + tasks = [ + task for task in tasks + if title_lower in str(getattr(task, "title", "") or getattr(task, "name", "") or "").lower() + ] + items: List[Dict[str, Any]] = [] + for index, task in enumerate(tasks[:max(1, min(30, int(limit or 10)))], 1): + task_hash = str(getattr(task, "hash", "") or "") + history = DownloadHistoryOper().get_by_hash(task_hash) if DownloadHistoryOper and task_hash else None + title_text = str(getattr(task, "title", "") or getattr(task, "name", "") or "").strip() + if history and getattr(history, "title", None): + title_text = title_text or str(history.title) + size_value = getattr(task, "size", None) + size_text = StringUtils.str_filesize(size_value) if StringUtils and size_value else "" + progress = getattr(task, "progress", None) + try: + progress_text = f"{float(progress):.1f}%" if progress is not None else "" + except Exception: + progress_text = str(progress or "") + items.append({ + "index": index, + "hash": task_hash, + "hash_short": task_hash[:8], + "downloader": str(getattr(task, "downloader", "") or ""), + "title": title_text or "未命名任务", + "name": str(getattr(task, "name", "") or ""), + "size": size_text, + "progress": progress_text, + "state": str(getattr(task, "state", "") or ""), + "dlspeed": getattr(task, "dlspeed", None), + "upspeed": getattr(task, "upspeed", None), + "left_time": getattr(task, "left_time", None), + "tags": str(getattr(task, "tags", "") or ""), + "media_title": str(getattr(history, "title", "") or "") if history else "", + }) + status_label = { + "downloading": "下载中", + "completed": "已完成", + "paused": "已暂停", + "all": "全部", + }.get(status_name, status_name) + if not items: + return { + "success": True, + "message": f"未找到{status_label}下载任务。", + "items": [], + "total": len(tasks), + "status": status_name, + } + lines = [f"下载任务:{status_label},共 {len(tasks)} 条,展示前 {len(items)} 条:"] + for item in items: + details = [ + item.get("progress") or "进度未知", + item.get("size") or "大小未知", + item.get("state") or "状态未知", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('hash_short')}", + ] + lines.append(f"{item.get('index')}. {item.get('title')}") + lines.append(" " + " | ".join(details)) + lines.append("写入操作需确认:可发“暂停下载 1”“恢复下载 1”“删除下载 1”。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": len(tasks), + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载任务失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载任务失败:{exc}", "items": []} + + def _control_download_task( + self, + *, + action: str, + hash_value: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "操作下载任务失败:当前环境缺少 MoviePilot 下载依赖。"} + task_hash = str(hash_value or "").strip() + if len(task_hash) != 40 or not all(ch in "0123456789abcdefABCDEF" for ch in task_hash): + return {"success": False, "message": "操作下载任务失败:hash 格式无效,请先查询下载任务后按编号操作。"} + downloader_name = str(downloader or "").strip() or None + action_name = str(action or "").strip().lower() + try: + chain = DownloadChain() + if action_name in {"pause", "stop"}: + ok = chain.set_downloading(task_hash, "stop", name=downloader_name) + label = "暂停" + elif action_name in {"resume", "start"}: + ok = chain.set_downloading(task_hash, "start", name=downloader_name) + label = "恢复" + elif action_name in {"delete", "remove"}: + ok = chain.remove_torrents(hashs=[task_hash], downloader=downloader_name, delete_file=bool(delete_files)) + label = "删除" + else: + return {"success": False, "message": f"操作下载任务失败:不支持的动作 {action}"} + suffix = "(包含文件)" if action_name in {"delete", "remove"} and delete_files else "" + return { + "success": bool(ok), + "message": f"{label}下载任务{'成功' if ok else '失败'}:{task_hash[:8]}{suffix}", + "hash": task_hash, + "downloader": downloader_name or "", + "action": action_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作下载任务失败:{task_hash} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作下载任务失败:{exc}"} + + def _query_downloaders(self) -> Dict[str, Any]: + if SystemConfigOper is None or SystemConfigKey is None: + return {"success": False, "message": "查询下载器失败:当前环境缺少 MoviePilot 配置依赖。", "items": []} + try: + raw_items = SystemConfigOper().get(SystemConfigKey.Downloaders) or [] + items: List[Dict[str, Any]] = [] + for index, item in enumerate(raw_items, 1): + if not isinstance(item, dict): + continue + items.append({ + "index": index, + "name": str(item.get("name") or ""), + "type": str(item.get("type") or ""), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + }) + enabled = [item for item in items if item.get("enabled")] + if not items: + return {"success": True, "message": "未配置下载器。", "items": [], "enabled_count": 0} + lines = [f"下载器配置:共 {len(items)} 个,启用 {len(enabled)} 个"] + for item in items: + status = "启用" if item.get("enabled") else "停用" + default = ",默认" if item.get("default") else "" + lines.append(f"{item.get('index')}. {item.get('name') or '-'} | {item.get('type') or '-'} | {status}{default}") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "enabled_count": len(enabled), + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载器失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载器失败:{exc}", "items": []} + + def _query_sites(self, *, status: str = "active", name: str = "", limit: int = 30) -> Dict[str, Any]: + if SiteOper is None: + return {"success": False, "message": "查询站点失败:当前环境缺少 MoviePilot 站点依赖。", "items": []} + try: + status_name = str(status or "active").strip().lower() + name_filter = str(name or "").strip().lower() + sites = SiteOper().list_order_by_pri() or [] + items: List[Dict[str, Any]] = [] + for site in sites: + is_active = bool(getattr(site, "is_active", False)) + if status_name == "active" and not is_active: + continue + if status_name == "inactive" and is_active: + continue + site_name = str(getattr(site, "name", "") or "") + if name_filter and name_filter not in site_name.lower(): + continue + cookie = str(getattr(site, "cookie", "") or "") + items.append({ + "index": len(items) + 1, + "id": getattr(site, "id", None), + "name": site_name, + "domain": str(getattr(site, "domain", "") or ""), + "url": str(getattr(site, "url", "") or ""), + "pri": getattr(site, "pri", None), + "is_active": is_active, + "has_cookie": bool(cookie), + "downloader": str(getattr(site, "downloader", "") or ""), + "proxy": bool(getattr(site, "proxy", False)), + "timeout": getattr(site, "timeout", None), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 30)))] + label = {"active": "已启用", "inactive": "已停用", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{label}站点。", "items": [], "total": total} + lines = [f"PT 站点:{label},共 {total} 个,展示前 {len(items)} 个:"] + for item in items: + cookie_state = "有Cookie" if item.get("has_cookie") else "无Cookie" + active_state = "启用" if item.get("is_active") else "停用" + lines.append( + f"{item.get('index')}. {item.get('name') or '-'} | {item.get('domain') or '-'} | " + f"{active_state} | {cookie_state} | 优先级:{item.get('pri')} | 下载器:{item.get('downloader') or '默认'}" + ) + lines.append("说明:这里不会返回 Cookie 明文;如站点搜索失败,优先检查是否启用、Cookie 是否存在、站点绑定下载器是否可用。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询站点失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询站点失败:{exc}", "items": []} + + def _query_subscribes( + self, + *, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "查询订阅失败:当前环境缺少 MoviePilot 订阅依赖。", "items": []} + try: + status_name = str(status or "all").strip() + media_type_name = str(media_type or "all").strip().lower() + name_filter = str(name or "").strip().lower() + subscribes = SubscribeOper().list() or [] + items: List[Dict[str, Any]] = [] + for sub in subscribes: + state = str(getattr(sub, "state", "") or "") + if status_name != "all" and state != status_name: + continue + sub_type = str(getattr(sub, "type", "") or "").lower() + if media_type_name != "all" and media_type_name not in {sub_type, "movie" if sub_type == "电影" else sub_type, "tv" if sub_type == "电视剧" else sub_type}: + continue + title = str(getattr(sub, "name", "") or "") + if name_filter and name_filter not in title.lower(): + continue + items.append({ + "index": len(items) + 1, + "id": getattr(sub, "id", None), + "name": title or "未命名订阅", + "year": str(getattr(sub, "year", "") or ""), + "type": str(getattr(sub, "type", "") or ""), + "season": getattr(sub, "season", None), + "state": state, + "total_episode": getattr(sub, "total_episode", None), + "lack_episode": getattr(sub, "lack_episode", None), + "start_episode": getattr(sub, "start_episode", None), + "quality": str(getattr(sub, "quality", "") or ""), + "resolution": str(getattr(sub, "resolution", "") or ""), + "effect": str(getattr(sub, "effect", "") or ""), + "include": str(getattr(sub, "include", "") or ""), + "exclude": str(getattr(sub, "exclude", "") or ""), + "sites": getattr(sub, "sites", None), + "downloader": str(getattr(sub, "downloader", "") or ""), + "save_path": str(getattr(sub, "save_path", "") or ""), + "best_version": getattr(sub, "best_version", None), + "tmdbid": getattr(sub, "tmdbid", None), + "doubanid": str(getattr(sub, "doubanid", "") or ""), + "last_update": str(getattr(sub, "last_update", "") or ""), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 20)))] + status_label = {"R": "启用", "S": "暂停", "P": "待处理", "N": "完成", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{status_label}订阅。", "items": [], "total": total} + lines = [f"MP 订阅:{status_label},共 {total} 条,展示前 {len(items)} 条:"] + for item in items: + season = f" S{int(item.get('season')):02d}" if item.get("season") else "" + lack = item.get("lack_episode") + lack_text = f"缺 {lack} 集" if lack not in (None, "", 0) else "无缺集" + filters = " / ".join(value for value in [item.get("resolution"), item.get("effect"), item.get("quality")] if value) or "默认规则" + lines.append(f"{item.get('index')}. #{item.get('id')} {item.get('name')} ({item.get('year') or '-'}){season}") + lines.append(f" 状态:{item.get('state') or '-'} | {lack_text} | 规则:{filters} | 下载器:{item.get('downloader') or '默认'}") + lines.append("写入操作需确认:可发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”“删除订阅 1”。") + return {"success": True, "message": "\n".join(lines), "items": items, "total": total, "status": status_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询订阅失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询订阅失败:{exc}", "items": []} + + def _control_subscribe(self, *, action: str, subscribe_id: int) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "操作订阅失败:当前环境缺少 MoviePilot 订阅依赖。"} + sid = int(subscribe_id or 0) + if sid <= 0: + return {"success": False, "message": "操作订阅失败:订阅 ID 无效。"} + action_name = str(action or "").strip().lower() + try: + oper = SubscribeOper() + sub = oper.get(sid) + if not sub: + return {"success": False, "message": f"操作订阅失败:订阅 #{sid} 不存在。"} + old_info = sub.to_dict() if hasattr(sub, "to_dict") else {} + if action_name in {"search", "run"}: + if Scheduler is None: + return {"success": False, "message": "搜索订阅失败:当前环境缺少调度器。"} + Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True}) + return {"success": True, "message": f"已触发订阅搜索:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + if action_name in {"pause", "stop"}: + updated = oper.update(sid, {"state": "S"}) + label = "暂停" + elif action_name in {"resume", "start"}: + updated = oper.update(sid, {"state": "R"}) + label = "恢复" + elif action_name in {"delete", "remove"}: + sub_name = str(getattr(sub, "name", "") or "") + sub_year = str(getattr(sub, "year", "") or "") + oper.delete(sid) + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeDeleted, {"subscribe_id": sid, "subscribe_info": old_info}) + if SubscribeHelper: + SubscribeHelper().sub_done_async({"tmdbid": getattr(sub, "tmdbid", None), "doubanid": getattr(sub, "doubanid", None)}) + return {"success": True, "message": f"成功删除订阅:#{sid} {sub_name} ({sub_year})", "subscribe_id": sid, "action": action_name} + else: + return {"success": False, "message": f"操作订阅失败:不支持的动作 {action}"} + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeModified, { + "subscribe_id": sid, + "old_subscribe_info": old_info, + "subscribe_info": updated.to_dict() if updated and hasattr(updated, "to_dict") else {}, + }) + return {"success": True, "message": f"{label}订阅成功:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作订阅失败:{sid} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作订阅失败:{exc}", "subscribe_id": sid} + + @staticmethod + def _path_preview(value: Any, max_parts: int = 4) -> str: + text = str(value or "").strip() + if not text: + return "" + normalized = text.replace("\\", "/") + parts = [part for part in normalized.split("/") if part] + if len(parts) <= max_parts: + return normalized + prefix = "/" if normalized.startswith("/") else "" + return f"{prefix}.../" + "/".join(parts[-max_parts:]) + + @staticmethod + def _transfer_status_bool(status: str) -> Optional[bool]: + name = str(status or "all").strip().lower() + if name in {"success", "succeeded", "ok", "true", "成功", "已成功"}: + return True + if name in {"failed", "fail", "error", "false", "失败", "错误"}: + return False + return None + + def _query_download_history( + self, + *, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if DownloadHistory is None or DownloadHistoryOper is None: + return {"success": False, "message": "查询下载历史失败:当前环境缺少 MoviePilot 下载历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + title_text = str(title or "").strip() + hash_text = str(hash_value or "").strip() + oper = DownloadHistoryOper() + db = getattr(oper, "_db", None) + if db is None: + records = oper.list_by_page(page=1, count=500) or [] + if title_text: + title_lower = title_text.lower() + records = [ + item for item in records + if title_lower in str(getattr(item, "title", "") or "").lower() + or title_lower in str(getattr(item, "torrent_name", "") or "").lower() + or title_lower in str(getattr(item, "path", "") or "").lower() + ] + if hash_text: + records = [ + item for item in records + if str(getattr(item, "download_hash", "") or "").lower().startswith(hash_text.lower()) + ] + total = len(records) + selected_records = records[(page_num - 1) * page_size:(page_num - 1) * page_size + page_size] + else: + query = db.query(DownloadHistory) + if title_text: + like = f"%{title_text}%" + query = query.filter( + DownloadHistory.title.like(like) + | DownloadHistory.torrent_name.like(like) + | DownloadHistory.path.like(like) + ) + if hash_text: + query = query.filter(DownloadHistory.download_hash.like(f"{hash_text}%")) + query = query.order_by(DownloadHistory.date.desc(), DownloadHistory.id.desc()) + total = query.count() + selected_records = query.offset((page_num - 1) * page_size).limit(page_size).all() + + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=(page_num - 1) * page_size + 1): + task_hash = str(getattr(record, "download_hash", "") or "") + transfer_records = TransferHistory.list_by_hash(download_hash=task_hash) if TransferHistory is not None and task_hash else [] + transfer_success = any(bool(getattr(item, "status", False)) for item in transfer_records or []) + transfer_failed = any(not bool(getattr(item, "status", False)) for item in transfer_records or []) + if transfer_success: + transfer_status = "success" + transfer_status_text = "已入库" + elif transfer_failed: + transfer_status = "failed" + transfer_status_text = "整理失败" + else: + transfer_status = "none" + transfer_status_text = "未见整理记录" + transfer_dest = "" + transfer_error = "" + if transfer_records: + first_transfer = transfer_records[0] + transfer_dest = self._path_preview(getattr(first_transfer, "dest", "")) + transfer_error = str(getattr(first_transfer, "errmsg", "") or "")[:300] + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": str(getattr(record, "type", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash": task_hash, + "download_hash_short": task_hash[:8], + "torrent_name": str(getattr(record, "torrent_name", "") or ""), + "torrent_site": str(getattr(record, "torrent_site", "") or ""), + "username": str(getattr(record, "username", "") or ""), + "channel": str(getattr(record, "channel", "") or ""), + "path_preview": self._path_preview(getattr(record, "path", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + "transfer_status": transfer_status, + "transfer_status_text": transfer_status_text, + "transfer_count": len(transfer_records or []), + "transfer_dest_preview": transfer_dest, + } + if transfer_error and transfer_status == "failed": + item["transfer_error"] = transfer_error + items.append(item) + + title_label = f":{title_text or hash_text}" if title_text or hash_text else "" + if not items: + return { + "success": True, + "message": f"未找到下载历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + } + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"下载历史{title_label}:第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + details = [ + item.get("date") or "-", + f"站点:{item.get('torrent_site') or '-'}", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('download_hash_short') or '-'}", + f"整理:{item.get('transfer_status_text')}", + ] + lines.append(" " + " | ".join(details)) + if item.get("path_preview"): + lines.append(f" 保存:{item.get('path_preview')}") + if item.get("transfer_dest_preview"): + lines.append(f" 入库:{item.get('transfer_dest_preview')}") + if item.get("transfer_error"): + lines.append(f" 整理错误:{item.get('transfer_error')}") + lines.append("说明:这是只读查询,用于追踪下载提交后是否进入整理流程。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载历史失败:{exc}", "items": []} + + def _query_transfer_history( + self, + *, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if TransferHistory is None: + return {"success": False, "message": "查询整理历史失败:当前环境缺少 MoviePilot 整理历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + status_bool = self._transfer_status_bool(status) + title_text = str(title or "").strip() + search_text = title_text + if title_text and jieba is not None: + try: + search_text = "%".join(jieba.cut(title_text, HMM=False)) + except Exception: + search_text = title_text + + if search_text: + records = TransferHistory.list_by_title(title=search_text, page=1, count=-1, status=None) or [] + if status_bool is not None: + records = [item for item in records if bool(getattr(item, "status", False)) is status_bool] + else: + records = TransferHistory.list_by_page(page=1, count=-1, status=status_bool) or [] + + total = len(records) + start = (page_num - 1) * page_size + selected_records = records[start:start + page_size] + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=start + 1): + media_type = str(getattr(record, "type", "") or "") + if media_type_to_agent is not None: + try: + media_type = media_type_to_agent(media_type) + except Exception: + pass + status_ok = bool(getattr(record, "status", False)) + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": media_type, + "category": str(getattr(record, "category", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "mode": str(getattr(record, "mode", "") or ""), + "status": "success" if status_ok else "failed", + "status_text": "成功" if status_ok else "失败", + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash_short": str(getattr(record, "download_hash", "") or "")[:8], + "src_preview": self._path_preview(getattr(record, "src", "")), + "dest_preview": self._path_preview(getattr(record, "dest", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + } + errmsg = str(getattr(record, "errmsg", "") or "").strip() + if errmsg and not status_ok: + item["errmsg"] = errmsg[:300] + items.append(item) + + status_name = str(status or "all").strip().lower() + status_label = "成功" if status_bool is True else "失败" if status_bool is False else "全部" + title_label = f":{title_text}" if title_text else "" + if not items: + return { + "success": True, + "message": f"未找到{status_label}整理历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"整理历史{title_label}:{status_label},第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + label_parts = [ + item.get("status_text") or "-", + item.get("type") or "-", + item.get("mode") or "-", + item.get("date") or "-", + ] + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + lines.append(" " + " | ".join(label_parts)) + if item.get("dest_preview"): + lines.append(f" 目标:{item.get('dest_preview')}") + if item.get("errmsg"): + lines.append(f" 错误:{item.get('errmsg')}") + lines.append("说明:这是只读查询,用于判断下载后是否已经整理入库。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询整理历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询整理历史失败:{exc}", "items": []} + + def _execute_media_subscribe(self, keyword: str, immediate_search: bool) -> str: + if not all([MetaInfo, SubscribeChain]): + return "订阅失败:当前环境缺少 MoviePilot 订阅依赖。" + meta = MetaInfo(keyword) + try: + sid, message = SubscribeChain().add( + title=keyword, + year=meta.year, + mtype=meta.type, + season=meta.begin_season, + username="agentresourceofficer-feishu", + exist_ok=True, + message=False, + ) + if not sid: + return f"订阅失败:{keyword}\n原因:{message}" + lines = [f"已创建订阅:{keyword}", f"订阅ID:{sid}", f"结果:{message}"] + if immediate_search and Scheduler is not None: + 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"[AgentResourceOfficer][Feishu] 订阅媒体失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"订阅失败:{keyword}\n错误:{exc}" + + @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 _set_search_cache(self, cache_key: str, keyword: str, mediainfo: Any, results: List[Any]) -> None: + with self._search_cache_lock: + now = time.time() + expired_keys = [ + key + for key, item in self._search_cache.items() + if now - float((item or {}).get("ts") or 0) > 1800 + ] + for key in expired_keys: + self._search_cache.pop(key, None) + while len(self._search_cache) >= self._search_cache_limit: + oldest_key = min( + self._search_cache, + key=lambda key: float((self._search_cache.get(key) or {}).get("ts") or 0), + ) + self._search_cache.pop(oldest_key, None) + self._search_cache[cache_key] = { + "ts": now, + "keyword": keyword, + "mediainfo": mediainfo, + "results": list(results or []), + } + + 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 _run_p115_manual_transfer_batch(self, paths: List[str], chat_id: str, open_id: str) -> None: + summaries = [self._execute_p115_manual_transfer(path) for path in paths] + self.reply_text(chat_id, open_id, "\n\n".join(item for item in summaries if item)) + + def _run_p115_manual_transfer(self, path: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_p115_manual_transfer(path)) + + def _get_p115_manual_transfer_paths(self) -> List[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + raw = str(config.get("pan_transfer_paths") or "").strip() + return [line.strip() for line in raw.splitlines() if line.strip()] + except Exception as exc: + logger.warning(f"[AgentResourceOfficer][Feishu] 获取待整理目录失败:{exc}") + return [] + + 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 未初始化或未启用。" + result = servicer.monitorlife.once_transfer(path) + summary = self._format_p115_manual_transfer_result(result) + return summary or self._build_p115_manual_transfer_summary(log_path, log_offset, path) or f"刮削完成:{path}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 手动刮削失败:{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 "" + failed_items = result.get("failed_items") or [] + lines = [ + f"刮削完成:{path}", + f"总计:{result.get('total', 0)} 个项目(文件 {result.get('files', 0)},文件夹 {result.get('dirs', 0)})", + f"成功:{result.get('success', 0)} 个", + f"失败:{result.get('failed', 0)} 个", + f"跳过:{result.get('skipped', 0)} 个", + ] + if result.get("error"): + lines.append(f"错误:{result.get('error')}") + if failed_items: + lines.append("失败示例:") + lines.extend(f"- {item}" for item in failed_items[:3]) + if len(failed_items) > 3: + lines.append(f"- 还有 {len(failed_items) - 3} 项未展示") + lines.extend(self._p115_strm_followup_lines(path)) + return "\n".join(lines) + + def _p115_strm_followup_lines(self, path: str) -> List[str]: + hint = self._get_p115_strm_hint_path() or path + return [ + "如需增量生成 STRM,请再发送:生成STRM", + "如需按全部媒体库全量生成,请再发送:全量STRM", + f"如需指定路径全量生成,请再发送:指定路径STRM {hint}", + ] + + def _get_p115_strm_hint_path(self) -> Optional[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + paths = str(config.get("full_sync_strm_paths") or "").strip() + first_line = next((line.strip() for line in paths.splitlines() if line.strip()), "") + if not first_line: + return None + parts = first_line.split("#") + return parts[1].strip() if len(parts) >= 2 and parts[1].strip() else None + except Exception: + return None + + @staticmethod + def _safe_log_offset(log_path: Path) -> int: + try: + return log_path.stat().st_size if log_path.exists() else 0 + except Exception: + 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) + 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 = pattern.search(chunk) + if not match: + return None + summary = ( + f"刮削完成:{path}\n" + f"总计:{match.group('total')} 个项目(文件 {match.group('files')},文件夹 {match.group('dirs')})\n" + f"成功:{match.group('success')} 个\n" + f"失败:{match.group('failed')} 个\n" + f"跳过:{match.group('skipped')} 个" + ) + return summary + "\n" + "\n".join(self._p115_strm_followup_lines(path)) + except Exception: + return None + + def _submit_p115_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if PluginManager is not None: + try: + if not PluginManager().running_plugins.get("P115StrmHelper"): + self.reply_text(chat_id, open_id, "P115StrmHelper 未加载或未启用,无法执行 STRM 命令。") + return + except Exception: + pass + self._submit_moviepilot_command(command_text, chat_id, open_id) + + def _submit_moviepilot_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if eventmanager is None or EventType is None: + self.reply_text(chat_id, open_id, "当前环境缺少 MoviePilot 事件总线,无法转发该命令。") + return + eventmanager.send_event( + EventType.CommandExcute, + {"cmd": command_text, "source": None, "user": open_id or chat_id or "feishu"}, + ) + self.reply_text(chat_id, open_id, f"已接收命令:{command_text}\n任务已提交给 MoviePilot。") + + 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.plugin._extract_first_url(text) + if first_url and (self.plugin._is_115_url(first_url) or self.plugin._is_quark_url(first_url)): + return f"/smart_entry {text}".strip() + + alias_map = self.parse_alias_text(self.command_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_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) + + @staticmethod + def _is_duplicate_event_cross_instance(event_id: str, now: float) -> bool: + try: + _EVENT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _EVENT_CACHE_FILE.touch(exist_ok=True) + with _EVENT_CACHE_FILE.open("r+", 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"[AgentResourceOfficer][Feishu] 跨实例事件去重失败:{exc}") + return False + + def _is_allowed(self, chat_id: str, user_open_id: str) -> bool: + return bool( + self.allow_all + or (chat_id and chat_id in self.allowed_chat_ids) + or (user_open_id and user_open_id in self.allowed_user_ids) + ) + + @staticmethod + def _extract_text(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) + return re.sub(r"\s+", " ", text).strip() + + @staticmethod + def _is_help_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"帮助", "/help", "help"} + + @staticmethod + def _is_menu_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"菜单", "/menu", "menu", "面板", "控制面板"} + + def _build_help_text(self) -> str: + aliases = self.parse_alias_text(self.command_aliases) + alias_text = "\n".join(f"{key} -> {value}" for key, value in aliases.items()) or "未配置别名" + return ( + "可用命令:\n" + f"{', '.join(self.command_whitelist)}\n\n" + "别名:\n" + f"{alias_text}\n\n" + "快捷入口:发送“菜单”可查看可复制的快捷命令。" + ) + + @staticmethod + def _build_menu_text() -> str: + return ( + "快捷菜单\n" + "1. 云盘搜索 片名\n" + "2. 盘搜搜索 片名\n" + "3. 影巢搜索 片名\n" + "4. MP搜索 片名 / PT搜索 片名\n" + "5. 转存 片名(默认 115)\n" + "6. 夸克转存 片名\n" + "7. 下载 片名\n" + "8. 更新检查 片名\n" + "9. 选择 序号 / 详情 序号 / n\n" + "10. 115登录 / 115状态 / 115任务\n" + "11. 影巢签到 / 影巢签到日志" + ) + + @staticmethod + def _cache_key(chat_id: str, open_id: str) -> str: + return f"feishu::{chat_id or ''}::{open_id or ''}" + + @staticmethod + def _brief_response_error(data: Any) -> str: + if not isinstance(data, dict): + return "body=" + code = str(data.get("code") or "").strip() + msg = str(data.get("msg") or data.get("message") or "").strip() + parts: List[str] = [] + if code: + parts.append(f"code={code}") + if msg: + parts.append(f"msg={msg}") + return " ".join(parts) if parts else "body=" + + def reply_text(self, chat_id: str, open_id: str, text: str) -> None: + if not self.reply_enabled or not self.app_id or not self.app_secret: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else open_id + if not receive_id: + return + access_token = self._get_tenant_access_token() + if not access_token or RequestUtils is None: + return + url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={self.reply_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), + } + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[AgentResourceOfficer][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"[AgentResourceOfficer][Feishu] 发送文本失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def reply_qrcode_data_url(self, chat_id: str, 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"[AgentResourceOfficer][Feishu] 解码二维码失败:{exc}") + return + image_key = self._upload_image(image_bytes=image_bytes, file_name="p115-qrcode.png") + if image_key: + self._reply_image(chat_id, open_id, image_key) + + def _upload_image(self, image_bytes: bytes, file_name: str) -> Optional[str]: + if not image_bytes or RequestUtils is None: + return None + access_token = self._get_tenant_access_token() + if not access_token: + return None + response = RequestUtils(headers={"Authorization": f"Bearer {access_token}"}).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("[AgentResourceOfficer][Feishu] 上传图片失败:无响应") + 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"[AgentResourceOfficer][Feishu] 上传图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + return None + return str(((data.get("data") or {}).get("image_key")) or "").strip() or None + + def _reply_image(self, chat_id: str, open_id: str, image_key: str) -> None: + if not image_key or RequestUtils is None: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else 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={self.reply_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("[AgentResourceOfficer][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"[AgentResourceOfficer][Feishu] 发送图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def _get_tenant_access_token(self) -> Optional[str]: + if RequestUtils is None: + return None + 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("[AgentResourceOfficer][Feishu] 获取 tenant_access_token 失败:无响应") + return None + try: + data = response.json() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] token 响应解析失败:{exc}") + return None + token = data.get("tenant_access_token") + expire = int(data.get("expire") or 0) + if not token: + logger.error( + f"[AgentResourceOfficer][Feishu] token 缺失:{self._brief_response_error(data)}" + ) + return None + self._token_cache = {"token": token, "expires_at": now + expire} + return token diff --git a/plugins.v2/agentresourceofficer/requirements.txt b/plugins.v2/agentresourceofficer/requirements.txt new file mode 100644 index 0000000..e892782 --- /dev/null +++ b/plugins.v2/agentresourceofficer/requirements.txt @@ -0,0 +1,3 @@ +requests +cloudscraper +lark-oapi==1.5.3 diff --git a/plugins.v2/agentresourceofficer/schemas.py b/plugins.v2/agentresourceofficer/schemas.py new file mode 100644 index 0000000..ace4d1b --- /dev/null +++ b/plugins.v2/agentresourceofficer/schemas.py @@ -0,0 +1,259 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class HDHiveSearchSessionToolInput(BaseModel): + keyword: str = Field(..., description="要搜索的影片或剧集名称") + media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto") + year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录") + + +class HDHiveSessionPickToolInput(BaseModel): + session_id: str = Field(..., description="上一步搜索返回的会话 ID") + choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + + +class ShareRouteToolInput(BaseModel): + url: str = Field(..., description="115 或夸克分享链接") + path: Optional[str] = Field(default=None, description="目标目录") + access_code: Optional[str] = Field(default=None, description="提取码,可选") + + +class AssistantRouteToolInput(BaseModel): + text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接") + session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录") + mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive") + keyword: Optional[str] = Field(default=None, description="结构化搜索关键词") + url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克") + access_code: Optional[str] = Field(default=None, description="结构化提取码") + media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv") + year: Optional[str] = Field(default=None, description="结构化年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPickToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantHelpToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantSessionStateToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantSessionClearToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantCapabilitiesToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantReadinessToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class FeishuChannelHealthToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPulseToolInput(BaseModel): + pass + + +class AssistantStartupToolInput(BaseModel): + pass + + +class AssistantMaintainToolInput(BaseModel): + execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议") + limit: Optional[int] = Field(default=100, description="单次最多清理多少条") + + +class AssistantToolboxToolInput(BaseModel): + pass + + +class AssistantRequestTemplatesToolInput(BaseModel): + limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制") + names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run") + recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap") + include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略") + + +class AssistantSelfcheckToolInput(BaseModel): + pass + + +class AssistantHistoryToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录") + + +class AssistantExecuteActionToolInput(BaseModel): + name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115") + session: Optional[str] = Field(default="default", description="可选会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: Optional[int] = Field(default=None, description="需要选择编号时传入") + path: Optional[str] = Field(default=None, description="可选目标目录") + keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词") + media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型") + year: Optional[str] = Field(default=None, description="搜索类动作使用的年份") + url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接") + access_code: Optional[str] = Field(default=None, description="可选提取码") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤") + has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话") + stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话") + all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话") + limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数") + plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id") + prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecuteActionsToolInput(BaseModel): + actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body") + session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantWorkflowToolInput(BaseModel): + name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status") + session: Optional[str] = Field(default="default", description="工作流会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + keyword: Optional[str] = Field(default=None, description="搜索关键词") + choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1") + candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号") + resource_choice: Optional[int] = Field(default=None, description="影巢资源编号") + path: Optional[str] = Field(default=None, description="可选目标目录") + url: Optional[str] = Field(default=None, description="分享链接") + access_code: Optional[str] = Field(default=None, description="提取码") + media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv") + mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou") + year: Optional[str] = Field(default=None, description="年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + limit: Optional[int] = Field(default=20, description="推荐数量上限") + dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPreferencesToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好") + preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取") + reset: Optional[bool] = Field(default=False, description="是否重置偏好画像") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecutePlanToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划") + session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPlansToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行") + include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条计划") + + +class AssistantPlansClearToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条") + session: Optional[str] = Field(default=None, description="可选会话名;按会话清理") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行") + all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class AssistantRecoverToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议") + prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段") + limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话") + + +class AssistantSessionsToolInput(BaseModel): + kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要") + + +class AssistantSessionsClearToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话") + kind: Optional[str] = Field(default=None, description="按会话类型批量清理") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话") + stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话") + all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class P115QRCodeStartToolInput(BaseModel): + client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini") + + +class P115QRCodeCheckToolInput(BaseModel): + uid: str = Field(..., description="上一步二维码返回的 uid") + time: str = Field(..., description="上一步二维码返回的 time") + sign: str = Field(..., description="上一步二维码返回的 sign") + client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致") + + +class P115StatusToolInput(BaseModel): + pass + + +class P115PendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话") + + +class P115ResumePendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务") + + +class P115CancelPendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务") diff --git a/plugins.v2/agentresourceofficer/services/__init__.py b/plugins.v2/agentresourceofficer/services/__init__.py new file mode 100644 index 0000000..4c1538f --- /dev/null +++ b/plugins.v2/agentresourceofficer/services/__init__.py @@ -0,0 +1 @@ +"""Service modules for Agent影视助手.""" diff --git a/plugins.v2/agentresourceofficer/services/hdhive_openapi.py b/plugins.v2/agentresourceofficer/services/hdhive_openapi.py new file mode 100644 index 0000000..970c5ff --- /dev/null +++ b/plugins.v2/agentresourceofficer/services/hdhive_openapi.py @@ -0,0 +1,1113 @@ +from datetime import datetime +import base64 +import json +import re +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote +from zoneinfo import ZoneInfo + +import requests + +try: + from app.chain.media import MediaChain +except Exception: + MediaChain = None + +try: + from app.core.config import settings +except Exception: + settings = None + + +class HDHiveOpenApiService: + """Reusable HDHive execution layer for Agent影视助手.""" + + _signin_action_name = "checkIn" + _signin_router_tree = ["", {"children": ["(app)", {"children": ["__PAGE__", {}, None, None]}, None, None]}, None, None, True] + _login_api_candidates = [ + "/api/customer/user/login", + "/api/customer/auth/login", + ] + _login_page = "/login" + _login_action_router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Flogin%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D' + _login_action_fallback = "602b5a3af7ab2e93be6a14001ca83c1be491ccecea" + + def __init__( + self, + *, + api_key: str = "", + base_url: str = "https://hdhive.com", + timeout: int = 30, + ) -> None: + self.api_key = self.normalize_text(api_key) + self.base_url = (self.normalize_text(base_url) or "https://hdhive.com").rstrip("/") + self.timeout = self.safe_int(timeout, 30) + self._login_action_id = "" + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_slug(value: Any) -> str: + return str(value or "").strip().replace("-", "") + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def media_type_text(value: Any) -> str: + if value is None: + return "" + raw = str(getattr(value, "value", value)).strip().lower() + mapping = { + "电影": "movie", + "movie": "movie", + "电视剧": "tv", + "tv": "tv", + } + return mapping.get(raw, raw) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def base_headers(self) -> Dict[str, str]: + return { + "X-API-Key": self.api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + def api_url(self, path: str) -> str: + return f"{self.base_url.rstrip('/')}{path}" + + def tmdb_web_search_url(self, media_type: str, keyword: str) -> str: + query = quote(keyword) + if media_type == "movie": + return f"https://www.themoviedb.org/search/movie?query={query}" + if media_type == "tv": + return f"https://www.themoviedb.org/search/tv?query={query}" + return f"https://www.themoviedb.org/search?query={query}" + + def tmdb_web_search_headers(self) -> Dict[str, str]: + return { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + @staticmethod + def extract_year_from_release(value: Any) -> str: + match = re.search(r"(19|20)\d{2}", str(value or "")) + return match.group(0) if match else "" + + def tmdb_web_search_candidates( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[List[Dict[str, Any]], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + search_order = [media_type] if media_type in {"movie", "tv"} else ["tv", "movie"] + pattern = re.compile( + r'href="/(?Ptv|movie)/(?P\d+)"[^>]*>\s*' + r']*>\s*' + r'(?P<title>[^]*srcset="(?P[^"]*)"[^>]*src="(?P[^"]+)"[^>]*>' + r'.*?(?P[^<]+)', + re.S, + ) + candidates: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + errors: List[str] = [] + for search_type in search_order: + try: + response = requests.get( + self.tmdb_web_search_url(search_type, keyword), + headers=self.tmdb_web_search_headers(), + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + response.raise_for_status() + except Exception as exc: + errors.append(f"{search_type}:{exc}") + continue + html = response.text or "" + for match in pattern.finditer(html): + item_type = self.normalize_text(match.group("media_type")).lower() + tmdb_id = self.normalize_text(match.group("tmdb_id")) + if not tmdb_id or tmdb_id in seen_ids: + continue + item_year = self.extract_year_from_release(match.group("release")) + if year and item_year and item_year != year: + continue + seen_ids.add(tmdb_id) + candidates.append( + { + "title": self.normalize_text(match.group("title")), + "year": item_year, + "media_type": item_type or search_type, + "tmdb_id": tmdb_id, + "poster_path": self.normalize_text(match.group("src")), + } + ) + if len(candidates) >= candidate_limit: + return candidates, "" + return candidates, ";".join(errors) + + def request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any], str, int]: + if not self.api_key: + return False, {}, "未配置影巢 API Key", 400 + + try: + response = requests.request( + method=method.upper(), + url=self.api_url(path), + headers=self.base_headers(), + params=params, + json=payload if payload is not None else None, + timeout=timeout or self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + return False, {}, f"请求异常: {exc}", 0 + + try: + result = response.json() + except Exception: + result = { + "success": False, + "message": response.text[:300] if response.text else f"HTTP {response.status_code}", + "description": "接口未返回有效 JSON", + } + + if response.ok and isinstance(result, dict) and result.get("success", True): + return True, result, "", response.status_code + + message = "" + if isinstance(result, dict): + message = ( + result.get("description") + or result.get("message") + or result.get("code") + or f"HTTP {response.status_code}" + ) + if not message: + message = f"HTTP {response.status_code}" + return False, result if isinstance(result, dict) else {}, message, response.status_code + + def resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: + pan = str(item.get("pan_type") or "").lower() + points = item.get("unlock_points") + try: + points_value = int(points) if points is not None and str(points) != "" else 0 + except Exception: + points_value = 9999 + validate = str(item.get("validate_status") or "").lower() + resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] + sources = [str(v) for v in (item.get("source") or [])] + pan_rank = 0 if pan == "115" else 1 if pan == "quark" else 2 + points_rank = 0 if points_value <= 0 else 1 + validate_rank = 0 if validate in {"valid", ""} else 1 + resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 + source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 + return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) + + async def resolve_candidates_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[bool, Dict[str, Any], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + type_filter = "" if media_type in {"auto", "all", "*"} else media_type + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + + if not keyword: + return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" + if type_filter and type_filter not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie、tv 或 auto", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie、tv 或 auto" + chain_error = "" + medias = [] + if MediaChain is None: + chain_error = "MoviePilot MediaChain 不可用" + else: + try: + _, medias = await MediaChain().async_search(title=keyword) + except Exception as exc: + chain_error = f"TMDB 解析失败: {exc}" + try: + medias = list(medias or []) + except Exception: + medias = [] + + candidates: List[Dict[str, Any]] = [] + for media in medias: + item_type = self.media_type_text(getattr(media, "type", "")) + item_year = self.normalize_text(getattr(media, "year", "")) + if type_filter and item_type and item_type != type_filter: + continue + if year and item_year and item_year != year: + continue + tmdb_id = getattr(media, "tmdb_id", None) + if not tmdb_id: + continue + candidates.append( + { + "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", + "year": item_year, + "media_type": item_type or type_filter or "movie", + "tmdb_id": tmdb_id, + "poster_path": getattr(media, "poster_path", "") or "", + } + ) + if len(candidates) >= candidate_limit: + break + + fallback_used = False + fallback_message = "" + if not candidates: + web_candidates, web_error = self.tmdb_web_search_candidates( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if web_candidates: + candidates = web_candidates + fallback_used = True + else: + fallback_message = web_error + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(candidates), + "status_code": 200 if candidates else 404, + "message": "success" if candidates else "未找到可用于影巢搜索的 TMDB 候选", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "meta": { + "total": len(candidates), + "candidate_source": "tmdb_web_search" if fallback_used else "mediainfo_chain", + }, + } + if fallback_used: + result["fallback_reason"] = chain_error or "MediaChain 未返回候选" + elif chain_error: + result["chain_warning"] = chain_error + if not candidates and fallback_message: + result["fallback_error"] = fallback_message + if chain_error: + result["message"] = f"{chain_error};TMDB 网页搜索兜底也未命中" + elif not candidates and chain_error: + result["message"] = chain_error + return bool(candidates), result, result["message"] + + def search_resources(self, media_type: str, tmdb_id: str) -> Tuple[bool, Dict[str, Any], str]: + media_type = (media_type or "").strip().lower() + tmdb_id = self.normalize_text(tmdb_id) + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" + if not tmdb_id: + return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" + + ok, payload, message, status_code = self.request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"media_type": media_type, "tmdb_id": tmdb_id}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + return ok, result, message + + async def search_resources_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + result_limit: int = 12, + ) -> Tuple[bool, Dict[str, Any], str]: + result_limit = min(50, max(1, self.safe_int(result_limit, 12))) + ok, candidate_result, candidate_message = await self.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if not ok: + result = dict(candidate_result) + result["data"] = [] + return False, result, candidate_message + candidates = candidate_result.get("candidates") or [] + + merged_items: List[Dict[str, Any]] = [] + seen_slugs: set[str] = set() + last_status = 200 + + for candidate in candidates: + ok, payload, message = self.search_resources( + media_type=candidate["media_type"] or media_type, + tmdb_id=str(candidate["tmdb_id"]), + ) + last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status + if not ok: + continue + for resource in payload.get("data") or []: + slug = self.normalize_slug(resource.get("slug")) + if not slug or slug in seen_slugs: + continue + seen_slugs.add(slug) + annotated = dict(resource) + annotated["matched_tmdb_id"] = candidate["tmdb_id"] + annotated["matched_title"] = candidate["title"] + annotated["matched_year"] = candidate["year"] + merged_items.append(annotated) + + merged_items.sort(key=self.resource_sort_key) + merged_items = merged_items[:result_limit] + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(merged_items), + "status_code": last_status, + "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "data": merged_items, + "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, + } + return bool(merged_items), result, result["message"] + + def unlock_resource(self, slug: str) -> Tuple[bool, Dict[str, Any], str]: + slug = self.normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self.request( + "POST", + "/api/open/resources/unlock", + payload={"slug": slug}, + ) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_me(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/me") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_usage_today(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/usage/today") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_weekly_free_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/vip/weekly-free-quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def perform_checkin( + self, + *, + is_gambler: Optional[bool] = None, + trigger: str = "手动", + ) -> Tuple[bool, Dict[str, Any], str]: + gambler_mode = bool(is_gambler) + payload = {"is_gambler": True} if gambler_mode else None + ok, result_payload, message, status_code = self.request("POST", "/api/open/checkin", payload=payload) + data = result_payload.get("data") if isinstance(result_payload, dict) else {} + checked_in = bool((data or {}).get("checked_in")) if ok else False + if ok: + status_text = "签到成功" if checked_in else "今日已签到" + else: + status_text = "签到失败" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "trigger": trigger, + "is_gambler": gambler_mode, + "status": status_text, + "message": (data or {}).get("message") or result_payload.get("message") or message, + "data": data or {}, + } + return ok, result, message + + @staticmethod + def parse_cookie_string(cookie_str: Optional[str]) -> Dict[str, str]: + cookies: Dict[str, str] = {} + if not cookie_str: + return cookies + for cookie_item in str(cookie_str).split(";"): + if "=" in cookie_item: + name, value = cookie_item.strip().split("=", 1) + cookies[name] = value + return cookies + + @staticmethod + def _decode_token_user_id(token: str) -> str: + if not token or "." not in token: + return "" + try: + payload = token.split(".", 2)[1] + padding = "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8", "ignore") + data = json.loads(decoded) + return str(data.get("user_id") or data.get("sub") or data.get("id") or "").strip() + except Exception: + return "" + + @staticmethod + def _cookie_string_from_mapping(cookies: Dict[str, str]) -> str: + token_cookie = str((cookies or {}).get("token") or "").strip() + csrf_cookie = str((cookies or {}).get("csrf_access_token") or "").strip() + if not token_cookie: + return "" + cookie_items = [f"token={token_cookie}"] + if csrf_cookie: + cookie_items.append(f"csrf_access_token={csrf_cookie}") + return "; ".join(cookie_items) + + @classmethod + def _extract_login_action_id_from_text(cls, text: str) -> str: + patterns = [ + r'next-action"\s*:\s*"([a-fA-F0-9]{16,64})"', + r'name="next-action"\s+value="([a-fA-F0-9]{16,64})"', + r'createServerReference\("([a-f0-9]{40,})"[^\\n]+?"login"\)', + ] + for pattern in patterns: + match = re.search(pattern, text or "") + if match: + return str(match.group(1) or "").strip() + return "" + + def _discover_login_action_id(self, warm_text: str, scraper: Any) -> str: + if self._login_action_id: + return self._login_action_id + + action_id = self._extract_login_action_id_from_text(warm_text) + if action_id: + self._login_action_id = action_id + return action_id + + script_paths = re.findall( + r']+src="([^"]+/app/\(auth\)/login/page-[^"]+\.js)"', + warm_text or "", + ) + for script_path in script_paths: + script_url = script_path if script_path.startswith("http") else f"{self.base_url}{script_path}" + try: + resp = scraper.get( + script_url, + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Referer": f"{self.base_url}{self._login_page}", + "Accept": "*/*", + }, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + action_id = self._extract_login_action_id_from_text(getattr(resp, "text", "") or "") + if action_id: + self._login_action_id = action_id + return action_id + + self._login_action_id = self._login_action_fallback + return self._login_action_id + + @staticmethod + def _parse_server_action_error(response_text: str) -> str: + if not response_text: + return "" + try: + for line in response_text.splitlines(): + line = line.strip() + if not line.startswith("1:"): + continue + payload = json.loads(line[2:]) + error = payload.get("error") or {} + message = str(error.get("message") or "").strip() + description = str(error.get("description") or "").strip() + if message or description: + return f"{message} ({description})" if description and description != message else (message or description) + except Exception: + return "" + return "" + + def login_for_cookie(self, *, username: str, password: str) -> Tuple[bool, str, str]: + username = self.normalize_text(username) + password = self.normalize_text(password) + if not username or not password: + return False, "", "未配置影巢用户名或密码,无法自动刷新 Cookie" + + try: + import cloudscraper + scraper = cloudscraper.create_scraper() + except Exception: + scraper = requests + + login_url = f"{self.base_url}{self._login_page}" + warm_text = "" + try: + resp_warm = scraper.get( + login_url, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + warm_text = getattr(resp_warm, "text", "") or "" + except Exception: + pass + if "系统维护中" in warm_text or "maintenance" in warm_text.lower(): + return False, "", "影巢站点当前处于维护页,暂时无法自动登录刷新 Cookie" + + for path in self._login_api_candidates: + url = f"{self.base_url}{path}" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "application/json", + } + payload = {"username": username, "password": password} + try: + resp = scraper.post( + url, + headers=headers, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + + cookies_dict: Dict[str, str] = {} + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "API 登录成功" + + try: + data = resp.json() + except Exception: + data = {} + meta = (data.get("meta") or {}) if isinstance(data, dict) else {} + access_token = str(meta.get("access_token") or "").strip() + refresh_token = str(meta.get("refresh_token") or "").strip() + if access_token: + cookie_items = [f"token={access_token}"] + if refresh_token: + cookie_items.append(f"refresh_token={refresh_token}") + return True, "; ".join(cookie_items), "API 登录成功" + + action_id = self._discover_login_action_id(warm_text, scraper) + if action_id: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "text/plain;charset=UTF-8", + "next-action": action_id, + "next-router-state-tree": self._login_action_router_state, + } + body = json.dumps([{"username": username, "password": password}, "/"], separators=(",", ":")) + try: + resp = scraper.post( + login_url, + headers=headers, + data=body, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + resp = None + server_action_message = f"Server Action 登录请求异常: {exc}" + else: + server_action_message = "" + if resp is not None: + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "Server Action 登录成功" + action_error = self._parse_server_action_error(getattr(resp, "text", "") or "") + if action_error: + server_action_message = action_error + else: + server_action_message = "未解析到登录 Action" + + try: + from playwright.sync_api import sync_playwright + except Exception: + return False, "", server_action_message or "自动登录失败,且 Playwright 不可用" + + try: + proxy = None + try: + proxy_config = getattr(settings, "PROXY", None) if settings is not None else None + server = (proxy_config or {}).get("http") or (proxy_config or {}).get("https") + if server: + proxy = {"server": server} + except Exception: + proxy = None + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True, proxy=proxy) if proxy else pw.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + page.goto(login_url, wait_until="domcontentloaded", timeout=self.timeout * 1000) + for selector in [ + "input[name='username']", + "input[name='email']", + "input[type='email']", + "input[placeholder*='邮箱']", + "input[placeholder*='email']", + "input[placeholder*='用户名']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, username) + break + except Exception: + continue + for selector in [ + "input[name='password']", + "input[type='password']", + "input[placeholder*='密码']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, password) + break + except Exception: + continue + try: + button = ( + page.query_selector("button[type='submit']") + or page.query_selector("button:has-text('登录')") + or page.query_selector("button:has-text('Login')") + ) + if button: + button.click() + else: + page.keyboard.press("Enter") + except Exception: + page.keyboard.press("Enter") + try: + page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + cookies = context.cookies() + context.close() + browser.close() + except Exception as exc: + return False, "", f"Playwright 自动登录失败: {exc}" + + cookie_map = {str(item.get("name") or ""): str(item.get("value") or "") for item in cookies or []} + cookie_string = self._cookie_string_from_mapping(cookie_map) + if cookie_string: + return True, cookie_string, "Playwright 登录成功" + return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie" + + @classmethod + def _build_signin_tree_header(cls) -> str: + return quote(json.dumps(cls._signin_router_tree, separators=(",", ":"))) + + @staticmethod + def _build_signin_action_body(is_gambler: bool) -> str: + return json.dumps([bool(is_gambler)], separators=(",", ":")) + + @staticmethod + def _normalize_response_text(text: str) -> str: + if not text: + return "" + if "ä½" in text or "å·²" in text or "签到" in text: + try: + return text.encode("latin1", errors="ignore").decode("utf-8", errors="ignore") + except Exception: + return text + return text + + @classmethod + def _extract_signin_action_id_from_chunk(cls, chunk_text: str) -> str: + if not chunk_text: + return "" + patterns = [ + rf'createServerReference[\s\S]{{0,120}}?\("([a-f0-9]{{32,}})"[\s\S]{{0,1200}}?"{re.escape(cls._signin_action_name)}"', + rf'([a-f0-9]{{32,}}).{{0,240}}?"{re.escape(cls._signin_action_name)}"', + ] + for pattern in patterns: + match = re.search(pattern, chunk_text, re.S) + if match: + return match.group(1) + return "" + + @classmethod + def _parse_signin_action_response(cls, text: str) -> Tuple[bool, str]: + text = cls._normalize_response_text(text) + if not text: + return False, "签到响应为空" + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + _, payload = line.split(":", 1) + try: + data = json.loads(payload) + except Exception: + continue + if not isinstance(data, dict): + continue + if isinstance(data.get("response"), dict): + data = data["response"] + error = data.get("error") + if isinstance(error, dict): + message = cls._normalize_response_text(error.get("description") or error.get("message") or "签到失败") + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return False, message + message = cls._normalize_response_text(data.get("message") or data.get("description")) + success = data.get("success") + if message: + if success is False: + return False, message + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return True, message + return False, "签到响应格式异常" + + def _discover_signin_action_id(self, cookies: Dict[str, str], token: str, referer: str) -> str: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + try: + home_resp = requests.get( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception: + return "" + if home_resp.status_code != 200: + return "" + html = home_resp.text or "" + chunk_paths = list(dict.fromkeys(re.findall(r'/_next/static/chunks/[A-Za-z0-9._-]+\.js', html))) + for chunk_path in chunk_paths: + try: + chunk_resp = requests.get( + url=f"{self.base_url}{chunk_path}", + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/javascript,text/javascript,*/*;q=0.1", + "Connection": "close", + }, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=min(self.timeout, 20), + verify=False, + ) + except Exception: + continue + if chunk_resp.status_code != 200: + continue + action_id = self._extract_signin_action_id_from_chunk(chunk_resp.text or "") + if action_id: + return action_id + return "" + + def perform_legacy_web_checkin( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 400, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": "缺少可用的影巢网页 Cookie", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + if csrf_token: + headers["X-CSRF-TOKEN"] = csrf_token + + payload = {"is_gambler": True} if is_gambler else {} + try: + response = requests.post( + url=f"{self.base_url}/api/customer/user/checkin", + headers=headers, + cookies=cookies, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + verify=False, + ) + except Exception as exc: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"网页签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + try: + body = response.json() + except Exception: + body = {} + + message = "" + if isinstance(body, dict): + message = str(body.get("description") or body.get("message") or body.get("code") or "").strip() + if not message: + message = str(response.text or f"HTTP {response.status_code}").strip()[:200] + + lowered = message.lower() + already_signed = "已经签到" in message or "签到过" in message or "明天再来" in message + success = bool(response.status_code < 400 and (not isinstance(body, dict) or body.get("success") is not False)) + if already_signed: + success = True + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if already_signed else "签到成功" if success else "签到失败", + "message": message or ("签到成功" if success else f"HTTP {response.status_code}"), + "data": body if isinstance(body, dict) else {}, + "source": "hdhive_web_legacy", + } + return success, result, result["message"] + + def perform_web_checkin_with_fallback( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + legacy_ok, legacy_result, legacy_message = self.perform_legacy_web_checkin( + cookie_string=cookie_string, + is_gambler=is_gambler, + trigger=trigger, + ) + if legacy_ok: + return legacy_ok, legacy_result, legacy_message + + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + return legacy_ok, legacy_result, legacy_message + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + action_id = self._discover_signin_action_id(cookies, token, referer) + if not action_id: + message = "旧版网页签到接口不可用,且未能解析当前站点签到 Action;请更新影巢网页 Cookie 后重试" + legacy_result["message"] = message + legacy_result["status"] = "签到失败" + legacy_result["source"] = "hdhive_web_next_action" + return False, legacy_result, message + + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Content-Type": "text/plain;charset=UTF-8", + "Origin": self.base_url, + "Referer": f"{self.base_url}/", + "Authorization": f"Bearer {token}", + "next-action": action_id, + "next-router-state-tree": self._build_signin_tree_header(), + } + if csrf_token: + headers["x-csrf-token"] = csrf_token + + try: + response = requests.post( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + data=self._build_signin_action_body(is_gambler), + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception as exc: + return False, { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"Next Action 签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_next_action", + }, f"Next Action 签到请求异常: {exc}" + + redirect_target = str(response.headers.get("x-action-redirect") or response.headers.get("Location") or "").strip() + if "/login" in redirect_target: + message = "影巢网页 Cookie 已失效,请先在 HDHiveDailySign 中更新 Cookie 或重新自动登录" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {"redirect": redirect_target}, + "source": "hdhive_web_next_action", + } + return False, result, message + if response.status_code in (404, 405): + message = f"影巢网页签到入口暂不可用或 Cookie 已失效(HTTP {response.status_code}),请更新本插件里的影巢网页 Cookie 后重试" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return False, result, message + + response_text = "" + try: + response_text = response.content.decode("utf-8", errors="ignore") + except Exception: + response_text = response.text or "" + success, message = self._parse_signin_action_response(response_text) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if "已经签到" in message or "签到过" in message or "明天再来" in message else "签到成功" if success else "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return success, result, message diff --git a/plugins.v2/agentresourceofficer/services/p115_transfer.py b/plugins.v2/agentresourceofficer/services/p115_transfer.py new file mode 100644 index 0000000..536f9b5 --- /dev/null +++ b/plugins.v2/agentresourceofficer/services/p115_transfer.py @@ -0,0 +1,823 @@ +import importlib +import re +import sys +from base64 import b64encode +from dataclasses import asdict, is_dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from zoneinfo import ZoneInfo + +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None + + +class P115TransferService: + """Reusable 115 share transfer execution layer for Agent影视助手.""" + + CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"} + QR_CLIENT_TYPES = { + "web", + "android", + "115android", + "ios", + "115ios", + "alipaymini", + "wechatmini", + "115ipad", + "tv", + "qandroid", + } + + def __init__( + self, + *, + default_target_path: str = "/待整理", + cookie: str = "", + prefer_direct: bool = True, + ) -> None: + self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理" + self.cookie = self.normalize_text(cookie) + self.prefer_direct = bool(prefer_direct) + + def set_cookie(self, cookie: str = "") -> None: + self.cookie = self.normalize_text(cookie) + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def _ensure_helper_import_paths() -> None: + candidate_dirs = [] + try: + plugin_parent = Path(__file__).resolve().parents[2] + candidate_dirs.append(str(plugin_parent)) + except Exception: + pass + try: + app_plugins_spec = importlib.util.find_spec("app.plugins") + for location in app_plugins_spec.submodule_search_locations or []: + candidate_dirs.append(str(Path(location).resolve())) + except Exception: + pass + for base in candidate_dirs: + path = Path(base) + if path.exists(): + text = str(path) + if text not in sys.path: + sys.path.append(text) + + @staticmethod + def is_115_share_url(url: str) -> bool: + host = urlparse(url).netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + def ensure_115_share_url(self, url: str, access_code: str = "") -> str: + clean_url = self.normalize_text(url) + if not clean_url: + return "" + access_code = self.normalize_text(access_code) + parsed = urlparse(clean_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if access_code and "password" not in query: + query["password"] = access_code + clean_url = urlunparse(parsed._replace(query=urlencode(query))) + return clean_url + + @staticmethod + def _extract_115_payload(url: str) -> Tuple[str, str]: + clean_url = str(url or "").strip() + if not clean_url: + return "", "" + try: + from p115client.util import share_extract_payload + + payload = share_extract_payload(clean_url) or {} + return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip() + except Exception: + parsed = urlparse(clean_url) + share_code = "" + match = re.search(r"/s/([^/?#]+)", parsed.path or "") + if match: + share_code = match.group(1).strip() + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip() + return share_code, receive_code + + @classmethod + def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]: + pairs: Dict[str, str] = {} + for part in cls.normalize_text(cookie).strip(";").split(";"): + if "=" not in part: + continue + key, value = part.split("=", 1) + key = key.strip() + value = value.strip() + if key and value: + pairs[key] = value + return pairs + + @classmethod + def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]: + if not cls.normalize_text(cookie): + return False, "未配置独立 115 Cookie" + pairs = cls.parse_cookie_pairs(cookie) + missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs)) + if missing: + return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie" + return True, "" + + def cookie_state(self) -> Dict[str, Any]: + configured = bool(self.normalize_text(self.cookie)) + pairs = self.parse_cookie_pairs(self.cookie) + cookie_keys = sorted(pairs.keys()) + if not configured: + return { + "configured": False, + "valid": False, + "mode": "none", + "cookie_keys": [], + "message": "未配置独立 115 会话,将优先复用 P115StrmHelper 已登录客户端", + } + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + return { + "configured": True, + "valid": cookie_ok, + "mode": "client_cookie" if cookie_ok else "invalid_cookie", + "cookie_keys": cookie_keys, + "message": "" if cookie_ok else cookie_message, + } + + @classmethod + def normalize_qrcode_client_type(cls, client_type: Any) -> str: + text = cls.normalize_text(client_type).lower() + return text if text in cls.QR_CLIENT_TYPES else "alipaymini" + + @staticmethod + def jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool, list, dict)): + return value + if is_dataclass(value): + return asdict(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + if hasattr(value, "__dict__"): + return {k: v for k, v in vars(value).items() if not k.startswith("_")} + return str(value) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + @staticmethod + def _safe_int(value: Any, default: int = -1) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _response_error(resp: Any) -> str: + if not isinstance(resp, dict): + return str(resp or "") + for key in ("error", "message", "msg", "errno"): + value = resp.get(key) + if value not in (None, ""): + return str(value) + return str(resp) + + @classmethod + def _is_already_saved_message(cls, value: Any) -> bool: + text = cls.normalize_text(value) + return any( + marker in text + for marker in ( + "已经转存", + "已转存", + "已经保存", + "已保存", + "already", + "exist", + ) + ) + + @staticmethod + def _response_ok(resp: Any) -> bool: + if not isinstance(resp, dict): + return False + if resp.get("state") is True: + return True + if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0): + return True + if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0): + return True + return False + + @staticmethod + def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]: + try: + P115TransferService._ensure_helper_import_paths() + from app.plugins.p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + try: + P115TransferService._ensure_helper_import_paths() + from p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + pass + return {} + + @staticmethod + def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]: + if PluginManager is None: + return None, "PluginManager 不可用" + try: + plugin = PluginManager().running_plugins.get("P115StrmHelper") + except Exception as exc: + return None, f"读取 P115StrmHelper 运行态失败: {exc}" + if not plugin: + return None, "P115StrmHelper 未加载" + + module_names = [] + plugin_module = getattr(plugin.__class__, "__module__", "") or "" + if plugin_module: + module_names.append(f"{plugin_module}.service") + module_names.extend( + [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ] + ) + + for module_name in module_names: + try: + self._ensure_helper_import_paths() + module = sys.modules.get(module_name) or importlib.import_module(module_name) + servicer = getattr(module, "servicer", None) + if servicer is not None: + return servicer, None + except Exception: + continue + return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer" + + def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + return None, helper_error or "P115StrmHelper 未加载" + client = getattr(servicer, "client", None) + if not client: + return None, "P115StrmHelper 未登录 115 或客户端不可用" + return client, "p115strmhelper_client" + + def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]: + if not self.cookie: + return None, "未配置独立 115 Cookie" + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + if not cookie_ok: + return None, cookie_message + try: + from p115client import P115Client + + return P115Client( + self.cookie, + check_for_relogin=False, + ensure_cookies=False, + console_qrcode=False, + ), "direct_cookie" + except Exception as exc: + return None, f"独立 115 Cookie 初始化失败: {exc}" + + @classmethod + def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_token() + check_response(resp) + resp_info = resp.get("data", {}) if isinstance(resp, dict) else {} + uid = str(resp_info.get("uid") or "") + qrcode_time = str(resp_info.get("time") or "") + sign = str(resp_info.get("sign") or "") + qrcode = P115Client.login_qrcode(uid) + if not isinstance(qrcode, (bytes, bytearray)): + return False, {}, "获取二维码失败:返回内容类型异常" + return True, { + "uid": uid, + "time": qrcode_time, + "sign": sign, + "client_type": final_client_type, + "tips": "请使用 115 App 扫码登录", + "qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}", + }, "success" + except Exception as exc: + return False, {}, f"获取 115 登录二维码失败: {exc}" + + @classmethod + def check_qrcode_login( + cls, + *, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + payload = {"uid": uid, "time": time_value, "sign": sign} + resp = P115Client.login_qrcode_scan_status(payload) + if not isinstance(resp, dict): + return False, {}, "检查二维码状态失败:返回内容类型异常" + check_response(resp) + status_code = (resp.get("data") or {}).get("status") + except Exception as exc: + return False, {}, f"检查二维码状态失败: {exc}" + + if status_code == 0: + return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码" + if status_code == 1: + return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认" + if status_code == -1 or status_code is None: + return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期" + if status_code == -2: + return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录" + if status_code != 2: + return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}" + + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type) + if not isinstance(resp, dict): + return False, {}, "获取登录结果失败:返回内容类型异常" + check_response(resp) + except Exception as exc: + return False, {}, f"获取登录结果失败: {exc}" + + cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None + if not isinstance(cookie_data, dict): + return False, {}, "登录成功但未返回 Cookie" + cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip() + cookie_ok, cookie_message = cls.validate_client_cookie(cookie) + if not cookie_ok: + return False, {}, cookie_message + return True, { + "status": "success", + "client_type": final_client_type, + "cookie": cookie, + "cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()), + }, "登录成功" + + def get_direct_client(self) -> Tuple[Optional[Any], str, str]: + client, source = self._get_cookie_p115_client() + if client: + return client, source, "" + cookie_error = source + client, source = self._get_loaded_p115_client() + if client: + return client, source, "" + return None, "none", source or cookie_error + + @classmethod + def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]: + last_error = "" + for module_name in [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ]: + try: + cls._ensure_helper_import_paths() + service_module = importlib.import_module(module_name) + servicer = getattr(service_module, "servicer", None) + if servicer is not None: + return servicer, None + last_error = f"{module_name} 未暴露 servicer" + except Exception as exc: + last_error = f"{module_name} 导入失败: {exc}" + return None, last_error or "P115StrmHelper 未安装或无法导入" + + def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + servicer, helper_error = self._import_servicer_fallback() + if not servicer: + return None, f"P115StrmHelper 未安装或无法导入: {helper_error}" + if not servicer: + return None, "P115StrmHelper 未初始化" + if not getattr(servicer, "client", None): + return None, "P115StrmHelper 未登录 115 或客户端不可用" + helper = getattr(servicer, "sharetransferhelper", None) + if not helper: + return None, "P115StrmHelper 分享转存模块不可用" + return helper, None + + def health(self) -> Tuple[bool, Dict[str, Any], str]: + cookie_state = self.cookie_state() + direct_client, direct_source, direct_error = self.get_direct_client() + direct_ready = direct_client is not None + helper, helper_error = self.get_share_helper() + helper_ready = bool(helper and not helper_error) + ready = direct_ready or helper_ready + message = "" if ready else direct_error or helper_error or "115 转存不可用" + return ready, { + "ready": ready, + "direct_ready": direct_ready, + "direct_source": direct_source if direct_ready else "", + "direct_message": "" if direct_ready else direct_error, + "helper_ready": helper_ready, + "helper_message": "" if helper_ready else helper_error, + "cookie_state": cookie_state, + "message": message or "success", + }, message + + def _get_or_create_path_cid(self, client: Any, path: str) -> int: + return self._get_path_cid(client, path, create=True) + + def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int: + target_path = self.normalize_pan_path(path) or "/" + if target_path == "/": + return 0 + get_kwargs = self._p115_request_kwargs(app=False) + mkdir_kwargs = self._p115_request_kwargs(app=True) + try: + resp = client.fs_dir_getid(target_path, **get_kwargs) + pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1) + if pid > 0: + return pid + except Exception: + pass + + if not create: + return -1 + + try: + resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs) + cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1) + if cid >= 0: + return cid + if self._response_ok(resp): + cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1) + if cid >= 0: + return cid + raise RuntimeError(self._response_error(resp)) + except Exception as exc: + raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc + + def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "path": target_path, + "items": [], + "file_count": 0, + "folder_count": 0, + "removed_count": 0, + "message": "", + } + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + result["direct_source"] = source + return False, result, result["message"] + + cid = self._get_path_cid(client, target_path, create=False) + if cid < 0: + result["ok"] = True + result["direct_source"] = source + result["message"] = "115 默认目录不存在,视为空目录" + return True, result, result["message"] + + payload = { + "cid": int(cid), + "limit": 1150, + "offset": 0, + "show_dir": 1, + "cur": 1, + "count_folders": 1, + } + items: list[dict[str, Any]] = [] + total = 0 + try: + while True: + resp = client.fs_files(payload, **self._p115_request_kwargs(app=False)) + if not isinstance(resp, dict): + result["message"] = "读取 115 目录失败:返回内容异常" + result["direct_source"] = source + return False, result, result["message"] + batch = resp.get("data") or [] + total = self._safe_int(resp.get("count"), total) + for entry in batch: + if not isinstance(entry, dict): + continue + fid = self._safe_int(entry.get("fid"), -1) + item_cid = self._safe_int(entry.get("cid"), -1) + is_dir = fid < 0 + item_id = item_cid if is_dir else fid + if item_id < 0: + continue + items.append( + { + "id": item_id, + "name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")), + "is_dir": is_dir, + "type": "folder" if is_dir else "file", + "raw": entry, + } + ) + payload["offset"] = int(payload["offset"]) + len(batch) + if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total: + break + except Exception as exc: + result["message"] = f"读取 115 目录失败: {exc}" + result["direct_source"] = source + return False, result, result["message"] + + file_count = len([item for item in items if not item.get("is_dir")]) + folder_count = len([item for item in items if item.get("is_dir")]) + result.update( + { + "ok": True, + "direct_source": source, + "cid": cid, + "items": items, + "file_count": file_count, + "folder_count": folder_count, + "message": "success", + } + ) + return True, result, "success" + + def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + client, source, client_error = self.get_direct_client() + result = { + "ok": False, + "direct_source": source, + "removed_count": 0, + "message": "", + } + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + return False, result, result["message"] + + ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0] + if not ids: + result.update({"ok": True, "message": "115 默认目录当前层已是空目录"}) + return True, result, result["message"] + + try: + resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"删除 115 目录内容失败: {exc}" + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "删除 115 目录内容失败" + result["raw"] = self.jsonable(resp) + return False, result, result["message"] + + result.update( + { + "ok": True, + "removed_count": len(ids), + "message": "115 默认目录已清空当前层", + "raw": self.jsonable(resp), + } + ) + return True, result, result["message"] + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path) + if not listed_ok: + return False, listed_result, listed_message + + items = listed_result.get("items") or [] + if not items: + listed_result["message"] = "115 默认目录当前层已是空目录" + return True, listed_result, listed_result["message"] + + delete_ok, delete_result, delete_message = self.delete_items(items) + merged = dict(listed_result) + merged.update( + { + "ok": delete_ok, + "removed_count": delete_result.get("removed_count", 0), + "direct_source": delete_result.get("direct_source", listed_result.get("direct_source")), + "delete_raw": delete_result.get("raw"), + "message": delete_message, + } + ) + return delete_ok, merged, delete_message + + def transfer_share_direct( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "strategy": "direct", + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + share_code, receive_code = self._extract_115_payload(share_url) + if not share_code or not receive_code: + result["message"] = "解析 115 分享链接失败,缺少分享码或提取码" + return False, result, result["message"] + + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 直转客户端" + result["data"] = {"direct_source": source} + return False, result, result["message"] + + try: + parent_id = self._get_or_create_path_cid(client, transfer_path) + except Exception as exc: + result["message"] = str(exc) + result["data"] = {"direct_source": source} + return False, result, result["message"] + + payload = { + "share_code": share_code, + "receive_code": receive_code, + "file_id": 0, + "cid": int(parent_id), + "is_check": 0, + } + try: + resp = client.share_receive(payload, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"调用 115 直转接口失败: {exc}" + result["data"] = {"direct_source": source, "parent_id": parent_id} + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "115 直转失败" + result["data"] = { + "direct_source": source, + "parent_id": parent_id, + "raw": self.jsonable(resp), + } + if self._is_already_saved_message(result["message"]): + result["ok"] = True + result["message"] = "115 直转已存在" + return True, result, result["message"] + return False, result, result["message"] + + result.update( + { + "ok": True, + "message": "115 直转成功", + "data": { + "direct_source": source, + "share_code": share_code, + "receive_code": receive_code, + "save_parent": transfer_path, + "parent_id": parent_id, + "raw": self.jsonable(resp), + }, + } + ) + return True, result, result["message"] + + def transfer_share( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + if self.prefer_direct: + direct_ok, direct_result, direct_message = self.transfer_share_direct( + url=share_url, + access_code=access_code, + path=transfer_path, + trigger=trigger, + ) + if direct_ok: + return True, direct_result, direct_message + result["data"]["direct_fallback"] = direct_result + + helper, helper_error = self.get_share_helper() + if helper_error or not helper: + direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message") + result["message"] = helper_error or direct_error or "P115StrmHelper 不可用" + return False, result, result["message"] + + try: + transfer_result = helper.add_share_115( + share_url, + notify=False, + pan_path=transfer_path, + ) + except Exception as exc: + result["message"] = f"调用 P115StrmHelper 转存失败: {exc}" + return False, result, result["message"] + + if not transfer_result or not transfer_result[0]: + error_message = "" + if isinstance(transfer_result, tuple): + if len(transfer_result) > 2: + error_message = self.normalize_text(transfer_result[2]) + elif len(transfer_result) > 1: + error_message = self.normalize_text(transfer_result[1]) + if self._is_already_saved_message(error_message): + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存已存在", + "data": {"raw": self.jsonable(transfer_result)}, + } + ) + return True, result, result["message"] + result["message"] = error_message or "115 转存失败" + result["data"] = {"raw": self.jsonable(transfer_result)} + return False, result, result["message"] + + media_info = transfer_result[1] if len(transfer_result) > 1 else None + save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path + parent_id = transfer_result[3] if len(transfer_result) > 3 else None + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存成功", + "data": { + "media_info": self.jsonable(media_info), + "save_parent": save_parent, + "parent_id": parent_id, + }, + } + ) + return True, result, result["message"] diff --git a/plugins.v2/agentresourceofficer/services/quark_transfer.py b/plugins.v2/agentresourceofficer/services/quark_transfer.py new file mode 100644 index 0000000..68261e8 --- /dev/null +++ b/plugins.v2/agentresourceofficer/services/quark_transfer.py @@ -0,0 +1,664 @@ +import json +import random +import re +import time +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlparse, urlencode + +import requests + +from app.log import logger + +try: + from app.core.config import settings +except Exception: + settings = None + + +class QuarkTransferService: + """ + Reusable execution layer migrated out of QuarkShareSaver. + + This service intentionally focuses on transfer execution and directory + resolution. UI, plugin form logic, and entry adapters stay outside. + """ + + def __init__( + self, + *, + cookie: str = "", + timeout: int = 30, + default_target_path: str = "/飞书", + auto_import_cookiecloud: bool = True, + cookie_refresh_callback: Optional[Callable[[], str]] = None, + ) -> None: + self.cookie = self.clean_text(cookie) + self.timeout = max(10, self.safe_int(timeout, 30)) + self.default_target_path = self.normalize_path(default_target_path or "/飞书") + self.auto_import_cookiecloud = auto_import_cookiecloud + self.cookie_refresh_callback = cookie_refresh_callback + self.path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + @staticmethod + def extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + @classmethod + def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = cls.clean_text(share_text) + share_url = cls.extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = cls.clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = cls.clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + @classmethod + def validate_share_url(cls, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if cls.is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def set_cookie(self, cookie: str) -> None: + self.cookie = self.clean_text(cookie) + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self.cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _refresh_cookie(self) -> bool: + if not self.auto_import_cookiecloud or not self.cookie_refresh_callback: + return False + try: + cookie = self.clean_text(self.cookie_refresh_callback()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}") + return False + if not cookie: + return False + self.cookie = cookie + return True + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookie_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + try: + response = requests.request( + method=method.upper(), + url=url, + params=params or None, + json=json_body, + headers=self._build_headers(), + timeout=self.timeout, + ) + status_code = response.status_code + raw_body = response.text or "" + except requests.RequestException as exc: + return False, {}, f"请求失败: {exc}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = response.json() + except Exception: + text = str(raw_body)[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie(): + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookie_retry=False, + ) + + if status_code != 200: + if isinstance(data, dict): + code = self.clean_text(data.get("code")) + detail = self.clean_text(data.get("message") or data.get("msg")) + if detail: + if code: + return False, data, f"HTTP {status_code} [{code}]: {detail}" + return False, data, f"HTTP {status_code}: {detail}" + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self.clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self.safe_int(meta.get("_total"), 0) + count = self.safe_int(meta.get("_count"), len(current)) + size = max(1, self.safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + "raw": item, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + source_items = [item for item in (items or []) if isinstance(item, dict)] + + def build_fids(candidates: List[Dict[str, Any]]) -> List[str]: + result: List[str] = [] + for item in candidates: + fid = self.clean_text(item.get("fid")) + if fid: + result.append(fid) + return result + + def item_label(item: Dict[str, Any]) -> str: + return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid")) + + def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + fids = build_fids(candidates) + if not fids: + return False, {}, "默认目录当前层没有可删除项目" + payloads = [ + { + "action_type": 2, + "exclude_fids": [], + "filelist": [{"fid": fid} for fid in fids], + }, + { + "action_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + { + # Some web scripts historically used this misspelled key. + "actoin_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + ] + last_data: Dict[str, Any] = {} + last_message = "" + for index, payload in enumerate(payloads, start=1): + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/file/delete", + params={ + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + }, + json_body=payload, + ) + if ok: + if isinstance(data, dict): + data["delete_payload_variant"] = index + return True, data, "" + last_data = data if isinstance(data, dict) else {} + last_message = message or last_message + return False, last_data, last_message or "夸克删除失败" + + filelist: List[Dict[str, Any]] = [] + for item in source_items: + fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else "" + if fid: + filelist.append({"fid": fid}) + if not filelist: + return False, {}, "默认目录当前层没有可删除项目" + + ok, data, message = call_delete(source_items) + if ok: + data["deleted_count"] = len(filelist) + data["delete_mode"] = "batch" + return True, data, "" + + if len(source_items) <= 1: + return False, data, message or "夸克删除失败" + + deleted_count = 0 + failed_items: List[Dict[str, Any]] = [] + for item in source_items: + single_ok, single_data, single_message = call_delete([item]) + if single_ok: + deleted_count += 1 + continue + failed_items.append({ + "fid": self.clean_text(item.get("fid")), + "name": item_label(item), + "message": single_message or "删除失败", + "result": single_data, + }) + + result = { + "deleted_count": deleted_count, + "failed_count": len(failed_items), + "failed_items": failed_items[:20], + "delete_mode": "single_fallback", + "batch_error": message or "夸克批量删除失败", + "batch_result": data, + } + if failed_items: + return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败" + return True, result, "" + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path) + if not ok: + return False, {}, target_fid or "定位夸克目录失败" + + ok, children, message = self.list_children(target_fid) + if not ok: + return False, {}, message or "读取夸克目录失败" + + files = [item for item in children if not bool(item.get("dir"))] + folders = [item for item in children if bool(item.get("dir"))] + if not children: + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "默认目录当前层为空" + + ok, delete_result, message = self.delete_items(children) + removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0) + if not ok: + return False, { + "target_path": normalized_path, + "target_fid": target_fid, + "file_count": len(files), + "folder_count": len(folders), + "removed_count": removed_count, + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "failed_items": (delete_result or {}).get("failed_items") or [], + "delete_result": delete_result, + }, message or "夸克清空默认目录失败" + + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "delete_result": delete_result, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "success" + + def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self.list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self.clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self.normalize_path(path or self.default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self.path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self.path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self.find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self.create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self.path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self.clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self.safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self.list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code) + ok, message = self.validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + if not self.cookie: + self._refresh_cookie() + if not self.cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self.get_stoken(pwd_id, final_code) + if not ok: + return False, {}, message + + ok, share_items, message = self.get_share_items(pwd_id, stoken) + if not ok: + return False, {}, message + + ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path) + if not ok: + return False, {}, target_fid + + ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + return False, {}, message + + ok, task, message = self.wait_task(task_id) + if not ok: + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + return True, result, "success" diff --git a/plugins.v2/airecognizerenhancer/ARCHITECTURE.md b/plugins.v2/airecognizerenhancer/ARCHITECTURE.md new file mode 100644 index 0000000..314622b --- /dev/null +++ b/plugins.v2/airecognizerenhancer/ARCHITECTURE.md @@ -0,0 +1,83 @@ +# AI识别增强架构草案 + +`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。 + +## 设计目标 + +- 摆脱外部 AI Gateway 的强依赖 +- 直接使用 MoviePilot 已启用的 LLM 配置 +- 输出结构化识别结果,而不是只回传一段自由文本 + +## 模块分层 + +### 1. hooks + +负责接住识别失败事件和后续整理事件。 + +### 2. llm + +负责封装对 MP 当前 LLM 的调用: + +- 标准提示词 +- 结构化返回约束 +- 超时与错误兜底 + +### 3. normalize + +负责把 AI 输出转换成可继续进入 MP 整理链路的数据: + +- 标题 +- 年份 +- 类型 +- 季 +- 集 +- 置信度 + +### 4. actions + +负责根据结果执行后续动作: + +- 二次识别 +- 二次整理 +- 记录失败样本 + +## 首期配置模型 + +- `enabled` +- `notify` +- `debug` +- `confidence_threshold` +- `request_timeout` +- `max_retries` +- `save_failed_samples` + +## 二期规划 + +- 生成自定义识别词建议 +- 失败样本聚合分析 +- 提供给 MP Agent / Skill 直接调起 + +## 首个里程碑 + +第一个可用版本只追求: + +1. 原生识别失败后自动触发本地 LLM 判断 +2. 拿到结构化结果后自动二次整理 +3. 能明确记录“成功 / 放弃 / 失败原因” + +## 当前实现状态 + +- 已接住 `ChainEventType.NameRecognize` +- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出 +- 已提供手动调试接口用于验证标题识别结果 +- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议 +- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers` +- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制 +- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本 +- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除 +- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队 +- 已支持失败样本批量复查:可批量重跑并按结果批量出队 +- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库 +- 已支持低 token 精简摘要输出,适合作为智能体批处理入口 +- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地 +- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升 diff --git a/plugins.v2/airecognizerenhancer/README.md b/plugins.v2/airecognizerenhancer/README.md index ea48943..e10d001 100644 --- a/plugins.v2/airecognizerenhancer/README.md +++ b/plugins.v2/airecognizerenhancer/README.md @@ -83,6 +83,8 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次 当前版本:`0.1.12` +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + 这版已经验证过: - 最新版 MoviePilot 下可以正常加载 diff --git a/plugins.v2/airecognizerenhancer/__init__.py b/plugins.v2/airecognizerenhancer/__init__.py index 4471184..eee252e 100644 --- a/plugins.v2/airecognizerenhancer/__init__.py +++ b/plugins.v2/airecognizerenhancer/__init__.py @@ -1411,6 +1411,10 @@ AI 识别增强结果: return guess = result.get("guess") or {} if isinstance(event_data, dict): + if event_data.get("source_plugin"): + if self._debug: + logger.info(f"[AI识别增强] 已有插件处理识别结果,跳过覆盖: {event_data.get('source_plugin')}") + return event_data["name"] = guess.get("name", "") event_data["year"] = guess.get("year", "") event_data["season"] = guess.get("season", 0) diff --git a/plugins.v2/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md b/plugins.v2/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md new file mode 100644 index 0000000..75060bd --- /dev/null +++ b/plugins.v2/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md @@ -0,0 +1,188 @@ +# 外部智能体接入 Agent影视助手 + +让 `OpenClaw`、`Hermes`、`WorkBuddy` 或其他外部智能体,也能稳定调用 MoviePilot 的搜片、转存、下载、签到和修复能力。 + +核心思路很简单:外部智能体负责理解你说的话、调用 `Agent影视助手`、展示结果;真正的资源搜索、转存、下载和账号操作,都交给 MoviePilot 里的插件执行。 + +--- + +## 一步接入 + +把下面这段直接发给你的外部智能体: + +```text +请从这个仓库创建并使用 agent-resource-officer Skill: +https://github.com/liuyuexi1987/MoviePilot-Plugins + +创建后请依次读取: +1. skills/agent-resource-officer/SKILL.md +2. skills/agent-resource-officer/EXTERNAL_AGENTS.md +3. docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md + +连接配置: +ARO_BASE_URL=http://MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN + +如果你的客户端支持 MoviePilot 官方 MCP,也请同时接入: +MCP 地址:http://MoviePilot地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN + +分工规则: +1. 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询,可以优先用 MCP。 +2. 云盘搜索、盘搜、影巢、转存、夸克转存、115转存、下载、更新检查、编号选择、翻页、详情、Cookie 修复,继续优先用 agent-resource-officer skill / helper。 +3. 只有当前会话真的加载出 mcp__moviepilot__* 工具,才算 MCP 已接通;没接通时不要假装在用 MCP。 + +请把配置写入 ~/.config/agent-resource-officer/config。 +然后运行 readiness 验证连接,成功后按文档规则接入。 +``` + +`ARO_API_KEY` 在 MoviePilot 管理后台的系统设置 / 安全设置里找。 + +--- + +## 连接地址怎么填 + +先判断 MoviePilot 和智能体是不是在同一台机器。 + +### 同机部署 + +如果 MoviePilot 和智能体在同一台电脑或同一个容器网络里,可以这样填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +这也是最简单的情况。 + +### 跨机器部署 + +如果 MoviePilot 在 NAS,智能体在 Win / Mac 电脑上,`ARO_BASE_URL` 必须填 NAS 的实际地址: + +```bash +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +这里的 `127.0.0.1` 只代表智能体自己这台机器,不是 NAS。 + +如果你有多套 MoviePilot,要特别注意: + +- `ARO_BASE_URL` 指向哪套 MoviePilot,`下载 / MP搜索 / PT搜索 / 转存` 就使用哪套 MoviePilot。 +- 如果当前 MoviePilot 只用于网盘或 STRM,不要在这套实例里确认 PT 下载。 +- 如果 MoviePilot 和 qBittorrent 不在一台机器,可在 Agent影视助手设置里填写 `PT 下载保存路径`,路径要按目标 NAS / qB 的真实下载目录填写。 + +跨机器部署详细说明见 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)。 + +--- + +## 手动添加 MCP + +有些智能体不会自动读取或启用 MoviePilot MCP,需要你在智能体的 MCP 设置里手动添加。 + +填写: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS,地址要写 NAS 的实际地址: + +```text +MCP 地址:http://你的NAS地址:3000/api/v1/mcp +``` + +添加后,需要在智能体里确认 MCP 已启用,并且当前会话能看到类似 `mcp__moviepilot__*` 的工具。 + +如果看不到这些工具,就说明 MCP 没有真正加载成功。此时不要让智能体假装在用 MCP,资源流继续走 `agent-resource-officer skill / helper`。 + +--- + +## 怎么用 + +接入完成后,直接对智能体说: + +| 命令 | 作用 | +|---|---| +| `搜索 蜘蛛侠` | 搜索云盘资源,默认走盘搜 | +| `云盘搜索 蜘蛛侠` | 盘搜 + 影巢一起搜 | +| `MP搜索 蜘蛛侠` / `PT搜索 蜘蛛侠` | 走 MoviePilot 原生 PT 搜索 | +| `转存 蜘蛛侠` | 默认等同 `115转存 蜘蛛侠` | +| `115转存 蜘蛛侠` | 搜索后转存到 115 | +| `夸克转存 蜘蛛侠` | 搜索后转存到夸克 | +| `下载 蜘蛛侠` | 搜索并生成 PT 下载计划 | +| `更新检查 蜘蛛侠` | 检查是否有新资源 | +| `115登录` | 扫码登录 115 | +| `影巢签到` | 执行影巢签到 | + +完整命令列表见:`docs/ALL_COMMANDS.md`。 + +--- + +## MCP 要不要接 + +MoviePilot 官方 MCP 可以接,但它和 `agent-resource-officer skill / helper` 的定位不同。 + +推荐这样分工: + +| 场景 | 推荐入口 | +|---|---| +| 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询 | 官方 MCP | +| 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情、Cookie 修复 | `agent-resource-officer skill / helper` | +| `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流 | 优先 `agent-resource-officer skill / helper` | + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +``` + +认证头: + +```text +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +注意:只有当前智能体客户端真的加载出了 `mcp__moviepilot__*` 工具,才算 MCP 已接通。没有接通时,不要让智能体假装在用 MCP;资源流继续走 `agent-resource-officer`。 + +--- + +## 给智能体看的执行规则 + +这部分规则已经写在 `agent-resource-officer` Skill 里,普通用户不用背。 + +接入时只要让外部智能体读取本仓库里的 Skill,它就会知道哪些命令必须走 `route / pick`、哪些动作需要确认、哪些结果不能重排编号。 + +--- + +## 长线程维护 + +微信、飞书、WorkBuddy、Claw 这类长线程用久后,可能会出现: + +- `15详情` 被误解成 `选择 15` +- 编号续接到旧搜索结果 +- 一直套用旧格式或旧规则 + +这时直接对智能体说: + +```text +校准影视技能 +``` + +这条命令会让智能体重新加载影视助手的关键规则。不要在普通 `搜索 / 更新检查 / 检查` 前主动清会话,否则会破坏正常编号续接。 + +--- + +## 相关文档 + +- 全部命令一览:`docs/ALL_COMMANDS.md` +- [跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [Skill 说明](../skills/agent-resource-officer/SKILL.md) +- 外部智能体详细规范:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` diff --git a/plugins.v2/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md b/plugins.v2/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md new file mode 100644 index 0000000..b624fa0 --- /dev/null +++ b/plugins.v2/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md @@ -0,0 +1,177 @@ +# Agent影视助手跨机器部署 + +这份文档只讲一种常见情况: + +```text +MoviePilot 在 NAS / Docker / 远程主机 +外部智能体在 Win / Mac 电脑 +``` + +这属于正常用法,不是特殊模式。关键只有一个:智能体要能访问到 MoviePilot。 + +--- + +## 先填对 ARO_BASE_URL + +外部智能体所在电脑的配置文件一般是: + +```text +~/.config/agent-resource-officer/config +``` + +如果 MoviePilot 在 NAS,配置应类似: + +```text +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要写: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +因为这里的 `127.0.0.1` 代表智能体自己这台电脑,不是 NAS。 + +只有 MoviePilot 和智能体在同一台机器时,才用: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +--- + +## 多套 MoviePilot 时要注意 + +`ARO_BASE_URL` 指向哪套 MoviePilot,下面这些命令就使用哪套 MoviePilot: + +```text +MP搜索 +PT搜索 +下载 +订阅 +转存 +更新检查 +``` + +如果你有一套 MoviePilot 只用于网盘 / STRM,不要在这套实例里确认 PT 下载。 + +如果你真正下载用的是 NAS 上另一套 MoviePilot,就把 `ARO_BASE_URL` 指向那一套。 + +--- + +## MP 和 qB 不同机时 + +如果 MoviePilot 和 qBittorrent 不在一台机器,可以在 `Agent影视助手` 设置页填写: + +```text +PT 下载保存路径 +``` + +简单理解: + +- MoviePilot 和 qB 在同一台机器:通常不用填。 +- MoviePilot 和 qB 不在一台机器:填 qB 能识别的真实下载目录。 + +示例: + +```text +/downloads +/volume1/downloads +local:/downloads +``` + +不要填你当前电脑上的临时路径,除非 qB 也真的在这台电脑上。 + +--- + +## 盘搜 API 地址按 MoviePilot 视角填 + +这里容易混: + +- `ARO_BASE_URL` 是外部智能体访问 MoviePilot 的地址。 +- `盘搜 API 地址` 是 MoviePilot 插件访问 PanSou 的地址。 + +如果 PanSou 和 MoviePilot 在同一台 NAS / Docker 网络里,`盘搜 API 地址` 要填 MoviePilot 那边能访问到的地址,不一定是你电脑能访问到的地址。 + +--- + +## Cookie 修复读的是哪台电脑 + +这些命令会用到浏览器 Cookie: + +```text +刷新影巢Cookie +修复影巢签到 +刷新夸克Cookie +修复夸克转存 +``` + +跨机器时,它们读取的是**智能体所在电脑**的浏览器登录态,然后写回 NAS 上的 MoviePilot。 + +所以如果 MoviePilot 在 NAS、智能体在 Mac: + +1. 在 Mac 浏览器里登录 `https://hdhive.com` 或 `https://pan.quark.cn`。 +2. 再让智能体执行修复命令。 +3. 不需要去 NAS 桌面上找浏览器 Cookie。 + +--- + +## 最小验证 + +在智能体所在机器执行: + +```bash +python3 scripts/aro_request.py readiness +``` + +如果通过,说明智能体已经能访问 MoviePilot 插件。 + +再试一个只读命令: + +```bash +python3 scripts/aro_request.py route "115状态" +``` + +如果也能返回,跨机器主链基本就通了。 + +--- + +## 常见错误 + +### 1. NAS 环境还写 127.0.0.1 + +表现:智能体连接失败、请求打到自己电脑。 + +解决:把 `ARO_BASE_URL` 改成 NAS 的局域网 IP 或域名。 + +### 2. 改了仓库文件,但 MoviePilot 还在跑旧插件 + +仓库里的文件改完后,不等于容器里的插件已经更新。 + +如果页面或接口还是旧表现,先确认 MoviePilot 实际加载的是最新插件。 + +### 3. 长线程被旧上下文污染 + +表现: + +- `15详情` 被当成 `选择 15` +- 编号接到旧搜索结果 +- 明明更新了规则,智能体还是按旧说法执行 + +直接对智能体说: + +```text +校准影视技能 +``` + +不要在普通搜索前固定清会话,否则会破坏正常编号续接。 + +--- + +## 推荐阅读 + +- [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- 全部命令:`docs/ALL_COMMANDS.md` +- [插件安装说明](./PLUGIN_INSTALL.md) diff --git a/plugins.v2/docs/MAINTENANCE_COMMANDS.md b/plugins.v2/docs/MAINTENANCE_COMMANDS.md new file mode 100644 index 0000000..93602d9 --- /dev/null +++ b/plugins.v2/docs/MAINTENANCE_COMMANDS.md @@ -0,0 +1,193 @@ +# 仓库维护命令索引 + +这份文档只列当前常用的仓库维护与发布命令,不解释历史方案。 + +## 当前状态 + +- 当前插件版本:`AgentResourceOfficer 0.2.68` +- 当前 Skill helper 版本:`0.1.46` +- 当前 Release: + +## 最常用入口 + +- 仓库卫生检查: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 发版前完整检查: + +```bash +bash scripts/release-preflight.sh +``` + +- 低层发布检查: + +```bash +bash scripts/pre-release-check.sh +``` + +## 推荐顺序 + +- 日常看状态或准备整理仓库: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 想清理本地生成文件或顺手删除 `dist/`: + +```bash +bash scripts/clean-generated.sh +bash scripts/clean-generated.sh --dist +``` + +- 准备发版、打包、更新 Draft Release 之前: + +```bash +bash scripts/release-preflight.sh +``` + +- 准备在 GitHub 上创建或更新 Draft Release: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 想确认最近一次 GitHub Actions 产物是否完整: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +## 状态与审计 + +- 检查当前状态文档是否和代码版本一致: + +```bash +python3 scripts/check-doc-current-state.py +``` + +- 审计远端和本地历史分支: + +```bash +python3 scripts/audit-remote-branches.py +``` + +- 归档本地非 `main` 分支到 `archive/*` tag: + +```bash +python3 scripts/archive-local-branches.py +python3 scripts/archive-local-branches.py --apply +``` + +## 打包与发布 + +- 创建 Draft Release 前 dry-run: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 创建 Draft Release: + +```bash +bash scripts/create-draft-release.sh +``` + +- 用当前 `dist/` 覆盖已有 Draft Release 附件: + +```bash +bash scripts/update-draft-release-assets.sh +``` + +- 校验公开 Release 下载附件: + +```bash +bash scripts/verify-release-download.sh +``` + +- 单独打包公开 Skill ZIP: + +```bash +bash scripts/package-skills.sh +``` + +## Artifact 与产物校验 + +- 下载并校验最近一次成功的 `Release Preflight` workflow artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +bash scripts/verify-release-preflight-artifact.sh +``` + +- 校验本地 release 资产目录: + +```bash +bash scripts/verify-release-assets.sh +bash scripts/verify-release-assets.sh /path/to/release-assets +``` + +- 校验插件 ZIP: + +```bash +DIST_DIR=dist bash scripts/verify-dist.sh +``` + +- 校验 Skill ZIP: + +```bash +DIST_DIR=dist/skills bash scripts/verify-skill-dist.sh +``` + +## 汇总输出 + +- 打印插件 ZIP Markdown 表格: + +```bash +bash scripts/print-release-summary.sh +``` + +- 打印 Skill ZIP Markdown 表格: + +```bash +bash scripts/print-skill-release-summary.sh +``` + +- 生成 Release notes: + +```bash +bash scripts/generate-release-notes.sh +``` + +## 帮助 + +这些脚本现在都支持 `--help` 或 `-h`,包括: + +- `repo-hygiene.sh` +- `release-preflight.sh` +- `pre-release-check.sh` +- `check-skills.sh` +- `clean-generated.sh` +- `package-plugin.sh` +- `package-skills.sh` +- `sync-repo-layout.sh` +- `sync-package-v2.sh` +- `create-draft-release.sh` +- `update-draft-release-assets.sh` +- `generate-release-notes.sh` +- `write-dist-sha256.sh` +- `patch-p115strmhelper-mp-compat.sh` +- `verify-release-preflight-artifact.sh` +- `verify-ci-artifact.sh` +- `verify-release-download.sh` +- `verify-release-assets.sh` +- `verify-dist.sh` +- `verify-skill-dist.sh` +- `print-release-summary.sh` +- `print-skill-release-summary.sh` +- `check-doc-current-state.py` +- `audit-remote-branches.py` +- `archive-local-branches.py` diff --git a/plugins.v2/docs/PLUGIN_INSTALL.md b/plugins.v2/docs/PLUGIN_INSTALL.md new file mode 100644 index 0000000..5d42c27 --- /dev/null +++ b/plugins.v2/docs/PLUGIN_INSTALL.md @@ -0,0 +1,172 @@ +# 插件安装说明 + +这份文档只讲普通用户怎么安装、先装什么、装完从哪里开始。 + +如果你只是新手,不需要看打包、发布、维护命令。 + +--- + +## 先装哪两个 + +优先安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这两个就是当前主线: + +- `Agent影视助手`:飞书命令入口、外部智能体入口、盘搜、影巢、115、夸克、MP/PT 下载。 +- `AI识别增强`:MoviePilot 原生识别失败时,用 LLM 做一层兜底。 + +旧插件可以先不装。 + +--- + +## 插件仓库安装 + +在 MoviePilot 插件市场里添加自定义插件仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +然后在插件市场安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这是最推荐的安装方式。 + +--- + +## 本地 ZIP 安装 + +如果你拿到的是 Release 里的 ZIP 包,也可以在 MoviePilot 插件页本地上传安装。 + +普通用户只需要优先认这两个包: + +```text +AgentResourceOfficer-版本号.zip +AIRecognizerEnhancer-版本号.zip +``` + +其他旧插件包只用于兼容旧链路,新装一般不用优先安装。 + +--- + +## 装完 Agent影视助手后做什么 + +打开 `Agent影视助手` 设置页面,按你要用的功能填写: + +| 你想用的功能 | 需要配置 | +|---|---| +| 飞书命令入口 | 飞书应用的 `App ID` / `App Secret` | +| 盘搜搜索 | `盘搜 API 地址` | +| 影巢搜索 | `影巢 OpenAPI Key` | +| 115 转存 | `115 默认目录`,然后发 `115登录` 扫码 | +| 夸克转存 | 夸克 Cookie 或 CookieCloud | +| PT 下载 | 通常依赖 MoviePilot 原生下载器;MP 和 qB 不同机时可填 `PT 下载保存路径` | + +不用的功能可以先不填,插件会自动跳过。 + +--- + +## 不接智能体,只用飞书 + +如果你不使用外部智能体,只想把飞书当成命令入口: + +1. 在插件设置页配好飞书。 +2. 确认只保留一个飞书入口监听,避免旧飞书插件和新插件同时收消息。 +3. 直接在飞书里发命令。 + +常用命令: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 接外部智能体 + +如果你要让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体控制 MoviePilot,安装插件后还要让智能体安装 `agent-resource-officer skill / helper`。 + +最短路径: + +1. MoviePilot 安装并启用 `Agent影视助手`。 +2. 把 [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) 里的提示词发给你的智能体。 +3. 智能体按文档安装 skill,并填写: + +```text +ARO_BASE_URL=http://你的MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS、智能体在 Win / Mac,请看: + +[跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) + +### MCP 怎么办 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,也可以同时接: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +建议分工: + +- 查插件列表、下载器状态、站点状态、历史记录、工作流这类 MoviePilot 管理信息,可以优先用 MCP。 +- 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、Cookie 修复,继续优先用 `agent-resource-officer skill / helper`。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也继续优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +--- + +## AI识别增强怎么用 + +`AI识别增强` 不需要额外 Gateway。 + +它直接复用 MoviePilot 当前已经启用的 LLM 配置,在原生文件名识别失败时做兜底,然后把结果交回 MoviePilot 原生整理链。 + +详细说明见:[AI识别增强](../AIRecognizerEnhancer/README.md) + +--- + +## 旧插件还要不要装 + +新装一般不需要优先安装旧插件。 + +| 旧插件 | 用途 | 建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 旧影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 旧夸克独立转存 | 主能力已收进 Agent影视助手 | + +如果你是老环境迁移,可以暂时保留;如果是新装,先用 `Agent影视助手`。 + +--- + +## 维护者文档 + +如果你只是普通用户,到这里就够了。 + +如果你要打包、发布或维护仓库,再看: + +- [维护命令](./MAINTENANCE_COMMANDS.md) +- 发布检查:`docs/RELEASE_CHECKLIST.md` +- 打包说明:`docs/PACKAGING.md` diff --git a/plugins.v2/feishucommandbridgelong/README.md b/plugins.v2/feishucommandbridgelong/README.md new file mode 100644 index 0000000..66acbcf --- /dev/null +++ b/plugins.v2/feishucommandbridgelong/README.md @@ -0,0 +1,109 @@ +# FeishuCommandBridgeLong + +MoviePilot 的飞书长连接桥接插件。当前定位是兼容/备份入口;新用户更推荐直接使用 `Agent影视助手` 内置的飞书入口。 + +## 这版的定位 + +- 保留旧飞书桥接的轻量远程操作体验 +- 作为迁移期兼容插件继续可用 +- 新功能优先进入 `Agent影视助手`,避免飞书入口和资源执行逻辑继续分叉 +- 如果只想装一个插件完成云盘资源整合 + 飞书入口,优先安装并开启 `Agent影视助手` 的内置飞书入口 + +## 当前能力 + +- 飞书长连接接收 `im.message.receive_v1` +- 智能单入口:自动识别片名、115 链接、夸克链接、盘搜搜索 +- 影巢两段式搜索:先选影片,再看资源 +- `详情` / `审查` / `n 下一页` 会话续接 +- MoviePilot 原生搜索、下载、订阅、订阅搜索 +- `P115StrmHelper` 的手动整理、增量 STRM、全量 STRM +- 115 扫码登录与状态查询 +- 待继续 115 任务查看、继续、取消 + +## 执行后端 + +- `旧桥接直连` + 适合保持现有飞书操作习惯,速度快。 +- `自动优先新主线,失败回落旧桥接` + 优先委托 `Agent影视助手`,失败再退回旧桥接。 +- `仅走 Agent影视助手 新主线` + 调试和后续统一主干时更合适。 + +日常老环境可以继续用 `旧桥接直连`。新环境建议改用 `Agent影视助手` 内置飞书入口;如果暂时仍使用本插件,建议切到 `仅走 Agent影视助手 新主线`,让资源动作统一落到 Agent影视助手。 + +## 新推荐入口 + +`Agent影视助手` 已内置可选 `Feishu Channel`,开启后可以直接接收飞书长连接消息,并复用同一套 `assistant/route`、`assistant/pick`、115 扫码和待任务续跑能力。 + +迁移建议: + +1. 在本插件里先关闭 `启用插件`。 +2. 到 `Agent影视助手` 中打开 `启用内置飞书入口`。 +3. 迁移同一组飞书 `App ID / App Secret / Verification Token / 白名单`。 +4. 确认 `GET /api/v1/plugin/AgentResourceOfficer/feishu/health` 显示运行正常。 + +## 常用飞书命令 + +```txt +处理 流浪地球2 +影巢搜索 流浪地球2 +yc流浪地球2 +2流浪地球2 + +盘搜搜索 流浪地球2 +ps流浪地球2 +1流浪地球2 + +链接 https://115cdn.com/s/xxxx path=/待整理 +链接 https://pan.quark.cn/s/xxxx path=/飞书 + +选择 1 +选择 1 path=/最新动画 + +详情 +审查 +n 下一页 +``` + +## 115 相关命令 + +```txt +115登录 +115扫码 +检查115登录 +115登录状态 +115状态 +115帮助 +115任务 +继续115任务 +取消115任务 +``` + +- 当飞书桥接走 `Agent影视助手` 新主线时,`115登录` 会直接拉起扫码登录流程 +- 如果飞书回复里带了二维码图片,直接用 115 App 扫码即可 +- 某次 115 转存因为登录或会话问题失败后,可直接回复 `115任务` 查看当前待处理任务 +- 登录成功后回复 `检查115登录`,会自动尝试继续上一次待处理的 115 任务 + +## 智能单入口说明 + +- 发片名:进入影巢或盘搜搜索流程 +- 发 115 / 夸克链接:自动识别并转存,其中 115 链接会优先委托 `Agent影视助手`,确保失败后的待任务、扫码续跑和取消任务都在同一条会话链里 +- `path=/目录`、`位置=目录` 都支持 +- 裸链接也支持,不一定要带 `处理` 或 `链接` 前缀 + +## 智能体 API + +插件提供两条更适合外部智能体调用的入口: + +```txt +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/route +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick +``` + +`route` 负责分流,`pick` 负责继续选择。飞书消息入口和这两条 API 用的是同一套会话逻辑。 + +## 依赖 + +```txt +lark-oapi==1.5.3 +``` diff --git a/plugins.v2/feishucommandbridgelong/__init__.py b/plugins.v2/feishucommandbridgelong/__init__.py new file mode 100644 index 0000000..4e1b478 --- /dev/null +++ b/plugins.v2/feishucommandbridgelong/__init__.py @@ -0,0 +1,4111 @@ +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 diff --git a/plugins.v2/feishucommandbridgelong/requirements.txt b/plugins.v2/feishucommandbridgelong/requirements.txt new file mode 100644 index 0000000..db1f7ac --- /dev/null +++ b/plugins.v2/feishucommandbridgelong/requirements.txt @@ -0,0 +1 @@ +lark-oapi==1.5.3 diff --git a/plugins.v2/hdhiveopenapi/__init__.py b/plugins.v2/hdhiveopenapi/__init__.py new file mode 100644 index 0000000..f7627b2 --- /dev/null +++ b/plugins.v2/hdhiveopenapi/__init__.py @@ -0,0 +1,2012 @@ +import importlib +import json +from dataclasses import asdict, is_dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from zoneinfo import ZoneInfo + +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from fastapi import Request + +try: + from app.chain.media import MediaChain +except Exception: + MediaChain = None + +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase + +try: + from app.schemas import NotificationType +except Exception: + NotificationType = None + + +class HdhiveOpenApi(_PluginBase): + plugin_name = "影巢 OpenAPI" + plugin_desc = "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/hdhive.ico" + plugin_version = "0.3.0" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "hdhiveopenapi_" + plugin_order = 30 + auth_level = 1 + + _enabled = False + _notify = True + _onlyonce = False + _cron = "0 8 * * *" + _api_key = "" + _base_url = "https://hdhive.com" + _gambler_mode = False + _timeout = 30 + _history_days = 30 + + _search_media_type = "movie" + _search_tmdb_id = "" + _search_once = False + + _unlock_slug = "" + _unlock_once = False + _transfer_115_enabled = False + _transfer_115_path = "/待整理" + _auto_transfer_115_on_unlock = False + _transfer_115_once = False + + _share_action = "list" + _share_slug = "" + _share_page = 1 + _share_page_size = 10 + _share_payload = "" + _share_once = False + + _scheduler: Optional[BackgroundScheduler] = None + + _history_key = "checkin_history" + _account_key = "last_account" + _quota_key = "last_quota" + _usage_today_key = "last_usage_today" + _usage_key = "last_usage" + _weekly_quota_key = "last_weekly_quota" + _search_key = "last_resource_search" + _unlock_key = "last_resource_unlock" + _transfer_115_key = "last_transfer_115" + _check_resource_key = "last_check_resource" + _shares_list_key = "last_shares_list" + _share_detail_key = "last_share_detail" + _share_action_key = "last_share_action" + _ping_key = "last_ping" + _last_error_key = "last_error" + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _normalize_slug(value: Any) -> str: + return str(value or "").strip().replace("-", "") + + @staticmethod + def _normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def _media_type_text(value: Any) -> str: + if value is None: + return "" + raw = str(getattr(value, "value", value)).strip().lower() + mapping = { + "电影": "movie", + "movie": "movie", + "电视剧": "tv", + "tv": "tv", + } + return mapping.get(raw, raw) + + @staticmethod + def _coerce_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + def _tz_now(self) -> datetime: + try: + return datetime.now(ZoneInfo(settings.TZ)) + except Exception: + return datetime.now() + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "cron": self._cron, + "api_key": self._api_key, + "base_url": self._base_url, + "gambler_mode": self._gambler_mode, + "timeout": self._timeout, + "history_days": self._history_days, + "search_media_type": self._search_media_type, + "search_tmdb_id": self._search_tmdb_id, + "search_once": self._search_once, + "unlock_slug": self._unlock_slug, + "unlock_once": self._unlock_once, + "transfer_115_enabled": self._transfer_115_enabled, + "transfer_115_path": self._transfer_115_path, + "auto_transfer_115_on_unlock": self._auto_transfer_115_on_unlock, + "transfer_115_once": self._transfer_115_once, + "share_action": self._share_action, + "share_slug": self._share_slug, + "share_page": self._share_page, + "share_page_size": self._share_page_size, + "share_payload": self._share_payload, + "share_once": self._share_once, + } + if overrides: + config.update(overrides) + return config + + def _save_state(self, key: str, value: Any) -> None: + try: + self.save_data(key=key, value=value) + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 保存状态失败 {key}: {exc}") + + def _load_state(self, key: str, default: Any = None) -> Any: + try: + value = self.get_data(key) + return default if value is None else value + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 读取状态失败 {key}: {exc}") + return default + + def _mask_secret(self, value: str, prefix: int = 4, suffix: int = 4) -> str: + if not value: + return "" + if len(value) <= prefix + suffix: + return "*" * len(value) + return f"{value[:prefix]}{'*' * (len(value) - prefix - suffix)}{value[-suffix:]}" + + def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None: + self._save_state( + self._last_error_key, + { + "action": action, + "message": message, + "payload": payload or {}, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + + def _is_115_share_url(self, url: str) -> bool: + host = urlparse(url).netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + def _ensure_115_share_url(self, url: str, access_code: str = "") -> str: + clean_url = self._normalize_text(url) + if not clean_url: + return "" + access_code = self._normalize_text(access_code) + parsed = urlparse(clean_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if access_code and "password" not in query: + query["password"] = access_code + clean_url = urlunparse(parsed._replace(query=urlencode(query))) + return clean_url + + @staticmethod + def _jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool, list, dict)): + return value + if is_dataclass(value): + return asdict(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + if hasattr(value, "__dict__"): + return {k: v for k, v in vars(value).items() if not k.startswith("_")} + return str(value) + + def _get_p115_share_helper(self) -> Tuple[Optional[Any], Optional[str]]: + try: + service_module = importlib.import_module("app.plugins.p115strmhelper.service") + except Exception as exc: + return None, f"P115StrmHelper 未安装或无法导入: {exc}" + + servicer = getattr(service_module, "servicer", None) + if not servicer: + return None, "P115StrmHelper 未初始化" + if not getattr(servicer, "client", None): + return None, "P115StrmHelper 未登录 115 或客户端不可用" + helper = getattr(servicer, "sharetransferhelper", None) + if not helper: + return None, "P115StrmHelper 分享转存模块不可用" + return helper, None + + def _base_headers(self) -> Dict[str, str]: + return { + "X-API-Key": self._api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot"), + } + + def _api_url(self, path: str) -> str: + return f"{self._base_url.rstrip('/')}{path}" + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any], str, int]: + if not self._api_key: + return False, {}, "未配置影巢 API Key", 400 + + try: + response = requests.request( + method=method.upper(), + url=self._api_url(path), + headers=self._base_headers(), + params=params, + json=payload if payload is not None else None, + timeout=timeout or self._timeout, + proxies=getattr(settings, "PROXY", None), + ) + except Exception as exc: + return False, {}, f"请求异常: {exc}", 0 + + try: + result = response.json() + except Exception: + result = { + "success": False, + "message": response.text[:300] if response.text else f"HTTP {response.status_code}", + "description": "接口未返回有效 JSON", + } + + if response.ok and isinstance(result, dict) and result.get("success", True): + return True, result, "", response.status_code + + message = "" + if isinstance(result, dict): + message = ( + result.get("description") + or result.get("message") + or result.get("code") + or f"HTTP {response.status_code}" + ) + if not message: + message = f"HTTP {response.status_code}" + return False, result if isinstance(result, dict) else {}, message, response.status_code + + def _notify_message(self, title: str, text: str) -> None: + if not self._notify: + return + if not hasattr(self, "post_message"): + return + try: + if NotificationType is not None: + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + else: + self.post_message(title=title, text=text) + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 发送通知失败: {exc}") + + def _append_history(self, record: Dict[str, Any]) -> None: + history = self._load_state(self._history_key, default=[]) or [] + history.append(record) + now = self._tz_now() + valid_history: List[Dict[str, Any]] = [] + for item in history: + date_text = str(item.get("time") or item.get("date") or "").strip() + if not date_text: + continue + try: + item_dt = datetime.strptime(date_text, "%Y-%m-%d %H:%M:%S") + except Exception: + valid_history.append(item) + continue + if (now.replace(tzinfo=None) - item_dt).days < self._history_days: + valid_history.append(item) + self._save_state(self._history_key, valid_history[-100:]) + + def _refresh_snapshots(self, silent: bool = False) -> None: + ok, data, message = self.ping(remember=True) + if not ok and not silent: + self._remember_error("ping", message, data) + return + self.fetch_me(remember=True) + self.fetch_quota(remember=True) + self.fetch_usage_today(remember=True) + self.fetch_weekly_free_quota(remember=True) + + def init_plugin(self, config: dict = None): + self.stop_service() + + config = config or {} + self._enabled = bool(config.get("enabled")) + self._notify = bool(config.get("notify", True)) + self._onlyonce = bool(config.get("onlyonce")) + self._cron = self._normalize_text(config.get("cron")) or "0 8 * * *" + self._api_key = self._normalize_text(config.get("api_key")) + self._base_url = (self._normalize_text(config.get("base_url")) or "https://hdhive.com").rstrip("/") + self._gambler_mode = bool(config.get("gambler_mode")) + self._timeout = self._safe_int(config.get("timeout"), 30) + self._history_days = self._safe_int(config.get("history_days"), 30) + + self._search_media_type = self._normalize_text(config.get("search_media_type")) or "movie" + if self._search_media_type not in {"movie", "tv"}: + self._search_media_type = "movie" + self._search_tmdb_id = self._normalize_text(config.get("search_tmdb_id")) + self._search_once = bool(config.get("search_once")) + + self._unlock_slug = self._normalize_slug(config.get("unlock_slug")) + self._unlock_once = bool(config.get("unlock_once")) + self._transfer_115_enabled = bool(config.get("transfer_115_enabled")) + self._transfer_115_path = self._normalize_pan_path(config.get("transfer_115_path")) or "/待整理" + self._auto_transfer_115_on_unlock = bool(config.get("auto_transfer_115_on_unlock")) + self._transfer_115_once = bool(config.get("transfer_115_once")) + + self._share_action = self._normalize_text(config.get("share_action")) or "list" + if self._share_action not in {"list", "detail", "create", "update", "delete"}: + self._share_action = "list" + self._share_slug = self._normalize_slug(config.get("share_slug")) + self._share_page = max(1, self._safe_int(config.get("share_page"), 1)) + self._share_page_size = min(100, max(1, self._safe_int(config.get("share_page_size"), 10))) + self._share_payload = str(config.get("share_payload") or "").strip() + self._share_once = bool(config.get("share_once")) + + if self._enabled and self._api_key: + self._refresh_snapshots(silent=True) + + scheduled_jobs: List[Tuple[str, Any]] = [] + reset_config: Dict[str, Any] = {} + if self._onlyonce: + scheduled_jobs.append(("影巢 OpenAPI 立即签到", self._run_checkin_once)) + reset_config["onlyonce"] = False + self._onlyonce = False + if self._search_once: + scheduled_jobs.append(("影巢 OpenAPI 资源查询", self._run_search_once)) + reset_config["search_once"] = False + self._search_once = False + if self._unlock_once: + scheduled_jobs.append(("影巢 OpenAPI 资源解锁", self._run_unlock_once)) + reset_config["unlock_once"] = False + self._unlock_once = False + if self._transfer_115_once: + scheduled_jobs.append(("影巢 OpenAPI 转存到115", self._run_transfer_115_once)) + reset_config["transfer_115_once"] = False + self._transfer_115_once = False + if self._share_once: + scheduled_jobs.append(("影巢 OpenAPI 分享操作", self._run_share_once)) + reset_config["share_once"] = False + self._share_once = False + + if scheduled_jobs: + self._scheduler = BackgroundScheduler(timezone=getattr(settings, "TZ", "Asia/Shanghai")) + base_time = self._tz_now() + for index, (job_name, func) in enumerate(scheduled_jobs): + self._scheduler.add_job( + func=func, + trigger="date", + run_date=base_time + timedelta(seconds=3 + index), + name=job_name, + ) + self._scheduler.start() + + if reset_config: + self.update_config(self._build_config(reset_config)) + + def get_state(self) -> bool: + return self._enabled and bool(self._api_key) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled or not self._api_key or not self._cron: + return [] + return [ + { + "id": "hdhiveopenapi_checkin", + "name": "影巢 OpenAPI 每日签到", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self._scheduled_checkin, + "kwargs": {}, + } + ] + + def stop_service(self): + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown(wait=False) + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 停止调度器失败: {exc}") + finally: + self._scheduler = None + + def ping(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/ping") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._ping_key, result) + if not ok: + self._remember_error("ping", message, payload) + return ok, result, message + + def fetch_me(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/me") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._account_key, result) + if not ok: + self._remember_error("me", message, payload) + return ok, result, message + + def fetch_quota(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/quota") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._quota_key, result) + if not ok: + self._remember_error("quota", message, payload) + return ok, result, message + + def fetch_usage(self, start_date: str = "", end_date: str = "", remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + params: Dict[str, Any] = {} + if start_date: + params["start_date"] = start_date + if end_date: + params["end_date"] = end_date + ok, payload, message, status_code = self._request("GET", "/api/open/usage", params=params or None) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": params, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._usage_key, result) + if not ok: + self._remember_error("usage", message, payload) + return ok, result, message + + def fetch_usage_today(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/usage/today") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._usage_today_key, result) + if not ok: + self._remember_error("usage_today", message, payload) + return ok, result, message + + def fetch_weekly_free_quota(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/vip/weekly-free-quota") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._weekly_quota_key, result) + if not ok: + self._remember_error("weekly_free_quota", message, payload) + return ok, result, message + + def perform_checkin( + self, + *, + is_gambler: Optional[bool] = None, + remember: bool = True, + trigger: str = "手动", + ) -> Tuple[bool, Dict[str, Any], str]: + gambler_mode = self._gambler_mode if is_gambler is None else bool(is_gambler) + payload = {"is_gambler": gambler_mode} if gambler_mode else None + ok, result_payload, message, status_code = self._request("POST", "/api/open/checkin", payload=payload) + data = result_payload.get("data") if isinstance(result_payload, dict) else {} + checked_in = bool((data or {}).get("checked_in")) if ok else False + status_text = "签到成功" if checked_in else "今日已签到" + if not ok: + status_text = "签到失败" + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "trigger": trigger, + "is_gambler": gambler_mode, + "status": status_text, + "message": (data or {}).get("message") or result_payload.get("message") or message, + "data": data or {}, + } + if remember: + self._append_history(result) + if ok: + self.fetch_me(remember=True) + self.fetch_weekly_free_quota(remember=True) + else: + self._remember_error("checkin", message, result_payload) + + if ok: + title = "【影巢 OpenAPI 签到】" + text = ( + f"时间:{result['time']}\n" + f"方式:{trigger}\n" + f"模式:{'赌狗签到' if gambler_mode else '普通签到'}\n" + f"结果:{result['status']}\n" + f"详情:{result['message']}" + ) + self._notify_message(title, text) + return ok, result, message + + def search_resources(self, media_type: str, tmdb_id: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + media_type = (media_type or "").strip().lower() + tmdb_id = self._normalize_text(tmdb_id) + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" + if not tmdb_id: + return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" + + ok, payload, message, status_code = self._request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"media_type": media_type, "tmdb_id": tmdb_id}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._search_key, result) + if not ok: + self._remember_error("resources_search", message, payload) + return ok, result, message + + def _resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: + pan = str(item.get("pan_type") or "").lower() + points = item.get("unlock_points") + try: + points_value = int(points) if points is not None and str(points) != "" else 0 + except Exception: + points_value = 9999 + validate = str(item.get("validate_status") or "").lower() + resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] + sources = [str(v) for v in (item.get("source") or [])] + pan_rank = 0 if pan == "115" else 1 + points_rank = 0 if points_value <= 0 else 1 + validate_rank = 0 if validate in {"valid", ""} else 1 + resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 + source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 + return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) + + async def search_resources_by_keyword( + self, + keyword: str, + media_type: str = "movie", + year: str = "", + candidate_limit: int = 5, + result_limit: int = 10, + remember: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + keyword = self._normalize_text(keyword) + media_type = self._normalize_text(media_type).lower() or "movie" + year = self._normalize_text(year) + candidate_limit = min(10, max(1, self._safe_int(candidate_limit, 5))) + result_limit = min(50, max(1, self._safe_int(result_limit, 10))) + + if not keyword: + return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie 或 tv" + if MediaChain is None: + return False, {"message": "MoviePilot MediaChain 不可用", "query": {"keyword": keyword, "media_type": media_type}}, "MoviePilot MediaChain 不可用" + + try: + _, medias = await MediaChain().async_search(title=keyword) + except Exception as exc: + return False, {"message": f"TMDB 解析失败: {exc}", "query": {"keyword": keyword, "media_type": media_type}}, f"TMDB 解析失败: {exc}" + + candidates: List[Dict[str, Any]] = [] + for media in medias or []: + item_type = self._media_type_text(getattr(media, "type", "")) + item_year = self._normalize_text(getattr(media, "year", "")) + if media_type and item_type and item_type != media_type: + continue + if year and item_year and item_year != year: + continue + tmdb_id = getattr(media, "tmdb_id", None) + if not tmdb_id: + continue + candidates.append( + { + "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", + "year": item_year, + "media_type": item_type or media_type, + "tmdb_id": tmdb_id, + "poster_path": getattr(media, "poster_path", "") or "", + } + ) + if len(candidates) >= candidate_limit: + break + + if not candidates: + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 404, + "message": "未找到可用于影巢搜索的 TMDB 候选", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": [], + "data": [], + "meta": {"total": 0}, + } + if remember: + self._save_state(self._search_key, result) + return False, result, result["message"] + + merged_items: List[Dict[str, Any]] = [] + seen_slugs: set[str] = set() + last_status = 200 + + for candidate in candidates: + ok, payload, message = self.search_resources( + media_type=candidate["media_type"] or media_type, + tmdb_id=str(candidate["tmdb_id"]), + remember=False, + ) + last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status + if not ok: + continue + for resource in payload.get("data") or []: + slug = self._normalize_slug(resource.get("slug")) + if not slug or slug in seen_slugs: + continue + seen_slugs.add(slug) + annotated = dict(resource) + annotated["matched_tmdb_id"] = candidate["tmdb_id"] + annotated["matched_title"] = candidate["title"] + annotated["matched_year"] = candidate["year"] + merged_items.append(annotated) + + merged_items.sort(key=self._resource_sort_key) + merged_items = merged_items[:result_limit] + + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(merged_items), + "status_code": last_status, + "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "data": merged_items, + "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, + } + if remember: + self._save_state(self._search_key, result) + if not merged_items: + self._remember_error("resources_search_keyword", result["message"], result) + return bool(merged_items), result, result["message"] + + def unlock_resource( + self, + slug: str, + remember: bool = True, + *, + transfer_115: bool = False, + transfer_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + + ok, payload, message, status_code = self._request( + "POST", + "/api/open/resources/unlock", + payload={"slug": slug}, + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + should_transfer = bool(ok and transfer_115) + if should_transfer: + unlock_data = result.get("data") or {} + transfer_ok, transfer_result, transfer_message = self.transfer_115_share( + url=unlock_data.get("full_url") or unlock_data.get("url") or "", + access_code=unlock_data.get("access_code") or "", + path=transfer_path or self._transfer_115_path, + remember=True, + trigger="解锁后自动转存", + ) + result["transfer_115"] = transfer_result + if not transfer_ok: + result["transfer_115_message"] = transfer_message + if remember: + self._save_state(self._unlock_key, result) + if ok: + self.fetch_me(remember=True) + else: + self._remember_error("resources_unlock", message, payload) + return ok, result, message + + def transfer_115_share( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + remember: bool = True, + trigger: str = "手动转存", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self._normalize_pan_path(path) or self._transfer_115_path or "/待整理" + unlock_snapshot = self._load_state(self._unlock_key, {}) or {} + unlock_data = unlock_snapshot.get("data") or {} + share_url = self._ensure_115_share_url( + url or unlock_data.get("full_url") or unlock_data.get("url") or "", + access_code or unlock_data.get("access_code") or "", + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的解锁链接" + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + if not self._is_115_share_url(share_url): + result["message"] = "当前解锁结果不是 115 分享链接,无法直接转存到 115" + if remember: + self._save_state(self._transfer_115_key, result) + return False, result, result["message"] + + helper, helper_error = self._get_p115_share_helper() + if helper_error or not helper: + result["message"] = helper_error or "P115StrmHelper 不可用" + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + + try: + transfer_result = helper.add_share_115( + share_url, + notify=False, + pan_path=transfer_path, + ) + except Exception as exc: + result["message"] = f"调用 P115StrmHelper 转存失败: {exc}" + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + + if not transfer_result or not transfer_result[0]: + error_message = "" + if isinstance(transfer_result, tuple): + if len(transfer_result) > 2: + error_message = self._normalize_text(transfer_result[2]) + elif len(transfer_result) > 1: + error_message = self._normalize_text(transfer_result[1]) + result["message"] = error_message or "115 转存失败" + result["data"] = {"raw": self._jsonable(transfer_result)} + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + + media_info = transfer_result[1] if len(transfer_result) > 1 else None + save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path + parent_id = transfer_result[3] if len(transfer_result) > 3 else None + result.update( + { + "ok": True, + "message": "115 转存成功", + "data": { + "media_info": self._jsonable(media_info), + "save_parent": save_parent, + "parent_id": parent_id, + }, + } + ) + if remember: + self._save_state(self._transfer_115_key, result) + return True, result, result["message"] + + def check_resource(self, url: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + clean_url = self._normalize_text(url) + if not clean_url: + return False, {"message": "url 不能为空", "url": ""}, "url 不能为空" + + ok, payload, message, status_code = self._request( + "POST", + "/api/open/check/resource", + payload={"url": clean_url}, + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "url": clean_url, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._check_resource_key, result) + if not ok: + self._remember_error("check_resource", message, payload) + return ok, result, message + + def list_shares(self, page: int = 1, page_size: int = 20, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + page = max(1, self._safe_int(page, 1)) + page_size = min(100, max(1, self._safe_int(page_size, 20))) + ok, payload, message, status_code = self._request( + "GET", + "/api/open/shares", + params={"page": page, "page_size": page_size}, + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"page": page, "page_size": page_size}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._shares_list_key, result) + if not ok: + self._remember_error("shares_list", message, payload) + return ok, result, message + + def get_share_detail(self, slug: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + + ok, payload, message, status_code = self._request("GET", f"/api/open/shares/{slug}") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._share_detail_key, result) + if not ok: + self._remember_error("shares_detail", message, payload) + return ok, result, message + + def create_share(self, share_payload: Dict[str, Any], remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("POST", "/api/open/shares", payload=share_payload) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "action": "create", + "message": payload.get("message") if ok else message, + "payload": share_payload, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._share_action_key, result) + if ok: + self.fetch_me(remember=True) + else: + self._remember_error("shares_create", message, payload) + return ok, result, message + + def update_share(self, slug: str, share_payload: Dict[str, Any], remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self._request("PATCH", f"/api/open/shares/{slug}", payload=share_payload) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "action": "update", + "slug": slug, + "message": payload.get("message") if ok else message, + "payload": share_payload, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._share_action_key, result) + if not ok: + self._remember_error("shares_update", message, payload) + return ok, result, message + + def delete_share(self, slug: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self._request("DELETE", f"/api/open/shares/{slug}") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "action": "delete", + "slug": slug, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else None, + } + if remember: + self._save_state(self._share_action_key, result) + if ok: + self.fetch_me(remember=True) + else: + self._remember_error("shares_delete", message, payload) + return ok, result, message + + def _scheduled_checkin(self) -> None: + self.perform_checkin(trigger="定时任务", remember=True) + + def _run_checkin_once(self) -> None: + self.perform_checkin(trigger="配置页立即运行", remember=True) + + def _run_search_once(self) -> None: + ok, result, message = self.search_resources(self._search_media_type, self._search_tmdb_id, remember=True) + if ok: + logger.info( + "[HdhiveOpenApi] 一次性资源查询完成: %s/%s, 返回 %s 条", + self._search_media_type, + self._search_tmdb_id, + len(result.get("data") or []), + ) + else: + logger.warning("[HdhiveOpenApi] 一次性资源查询失败: %s", message) + + def _run_unlock_once(self) -> None: + ok, _, message = self.unlock_resource( + self._unlock_slug, + remember=True, + transfer_115=self._auto_transfer_115_on_unlock, + transfer_path=self._transfer_115_path, + ) + if ok: + logger.info("[HdhiveOpenApi] 一次性资源解锁完成: %s", self._unlock_slug) + else: + logger.warning("[HdhiveOpenApi] 一次性资源解锁失败: %s", message) + + def _run_transfer_115_once(self) -> None: + ok, _, message = self.transfer_115_share( + path=self._transfer_115_path, + remember=True, + trigger="配置页立即转存", + ) + if ok: + logger.info("[HdhiveOpenApi] 一次性 115 转存完成: %s", self._transfer_115_path) + else: + logger.warning("[HdhiveOpenApi] 一次性 115 转存失败: %s", message) + + def _parse_share_payload(self) -> Tuple[bool, Dict[str, Any], str]: + if not self._share_payload.strip(): + return True, {}, "" + try: + payload = json.loads(self._share_payload) + except Exception as exc: + return False, {}, f"分享请求 JSON 解析失败: {exc}" + if not isinstance(payload, dict): + return False, {}, "分享请求 JSON 必须是对象" + return True, payload, "" + + def _run_share_once(self) -> None: + ok, payload, message = self._parse_share_payload() + if not ok: + self._remember_error("share_payload", message, {}) + logger.warning("[HdhiveOpenApi] 一次性分享操作失败: %s", message) + return + + action = self._share_action + if action == "list": + self.list_shares(page=self._share_page, page_size=self._share_page_size, remember=True) + return + if action == "detail": + self.get_share_detail(self._share_slug, remember=True) + return + if action == "create": + self.create_share(payload, remember=True) + return + if action == "update": + self.update_share(self._share_slug, payload, remember=True) + return + if action == "delete": + self.delete_share(self._share_slug, remember=True) + return + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "notify", "label": "签到发送通知"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "gambler_mode", "label": "默认赌狗签到"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "onlyonce", "label": "立即签到一次"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "api_key", + "label": "影巢 Open API Key", + "placeholder": "请输入影巢 API Key", + "type": "password", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "base_url", + "label": "影巢站点地址", + "placeholder": "https://hdhive.com", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VCronField", + "props": { + "model": "cron", + "label": "每日签到周期", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "timeout", + "label": "接口超时(秒)", + "type": "number", + "placeholder": "30", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "search_media_type", + "label": "资源查询类型", + "items": [ + {"title": "电影 movie", "value": "movie"}, + {"title": "剧集 tv", "value": "tv"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "search_tmdb_id", + "label": "查询 TMDB ID(可留空)", + "placeholder": "例如 550;留空时可直接用 API keyword 搜索", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "search_once", "label": "立即查询资源"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "unlock_slug", + "label": "解锁资源 slug", + "placeholder": "请输入 32 位资源 slug", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "unlock_once", "label": "立即解锁资源"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_115_enabled", "label": "启用 115 转存"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "transfer_115_path", + "label": "115 固定目录", + "placeholder": "/待整理/影巢", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "auto_transfer_115_on_unlock", "label": "解锁后自动转存"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_115_once", "label": "转存最近一次 115 链接"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "share_action", + "label": "分享操作", + "items": [ + {"title": "list 列表", "value": "list"}, + {"title": "detail 详情", "value": "detail"}, + {"title": "create 创建", "value": "create"}, + {"title": "update 更新", "value": "update"}, + {"title": "delete 删除", "value": "delete"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_slug", + "label": "分享 slug", + "placeholder": "detail/update/delete 时填写", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_page", + "label": "列表页码", + "type": "number", + "placeholder": "1", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_page_size", + "label": "每页条数", + "type": "number", + "placeholder": "10", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + {"component": "VSwitch", "props": {"model": "share_once", "label": "立即执行分享操作"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "share_payload", + "label": "分享请求 JSON", + "rows": 8, + "placeholder": "{\"tmdb_id\":\"550\",\"media_type\":\"movie\",\"title\":\"Fight Club 4K REMUX\",\"url\":\"https://pan.example.com/s/abc123\",\"access_code\":\"x1y2\",\"unlock_points\":10}", + "hint": "create/update 时填写 JSON。list/detail/delete 可留空。", + "persistent-hint": True, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "核心能力已覆盖:用户信息、每日签到、资源查询与解锁、分享管理、用量与配额。\\n" + "新增:支持把解锁出来的 115 分享链接直接转存到固定目录。\\n" + "注意:只有解锁结果本身是 115 分享链接时才能直接转存,天翼/夸克/阿里等链接不会自动塞进 115。\\n" + "页面内的一次性操作适合联调;真正对外集成时,建议直接调用插件 API。\\n" + "插件 API 示例:\\n" + "GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&tmdb_id=550\\n" + "GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&keyword=超级马里奥兄弟大电影\\n" + "POST /api/v1/plugin/HdhiveOpenApi/resources/unlock\\n" + "POST /api/v1/plugin/HdhiveOpenApi/transfer/115\\n" + "GET /api/v1/plugin/HdhiveOpenApi/shares\\n" + "POST /api/v1/plugin/HdhiveOpenApi/shares/create" + ), + }, + } + ], + } + ], + }, + ], + } + ], self._build_config() + + def _build_key_value_card(self, title: str, rows: List[Tuple[str, Any]], md: int = 6) -> dict: + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": title}, + { + "component": "VCardText", + "content": [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"{label}:{value if value not in (None, '') else '—'}", + } + for label, value in rows + ], + }, + ], + } + ], + } + + def _build_resource_rows(self, items: List[Dict[str, Any]]) -> List[dict]: + rows: List[dict] = [] + for item in items[:20]: + rows.append( + { + "component": "tr", + "content": [ + {"component": "td", "text": item.get("slug", "")}, + {"component": "td", "text": item.get("title", "—")}, + {"component": "td", "text": item.get("share_size", "—")}, + {"component": "td", "text": "/".join(item.get("source") or []) or "—"}, + {"component": "td", "text": "/".join(item.get("video_resolution") or []) or "—"}, + {"component": "td", "text": str(item.get("unlock_points", "0"))}, + {"component": "td", "text": "是" if item.get("is_unlocked") else "否"}, + {"component": "td", "text": "是" if item.get("is_official") else "否"}, + ], + } + ) + return rows + + def _build_share_rows(self, items: List[Dict[str, Any]]) -> List[dict]: + rows: List[dict] = [] + for item in items[:20]: + rows.append( + { + "component": "tr", + "content": [ + {"component": "td", "text": item.get("slug", "")}, + {"component": "td", "text": item.get("title", "—")}, + {"component": "td", "text": item.get("share_size", "—")}, + {"component": "td", "text": str(item.get("unlock_points", "0"))}, + {"component": "td", "text": str(item.get("unlocked_users_count", "0"))}, + {"component": "td", "text": item.get("created_at", "—")}, + ], + } + ) + return rows + + def get_page(self) -> List[dict]: + ping = self._load_state(self._ping_key, {}) or {} + account = self._load_state(self._account_key, {}) or {} + quota = self._load_state(self._quota_key, {}) or {} + usage_today = self._load_state(self._usage_today_key, {}) or {} + weekly_quota = self._load_state(self._weekly_quota_key, {}) or {} + search_result = self._load_state(self._search_key, {}) or {} + unlock_result = self._load_state(self._unlock_key, {}) or {} + transfer_115_result = self._load_state(self._transfer_115_key, {}) or {} + shares_list = self._load_state(self._shares_list_key, {}) or {} + share_detail = self._load_state(self._share_detail_key, {}) or {} + share_action = self._load_state(self._share_action_key, {}) or {} + last_error = self._load_state(self._last_error_key, {}) or {} + history = list(reversed(self._load_state(self._history_key, []) or []))[:20] + + user = (account.get("data") or {}) if isinstance(account, dict) else {} + user_meta = (user.get("user_meta") or {}) if isinstance(user, dict) else {} + quota_data = quota.get("data") or {} + usage_today_data = usage_today.get("data") or {} + weekly_data = weekly_quota.get("data") or {} + resource_items = search_result.get("data") or [] + share_items = shares_list.get("data") or [] + unlock_data = unlock_result.get("data") or {} + transfer_115_data = transfer_115_result.get("data") or {} + share_detail_data = share_detail.get("data") or {} + share_action_data = share_action.get("data") or {} + + history_rows = [ + { + "component": "tr", + "content": [ + {"component": "td", "text": item.get("time", "")}, + {"component": "td", "text": item.get("trigger", "—")}, + {"component": "td", "text": "赌狗" if item.get("is_gambler") else "普通"}, + {"component": "td", "text": item.get("status", "—")}, + {"component": "td", "text": item.get("message", "—")}, + ], + } + for item in history + ] + + page_content: List[dict] = [ + { + "component": "VContainer", + "content": [ + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "连接状态", + [ + ("启用", "是" if self._enabled else "否"), + ("API Key", self._mask_secret(self._api_key) or "未填写"), + ("Base URL", self._base_url), + ("最近 Ping", ping.get("time", "—")), + ("Ping 状态", "成功" if ping.get("ok") else (ping.get("message") or "未执行")), + ], + ), + self._build_key_value_card( + "用户信息", + [ + ("昵称", user.get("nickname", "—")), + ("用户名", user.get("username", "—")), + ("积分", user_meta.get("points", "—")), + ("VIP", "是" if user.get("is_vip") else "否"), + ("VIP 到期", user.get("vip_expiration_date", "—")), + ("累计签到", user_meta.get("signin_days_total", "—")), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "配额与今日用量", + [ + ("配额重置", quota_data.get("daily_reset", "—")), + ("接口上限", quota_data.get("endpoint_limit", "—")), + ("剩余配额", quota_data.get("endpoint_remaining", "—")), + ("今日总调用", usage_today_data.get("total_calls", "—")), + ("今日成功", usage_today_data.get("success_calls", "—")), + ("平均耗时(ms)", usage_today_data.get("avg_latency", "—")), + ], + ), + self._build_key_value_card( + "每周免费解锁额度", + [ + ("永久 VIP", "是" if weekly_data.get("is_forever_vip") else "否"), + ("周额度", weekly_data.get("limit", "—")), + ("本周已用", weekly_data.get("used", "—")), + ("剩余额度", weekly_data.get("remaining", "—")), + ("无限额度", "是" if weekly_data.get("unlimited") else "否"), + ("累积额度", weekly_data.get("bonus_quota", "—")), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": "签到历史"}, + { + "component": "VCardText", + "content": [ + { + "component": "VTable", + "props": {"density": "compact", "hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "时间"}, + {"component": "th", "text": "触发方式"}, + {"component": "th", "text": "模式"}, + {"component": "th", "text": "状态"}, + {"component": "th", "text": "说明"}, + ], + } + ], + }, + { + "component": "tbody", + "content": history_rows + or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 5}, "text": "暂无签到记录"}]}], + }, + ], + } + ], + }, + ], + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": "最近一次资源查询"}, + { + "component": "VCardSubtitle", + "text": ( + f"{search_result.get('time', '未执行')} | " + f"{(search_result.get('query') or {}).get('media_type', '—')} / " + f"{(search_result.get('query') or {}).get('tmdb_id', '—')} | " + f"{search_result.get('message', '—')}" + ), + }, + { + "component": "VCardText", + "content": [ + { + "component": "VTable", + "props": {"density": "compact", "hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "slug"}, + {"component": "th", "text": "标题"}, + {"component": "th", "text": "大小"}, + {"component": "th", "text": "片源"}, + {"component": "th", "text": "分辨率"}, + {"component": "th", "text": "解锁积分"}, + {"component": "th", "text": "已解锁"}, + {"component": "th", "text": "官方"}, + ], + } + ], + }, + { + "component": "tbody", + "content": self._build_resource_rows(resource_items) + or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 8}, "text": "暂无资源查询结果"}]}], + }, + ], + } + ], + }, + ], + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "最近一次资源解锁", + [ + ("时间", unlock_result.get("time", "—")), + ("slug", unlock_result.get("slug", "—")), + ("结果", unlock_result.get("message", "—")), + ("链接", unlock_data.get("url", "—")), + ("提取码", unlock_data.get("access_code", "—")), + ("完整链接", unlock_data.get("full_url", "—")), + ], + ), + self._build_key_value_card( + "最近一次 115 转存", + [ + ("时间", transfer_115_result.get("time", "—")), + ("触发方式", transfer_115_result.get("trigger", "—")), + ("结果", transfer_115_result.get("message", "—")), + ("目录", transfer_115_result.get("path", "—")), + ("保存位置", transfer_115_data.get("save_parent", "—")), + ("父目录 ID", transfer_115_data.get("parent_id", "—")), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "最近一次分享详情/操作", + [ + ("详情时间", share_detail.get("time", "—")), + ("详情标题", share_detail_data.get("title", "—")), + ("详情媒体", ((share_detail_data.get("media") or {}).get("title") if isinstance(share_detail_data.get("media"), dict) else "—") or "—"), + ("操作时间", share_action.get("time", "—")), + ("操作类型", share_action.get("action", "—")), + ("操作结果", share_action.get("message", "—")), + ("操作标题", share_action_data.get("title", "—") if isinstance(share_action_data, dict) else "—"), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": "最近一次分享列表"}, + { + "component": "VCardSubtitle", + "text": ( + f"{shares_list.get('time', '未执行')} | " + f"page={(shares_list.get('query') or {}).get('page', '—')} | " + f"size={(shares_list.get('query') or {}).get('page_size', '—')} | " + f"{shares_list.get('message', '—')}" + ), + }, + { + "component": "VCardText", + "content": [ + { + "component": "VTable", + "props": {"density": "compact", "hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "slug"}, + {"component": "th", "text": "标题"}, + {"component": "th", "text": "大小"}, + {"component": "th", "text": "解锁积分"}, + {"component": "th", "text": "已解锁人数"}, + {"component": "th", "text": "创建时间"}, + ], + } + ], + }, + { + "component": "tbody", + "content": self._build_share_rows(share_items) + or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 6}, "text": "暂无分享列表结果"}]}], + }, + ], + } + ], + }, + ], + } + ], + } + ], + }, + ], + } + ] + + if last_error: + page_content[0]["content"].append( + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "text": f"最近一次错误:{last_error.get('action', '—')} | {last_error.get('time', '—')} | {last_error.get('message', '—')}", + }, + } + ], + } + ], + } + ) + + return page_content + + def get_api(self) -> List[Dict[str, Any]]: + return [ + {"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查插件与 API Key 状态", "auth": "bear"}, + {"path": "/account", "endpoint": self.api_account, "methods": ["GET"], "summary": "获取当前用户信息", "auth": "bear"}, + {"path": "/checkin", "endpoint": self.api_checkin, "methods": ["POST"], "summary": "执行普通或赌狗签到", "auth": "bear"}, + {"path": "/quota", "endpoint": self.api_quota, "methods": ["GET"], "summary": "获取配额信息", "auth": "bear"}, + {"path": "/usage", "endpoint": self.api_usage, "methods": ["GET"], "summary": "获取用量统计", "auth": "bear"}, + {"path": "/usage/today", "endpoint": self.api_usage_today, "methods": ["GET"], "summary": "获取今日用量", "auth": "bear"}, + {"path": "/vip/weekly-free-quota", "endpoint": self.api_weekly_quota, "methods": ["GET"], "summary": "获取每周免费解锁额度", "auth": "bear"}, + {"path": "/resources/search", "endpoint": self.api_search_resources, "methods": ["GET"], "summary": "按 TMDB ID 或关键词搜索资源", "auth": "bear"}, + {"path": "/resources/unlock", "endpoint": self.api_unlock_resource, "methods": ["POST"], "summary": "按 slug 解锁资源", "auth": "bear"}, + {"path": "/transfer/115", "endpoint": self.api_transfer_115, "methods": ["POST"], "summary": "把 115 分享链接转存到固定目录", "auth": "bear"}, + {"path": "/resource/check", "endpoint": self.api_check_resource, "methods": ["POST"], "summary": "检测资源链接类型", "auth": "bear"}, + {"path": "/shares", "endpoint": self.api_list_shares, "methods": ["GET"], "summary": "获取我的分享列表", "auth": "bear"}, + {"path": "/shares/detail", "endpoint": self.api_share_detail, "methods": ["GET"], "summary": "获取分享详情", "auth": "bear"}, + {"path": "/shares/create", "endpoint": self.api_share_create, "methods": ["POST"], "summary": "创建分享", "auth": "bear"}, + {"path": "/shares/update", "endpoint": self.api_share_update, "methods": ["POST"], "summary": "更新分享", "auth": "bear"}, + {"path": "/shares/delete", "endpoint": self.api_share_delete, "methods": ["POST"], "summary": "删除分享", "auth": "bear"}, + ] + + async def api_health(self) -> Dict[str, Any]: + ok, result, message = self.ping(remember=False) + return { + "success": ok, + "message": result.get("message") or message or "success", + "data": { + "plugin_enabled": self._enabled, + "api_key_configured": bool(self._api_key), + "base_url": self._base_url, + "ping": result, + }, + } + + async def api_account(self) -> Dict[str, Any]: + ok, result, message = self.fetch_me(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_checkin(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + ok, result, message = self.perform_checkin( + is_gambler=self._coerce_bool(body.get("is_gambler"), self._gambler_mode), + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_quota(self) -> Dict[str, Any]: + ok, result, message = self.fetch_quota(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_usage(self, request: Request) -> Dict[str, Any]: + start_date = request.query_params.get("start_date", "") + end_date = request.query_params.get("end_date", "") + ok, result, message = self.fetch_usage(start_date=start_date, end_date=end_date, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_usage_today(self) -> Dict[str, Any]: + ok, result, message = self.fetch_usage_today(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_weekly_quota(self) -> Dict[str, Any]: + ok, result, message = self.fetch_weekly_free_quota(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_search_resources(self, request: Request) -> Dict[str, Any]: + media_type = request.query_params.get("type") or request.query_params.get("media_type") or "movie" + tmdb_id = request.query_params.get("tmdb_id", "") + keyword = request.query_params.get("keyword", "") + year = request.query_params.get("year", "") + candidate_limit = request.query_params.get("candidate_limit", "5") + result_limit = request.query_params.get("limit", "10") + if tmdb_id: + ok, result, message = self.search_resources(media_type=media_type, tmdb_id=tmdb_id, remember=True) + else: + ok, result, message = await self.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=self._safe_int(candidate_limit, 5), + result_limit=self._safe_int(result_limit, 10), + remember=True, + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_unlock_resource(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + slug = body.get("slug") or "" + transfer_115 = self._coerce_bool( + body.get("transfer_115"), + self._transfer_115_enabled and self._auto_transfer_115_on_unlock, + ) + transfer_path = body.get("path") or body.get("transfer_path") or self._transfer_115_path + ok, result, message = self.unlock_resource( + slug=slug, + remember=True, + transfer_115=transfer_115, + transfer_path=transfer_path, + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_transfer_115(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + ok, result, message = self.transfer_115_share( + url=body.get("url") or "", + access_code=body.get("access_code") or "", + path=body.get("path") or body.get("transfer_path") or self._transfer_115_path, + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_check_resource(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + url = body.get("url") or "" + ok, result, message = self.check_resource(url=url, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_list_shares(self, request: Request) -> Dict[str, Any]: + page = self._safe_int(request.query_params.get("page"), 1) + page_size = self._safe_int(request.query_params.get("page_size"), 20) + ok, result, message = self.list_shares(page=page, page_size=page_size, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_detail(self, request: Request) -> Dict[str, Any]: + slug = request.query_params.get("slug", "") + ok, result, message = self.get_share_detail(slug=slug, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_create(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + ok, result, message = self.create_share(body or {}, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_update(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + slug = body.pop("slug", "") if isinstance(body, dict) else "" + ok, result, message = self.update_share(slug=slug, share_payload=body or {}, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_delete(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + slug = body.get("slug", "") if isinstance(body, dict) else "" + ok, result, message = self.delete_share(slug=slug, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} diff --git a/plugins.v2/quarksharesaver/README.md b/plugins.v2/quarksharesaver/README.md new file mode 100644 index 0000000..1791c62 --- /dev/null +++ b/plugins.v2/quarksharesaver/README.md @@ -0,0 +1,45 @@ +# QuarkShareSaver + +轻量夸克分享转存插件。 + +它只负责一件事: + +- 把夸克分享链接直接转存到你自己的夸克网盘目录 + +适合的调用方式: + +- 智能体调用插件 API +- 飞书桥接发送简短命令 + +推荐接口: + +- `GET /api/v1/plugin/QuarkShareSaver/health` +- `GET /api/v1/plugin/QuarkShareSaver/folders?path=/` +- `POST /api/v1/plugin/QuarkShareSaver/share/info` +- `POST /api/v1/plugin/QuarkShareSaver/transfer` + +`transfer` 请求体示例: + +```json +{ + "url": "https://pan.quark.cn/s/xxxxxxxx", + "access_code": "abcd", + "path": "/来自分享/夸克" +} +``` + +飞书推荐命令: + +```text +夸克转存 https://pan.quark.cn/s/xxxxxxxx pwd=abcd path=/最新动画 +``` + +配置重点: + +- `Cookie` 使用浏览器登录 `pan.quark.cn` 后复制完整 Cookie +- `默认保存目录` 建议填一个固定路径,例如 `/来自分享/夸克` + +这类轻插件更适合做“稳定执行层”: + +- 智能体负责理解意图和补参数 +- 插件负责真正转存 diff --git a/plugins.v2/quarksharesaver/__init__.py b/plugins.v2/quarksharesaver/__init__.py new file mode 100644 index 0000000..1369467 --- /dev/null +++ b/plugins.v2/quarksharesaver/__init__.py @@ -0,0 +1,1113 @@ +import hmac +import json +import random +import re +import time +from datetime import datetime +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen +from fastapi import Request + +from app.log import logger +from app.plugins import _PluginBase + +try: + from app.core.config import settings +except Exception: + settings = None + +try: + from app.schemas import NotificationType +except Exception: + NotificationType = None + +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None + + +class QuarkShareSaver(_PluginBase): + plugin_name = "夸克分享转存" + plugin_desc = "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/quark.ico" + plugin_version = "0.1.0" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "quarksharesaver_" + plugin_order = 32 + auth_level = 1 + + _enabled = False + _notify = True + _cookie = "" + _default_target_path = "/飞书" + _timeout = 30 + _auto_import_cookiecloud = True + _import_cookiecloud_once = False + + _share_url = "" + _access_code = "" + _target_path = "" + _transfer_once = False + + _last_transfer_key = "last_transfer" + _last_error_key = "last_error" + _path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "cookie": self._cookie, + "default_target_path": self._default_target_path, + "timeout": self._timeout, + "auto_import_cookiecloud": self._auto_import_cookiecloud, + "import_cookiecloud_once": self._import_cookiecloud_once, + "share_url": self._share_url, + "access_code": self._access_code, + "target_path": self._target_path, + "transfer_once": self._transfer_once, + } + if overrides: + config.update(overrides) + return config + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _save_state(self, key: str, value: Any) -> None: + try: + self.save_data(key=key, value=value) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 保存状态失败 {key}: {exc}") + + def _load_state(self, key: str, default: Any = None) -> Any: + try: + value = self.get_data(key) + return default if value is None else value + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 读取状态失败 {key}: {exc}") + return default + + def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None: + self._save_state( + self._last_error_key, + { + "action": action, + "message": message, + "payload": payload or {}, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + + def _notify_message(self, title: str, text: str) -> None: + if not self._notify or not hasattr(self, "post_message"): + return + try: + if NotificationType is not None: + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + else: + self.post_message(title=title, text=text) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 发送通知失败: {exc}") + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _try_import_cookiecloud_cookie(self, *, force: bool = False) -> Tuple[bool, str]: + if self._cookie and not force: + return True, "已存在 Cookie,跳过自动导入" + cookie, message = self._load_cookiecloud_quark_cookie() + if not cookie: + logger.info(f"[QuarkShareSaver] CookieCloud 导入未命中: {message}") + return False, message + self._cookie = cookie + logger.info(f"[QuarkShareSaver] 已从 CookieCloud 导入夸克 Cookie,长度: {len(cookie)}") + return True, "已从 CookieCloud 导入夸克 Cookie" + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + @staticmethod + def _extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + def _extract_share_info(self, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = self._clean_text(share_text) + share_url = self._extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = self._clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = self._clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = self._clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def _is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + def _validate_share_url(self, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if self._is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self._cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookiecloud_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + final_url = url + if params: + query = urlencode([(key, "" if value is None else value) for key, value in params.items()]) + final_url = f"{url}?{query}" if query else url + payload = None + if json_body is not None: + payload = json.dumps(json_body).encode("utf-8") + try: + request = UrlRequest( + url=final_url, + data=payload, + headers=self._build_headers(), + method=method.upper(), + ) + with urlopen(request, timeout=self._timeout) as response: + status_code = getattr(response, "status", 200) + raw_body = response.read() + except HTTPError as exc: + status_code = exc.code + raw_body = exc.read() if hasattr(exc, "read") else b"" + except URLError as exc: + return False, {}, f"请求失败: {exc.reason}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = json.loads(raw_body.decode("utf-8")) + except Exception: + text = raw_body.decode("utf-8", errors="ignore")[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code == 401 and allow_cookiecloud_retry and self._auto_import_cookiecloud: + imported, _ = self._try_import_cookiecloud_cookie(force=True) + if imported: + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookiecloud_retry=False, + ) + + if status_code != 200: + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self._clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def _get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self._safe_int(meta.get("_total"), 0) + count = self._safe_int(meta.get("_count"), len(current)) + size = max(1, self._safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def _list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def _find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self._list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def _create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self._clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def _ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path or self._default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self._create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _resolve_existing_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + return False, "", f"目录不存在: {built}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self._clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def _wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self._safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self._clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def _check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self._list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + remember: bool = True, + trigger: str = "插件 API", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self._extract_share_info(share_text, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + + if not self._enabled: + return False, {}, "插件未启用" + if not self._cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + self._remember_error("get_stoken", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, share_items, message = self._get_share_items(pwd_id, stoken) + if not ok: + self._remember_error("get_share_items", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, target_fid, normalized_path = self._ensure_target_dir(target_path or self._default_target_path) + if not ok: + self._remember_error("ensure_target_dir", target_fid, {"path": target_path or self._default_target_path}) + return False, {}, target_fid + + ok, task_id, message = self._create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + self._remember_error("create_save_task", message, {"pwd_id": pwd_id, "path": normalized_path}) + return False, {}, message + + ok, task, message = self._wait_task(task_id) + if not ok: + self._remember_error("wait_task", message, {"task_id": task_id}) + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + if remember: + self._save_state(self._last_transfer_key, result) + self._notify_message( + "夸克分享转存完成", + ( + f"保存目录:{normalized_path}\n" + f"任务ID:{task_id}\n" + f"顶层条目:{len(share_items)}" + ), + ) + return True, result, "success" + + def init_plugin(self, config: dict = None): + config = config or {} + self._enabled = bool(config.get("enabled")) + self._notify = bool(config.get("notify", True)) + self._cookie = self._clean_text(config.get("cookie")) + self._default_target_path = self._normalize_path(config.get("default_target_path") or "/飞书") + self._timeout = max(10, self._safe_int(config.get("timeout"), 30)) + self._auto_import_cookiecloud = bool(config.get("auto_import_cookiecloud", True)) + self._import_cookiecloud_once = bool(config.get("import_cookiecloud_once")) + + self._share_url = self._clean_text(config.get("share_url")) + self._access_code = self._clean_text(config.get("access_code")) + self._target_path = self._normalize_path(config.get("target_path") or self._default_target_path) + self._transfer_once = bool(config.get("transfer_once")) + self._path_cache = {"/": "0"} + + if self._import_cookiecloud_once or (self._auto_import_cookiecloud and not self._cookie): + imported_cookie, message = self._try_import_cookiecloud_cookie(force=self._import_cookiecloud_once) + if self._import_cookiecloud_once: + self._import_cookiecloud_once = False + self.update_config(self._build_config({"cookie": self._cookie, "import_cookiecloud_once": False})) + elif imported_cookie: + self.update_config(self._build_config({"cookie": self._cookie})) + if imported_cookie and self._notify: + self._notify_message("夸克 Cookie 已导入", message) + + if self._transfer_once: + self._transfer_once = False + self.update_config(self._build_config({"transfer_once": False})) + if self._enabled and self._share_url: + ok, _, message = self.transfer_share( + self._share_url, + access_code=self._access_code, + target_path=self._target_path, + remember=True, + trigger="插件页面立即转存", + ) + if not ok: + self._notify_message("夸克分享转存失败", message) + + def get_state(self) -> bool: + return self._enabled and bool(self._cookie) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_api(self) -> List[Dict[str, Any]]: + return [ + {"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查 Cookie 与默认目录状态"}, + {"path": "/folders", "endpoint": self.api_folders, "methods": ["GET"], "summary": "列出夸克网盘目录"}, + {"path": "/share/info", "endpoint": self.api_share_info, "methods": ["POST"], "summary": "解析夸克分享链接顶层条目"}, + {"path": "/transfer", "endpoint": self.api_transfer, "methods": ["POST"], "summary": "把夸克分享链接转存到指定目录"}, + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "notify", "label": "发送站内通知"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VTextField", "props": {"model": "timeout", "label": "请求超时(秒)", "type": "number"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "auto_import_cookiecloud", "label": "Cookie 为空时自动从 CookieCloud 导入"} + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "import_cookiecloud_once", "label": "立即从 CookieCloud 重新导入一次"} + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "浏览器登录 pan.quark.cn 后复制完整 Cookie", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "default_target_path", + "label": "默认保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "推荐给智能体或飞书调用的接口:\n" + "POST /api/v1/plugin/QuarkShareSaver/transfer\n" + "参数:url, access_code, path。\n" + "飞书建议命令:夸克转存 分享链接 pwd=提取码 path=/最新动画\n" + "如果你启用了本地 CookieCloud,插件可以自动导入 quark.cn Cookie。" + ), + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_once", "label": "立即转存一次"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "target_path", + "label": "本次保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_url", + "label": "夸克分享链接", + "placeholder": "https://pan.quark.cn/s/xxxx", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "access_code", + "label": "提取码(可留空)", + "placeholder": "abcd", + }, + } + ], + } + ], + }, + ], + } + ], self._build_config() + + def get_page(self) -> List[dict]: + last_transfer = self._load_state(self._last_transfer_key, default={}) or {} + last_error = self._load_state(self._last_error_key, default={}) or {} + + transfer_lines = [ + f"最近一次:{last_transfer.get('time') or '暂无'}", + f"保存目录:{last_transfer.get('target_path') or '-'}", + f"任务ID:{last_transfer.get('task_id') or '-'}", + f"顶层条目:{last_transfer.get('saved_count') or 0}", + ] + if last_transfer.get("items"): + transfer_lines.append("示例条目:" + ", ".join(last_transfer.get("items")[:5])) + + error_lines = [ + f"最近错误动作:{last_error.get('action') or '暂无'}", + f"错误时间:{last_error.get('time') or '-'}", + f"错误信息:{last_error.get('message') or '-'}", + ] + + return [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"variant": "tonal"}, + "content": [ + { + "component": "VCardText", + "text": ( + "夸克分享转存插件负责做一件事:把夸克分享链接稳定转存到自己的夸克网盘。" + "推荐让智能体和飞书只调用这一个稳定入口,不要自己拼夸克接口。" + ), + } + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近转存"}, + {"component": "VCardText", "text": "\n".join(transfer_lines)}, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近错误"}, + {"component": "VCardText", "text": "\n".join(error_lines)}, + ], + } + ], + }, + ], + } + ] + + def get_service(self) -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + pass + + async def api_health(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok = False + message = "" + if self._enabled and self._cookie: + ok, message = self._check_cookie() + return { + "success": ok if self._enabled and self._cookie else False, + "message": "success" if ok else (message or "插件未启用或未配置 Cookie"), + "data": { + "plugin_enabled": self._enabled, + "cookie_configured": bool(self._cookie), + "default_target_path": self._default_target_path, + "timeout": self._timeout, + }, + } + + async def api_folders(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + path = self._normalize_path(request.query_params.get("path") or "/") + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"path": path, "items": []}} + + ok, folder_id, normalized = self._resolve_existing_dir(path) + if not ok: + return {"success": False, "message": folder_id or "目录不存在", "data": {"path": path, "items": []}} + + ok, items, message = self._list_children(folder_id) + dirs = [ + {"fid": item.get("fid"), "name": item.get("name"), "path": f"{normalized.rstrip('/')}/{item.get('name')}".replace("//", "/")} + for item in items + if item.get("dir") + ] + return {"success": ok, "message": "success" if ok else message, "data": {"path": normalized, "items": dirs}} + + async def api_share_info(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + share_url = body.get("url") or body.get("share_url") or "" + access_code = body.get("access_code") or body.get("pwd") or "" + share_url, pwd_id, final_code = self._extract_share_info(share_url, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return {"success": False, "message": message, "data": {}} + if not pwd_id: + return {"success": False, "message": "未识别到有效夸克分享链接", "data": {}} + + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"pwd_id": pwd_id}} + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + return {"success": False, "message": message, "data": {"pwd_id": pwd_id}} + + ok, items, message = self._get_share_items(pwd_id, stoken) + return { + "success": ok, + "message": "success" if ok else message, + "data": { + "pwd_id": pwd_id, + "access_code": final_code, + "items": items[:20], + "count": len(items), + }, + } + + async def api_transfer(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok, result, message = self.transfer_share( + share_text=body.get("url") or body.get("share_url") or "", + access_code=body.get("access_code") or body.get("pwd") or "", + target_path=body.get("path") or body.get("target_path") or self._default_target_path, + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": message, "data": result} diff --git a/plugins.v2/skills/agent-resource-officer/README.md b/plugins.v2/skills/agent-resource-officer/README.md new file mode 100644 index 0000000..9a937a9 --- /dev/null +++ b/plugins.v2/skills/agent-resource-officer/README.md @@ -0,0 +1,630 @@ +# agent-resource-officer + +公开版 AgentResourceOfficer Skill 模板,用来让外部智能体通过 MoviePilot 插件接口控制 115 云盘、夸克云盘等云盘资源工作流。插件是服务端执行层;Skill/helper 是客户端调度层。 + +当前 helper 版本:`0.1.46` + +## 当前状态 + +- 当前插件版本:`Agent影视助手 0.2.68` +- 当前最小循环:`startup -> decide --summary-only -> route --summary-only -> followup --summary-only` +- 当前优先读取字段:`recommended_agent_behavior`、`auto_run_command`、`confirm_command`、`display_command` +- 当前 AI 失败样本只读诊断入口: + - `python3 scripts/aro_request.py route --text "失败样本 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "工作清单 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "样本洞察 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "重放样本 3" --summary-only` + - `python3 scripts/aro_request.py route --text "重放 3" --summary-only` + - `python3 scripts/aro_request.py route --text "确认" --summary-only` + - `python3 scripts/aro_request.py templates --recipe ai_reingest --compact` +- 当前最低成本入口: + - `python3 scripts/aro_request.py readiness` + - `python3 scripts/aro_request.py external-agent` + - `python3 scripts/aro_request.py decide --summary-only` + - `python3 scripts/aro_request.py route --text "智能搜索 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "资源决策 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "资源决策 蜘蛛侠 详情" --summary-only` +- 当前搜索口径: + - `搜索 <片名>` / `找 <片名>` 默认先走盘搜 + - `云盘搜索 <片名>` 固定走盘搜 + 影巢 + - `影巢搜索 <片名>` 明确走影巢直接列表 + - `MP搜索 <片名>` / `PT搜索 <片名>` 明确走 MoviePilot 原生 PT 搜索 +- `转存 <片名>` 走云盘资源一条龙转存,优先盘搜 + 影巢 +- `下载 <片名>` 走 MP/PT 直接下载 +- 当前更新口径: + - `更新 <片名>` / `更新检查 <片名>` / `检查 <片名>` 先走更新检查 + - 直接展示官方参考进度、盘搜最新集资源、影巢最新集资源 + - 不要先清空会话,不要先改走影巢候选 + - 资源列表必须保留原始编号,方便后续直接回编号 +- 当前破坏性目录命令: + - `清空夸克默认转存目录` + - `清空夸克默认目录` + - `清空115转存目录` + - `清空115默认转存目录` + - `清空115默认目录` + - 只在用户原话明确提出时执行,不要从模糊“清理一下”里自行推断 +- 当前影巢签到修复入口: + - `python3 scripts/aro_request.py hdhive-cookie-refresh` + - `python3 scripts/aro_request.py hdhive-checkin-repair` + - 推荐做法:先确保 Edge 已登录 `https://hdhive.com`,再用上面两条命令自动写回完整 Cookie,不要手工复制 Cookie +- 当前夸克登录修复入口: + - `python3 scripts/aro_request.py quark-cookie-refresh` + - `python3 scripts/aro_request.py quark-transfer-repair` + - 推荐做法:先确保 Edge 已登录 `https://pan.quark.cn`,登录态失效时优先刷新 Cookie;只有明确是 `require login [guest]` 这类登录态问题时才自动修复 + +公开仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +## 使用方式 + +1. 获取仓库: + +```bash +git clone https://github.com/liuyuexi1987/MoviePilot-Plugins.git +cd MoviePilot-Plugins +``` + +2. 把整个目录复制到自己的 Skill 搜索路径,例如: + +```text +/agent-resource-officer +``` + +也可以直接运行安装脚本: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/agent-resource-officer +``` + +3. 配置连接信息: + +```text +~/.config/agent-resource-officer/config +``` + +示例: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=your_moviepilot_api_token +ARO_HDHIVE_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/hdhive-cookie-export +ARO_QUARK_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/quark-cookie-export +``` + +`ARO_BASE_URL` 按实际部署填写:同机可以用 `http://127.0.0.1:3000`,局域网可以用 `http://你的局域网IP:3000`,公网反代可以用自己的 HTTPS 域名。 + +如果你要让 helper 直接调用本机“影巢 Cookie 导出”工具,可选配置: + +```text +ARO_HDHIVE_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/hdhive-cookie-export +ARO_HDHIVE_COOKIE_EXPORT_PYTHON=/绝对路径/python +ARO_HDHIVE_COOKIE_BROWSER=edge +ARO_HDHIVE_COOKIE_SITE_URL=https://hdhive.com +ARO_HDHIVE_COOKIE_RESTART_CONTAINER=moviepilot-v2 +ARO_QUARK_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/quark-cookie-export +ARO_QUARK_COOKIE_EXPORT_PYTHON=/绝对路径/python +ARO_QUARK_COOKIE_BROWSER=edge +ARO_QUARK_COOKIE_SITE_URL=https://pan.quark.cn +ARO_QUARK_COOKIE_RESTART_CONTAINER=moviepilot-v2 +``` + +如果你直接使用本仓库,helper 也会优先自动尝试仓库里的: + +- `tools/hdhive-cookie-export/` +- `tools/quark-cookie-export/` + +`route` 支持两种写法: + +- `python3 scripts/aro_request.py route "盘搜搜索 大君夫人"` +- `python3 scripts/aro_request.py route --text "盘搜搜索 大君夫人"` +- `python3 scripts/aro_request.py route "云盘搜索 大君夫人"` +- `python3 scripts/aro_request.py route "智能搜索 蜘蛛侠"` + +`route`、`pick`、`workflow`、`plan-execute`、`followup` 还支持: + +- `--summary-only` +- `--command-only` + +适合外部智能体只拿“下一步怎么做”的最小结果。 + +夸克默认目录清空入口: + +```bash +python3 scripts/aro_request.py route "清空夸克默认转存目录" +``` + +这条命令只针对当前配置的夸克默认转存目录,按当前层项目执行清空:当前层文件会直接删除,当前层文件夹也会一并删除(删除文件夹时会连同文件夹内内容一起清掉)。不要把它当成 115 清理,也不要从普通清理意图里自动触发,更不要先 grep helper 源码判断“支不支持”。 + +115 默认目录清空入口: + +```bash +python3 scripts/aro_request.py route "清空115转存目录" +python3 scripts/aro_request.py route "清空115默认转存目录" +``` + +这条命令只针对当前配置的 115 默认转存目录,按当前层项目执行清空:当前层文件会直接删除,当前层文件夹也会一并删除(删除文件夹时会连同文件夹内内容一起清掉)。它是显式破坏性命令,不要从普通清理意图里自动触发,也不要先 grep helper 源码判断“支不支持”。 + +`pick`、`plan-execute`、`followup` 也支持更短的位置参数写法: + +- `python3 scripts/aro_request.py pick 1` +- `python3 scripts/aro_request.py pick 1 详情` +- `python3 scripts/aro_request.py plan-execute plan-xxx` +- `python3 scripts/aro_request.py followup plan-xxx` + +影巢 Cookie 刷新与签到修复: + +```bash +python3 scripts/aro_request.py hdhive-cookie-refresh +python3 scripts/aro_request.py hdhive-checkin-repair +``` + +前者会从本机浏览器导出完整网页 Cookie 并自动写回 MoviePilot/AgentResourceOfficer;后者会在刷新 Cookie 后直接再跑一次 `影巢签到`。当 `影巢签到` 或 `影巢签到日志` 明确提示网页登录态失效时,优先使用这两条命令,不要手工复制 Cookie。 + +夸克 Cookie 刷新与转存修复: + +```bash +python3 scripts/aro_request.py quark-cookie-refresh +python3 scripts/aro_request.py quark-transfer-repair +python3 scripts/aro_request.py quark-transfer-repair --retry-text "选择 7" --session default +``` + +前者会从本机浏览器导出夸克 Cookie 并自动写回 `AgentResourceOfficer` / `QuarkShareSaver`;后者会在刷新 Cookie 后检查夸克健康状态,必要时还能顺手重试一条刚才失败的转存命令。只有明确报出 `require login [guest]`、`夸克登录态已过期` 这类登录态问题时,才建议走这条修复链;分享受限、分享者封禁等错误不要误判成 Cookie 失效。 + +`plan-execute` 返回里会保留插件给出的 `recommended_action` 和 `follow_up_hint`。如果不想自己解析下一步,也可以直接执行 `python3 scripts/aro_request.py followup --session 'agent:<会话ID>'`。 + +`workflow`、`session`、`history`、`plans` 也支持常用短写法: + +- `python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠` +- `python3 scripts/aro_request.py session agent:demo` +- `python3 scripts/aro_request.py history agent:demo` +- `python3 scripts/aro_request.py plans plan-xxx` + +4. 让外部智能体使用本 Skill。 + +## 推荐入口 + +```bash +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py doctor --limit 5 +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py feishu-health +python3 scripts/aro_request.py recover --summary-only +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py templates --recipe followup --compact +python3 scripts/aro_request.py templates --recipe ai_reingest --compact +python3 scripts/aro_request.py version +python3 scripts/aro_request.py selftest +python3 scripts/aro_request.py commands +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py config-check +python3 scripts/aro_request.py readiness +python3 scripts/aro_request.py startup +python3 scripts/aro_request.py templates --recipe bootstrap +python3 scripts/aro_request.py templates --recipe mp_pt +python3 scripts/aro_request.py templates --recipe recommend +python3 scripts/aro_request.py preferences --session agent:demo +python3 scripts/aro_request.py selfcheck +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py route "盘搜搜索 大君夫人" +python3 scripts/aro_request.py route "智能搜索 蜘蛛侠" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 详情" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 计划" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 确认" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 直接执行" +python3 scripts/aro_request.py route "失败样本 蜘蛛侠" +python3 scripts/aro_request.py route "工作清单 蜘蛛侠" +python3 scripts/aro_request.py route "样本洞察 蜘蛛侠" +python3 scripts/aro_request.py route "重放样本 3" +python3 scripts/aro_request.py route "重放 3" +python3 scripts/aro_request.py route "确认" +python3 scripts/aro_request.py route "先计划" +python3 scripts/aro_request.py route "确认执行" +python3 scripts/aro_request.py route "先看详情" +python3 scripts/aro_request.py route "计划" +python3 scripts/aro_request.py route "详情" +python3 scripts/aro_request.py route "智能计划 蜘蛛侠" +python3 scripts/aro_request.py route "智能执行 蜘蛛侠" +python3 scripts/aro_request.py route "计划最佳" +python3 scripts/aro_request.py route "执行最佳" +python3 scripts/aro_request.py pick 1 +``` + +`auto` 会先读取 `startup.recommended_request_templates`,再自动拉取推荐的低 token recipe。 + +`selftest` 不连接 MoviePilot,只验证本地 helper 的决策和命令生成逻辑。 + +`version` 会输出当前 helper 版本。 + +`commands` 会输出 helper 命令目录、是否联网、是否可能写入。`writes` 固定为布尔值,具体触发条件在 `write_condition`。 + +`external-agent` 会输出可直接交给 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体的系统提示词和最小工具约定;`external-agent --full` 会输出完整接入说明。输出中会明确给出 `compat_aliases` 和 `deprecated_aliases`。旧命令 `workbuddy` 仍保留为兼容别名,但已标记为 deprecated。 + +如果你对接的是 MP 内置智能体,优先读取 `request_templates` 和原生 Agent Tool,不要让模型自己拼底层影巢、盘搜、115、夸克接口。飞书入口同样复用 `route / pick / followup`,只是消息来源不同。 + +从 `0.2.66` 开始,`request_templates` 还会直接给出 `entry_playbooks`,把外部智能体、MP 内置智能体、飞书入口各自该调什么 helper / HTTP / Tool 以及优先读取哪些字段直接列出来。新接入方优先读这个结构,不要再自己拼第二套启动脚手架。 + +如果外部智能体已经确定是 MP 原生 PT 搜索/下载/订阅任务,优先拉 `mp_pt` recipe;如果是热门推荐、豆瓣热映、Bangumi 番剧续接,优先拉 `recommend` recipe。推荐列表里的条目现在支持: +- `选择 1 决策` +- `选择 1 计划` +- `选择 1 确认` +- `详情 1` +也支持直接对当前榜单首项继续发: +- `详情` +- `计划` +- `确认` +也支持会话内短命令: +- `决策 1` +- `计划 1` +- `确认 1` +也支持单句直达当前榜单首项: +- `智能发现 热门电影 详情` +- `智能发现 热门电影 计划` +- `智能发现 热门电影 确认` +以及单句直达具体来源: +- `智能发现 热门电影 盘搜` +- `智能发现 热门电影 影巢` +- `智能发现 热门电影 原生` +如果已经从推荐会话切到了 `盘搜 / 影巢 / 原生`,也可以直接发: +- `回推荐` +- `盘搜 / 影巢 / 原生` +- 在 `盘搜 / 原生` handoff 会话里,也支持: + - `详情 / 计划 / 确认 / 决策` +如果先看了 `详情 1`,之后还可以直接继续发: +- `详情` +- `决策` +- `计划` +- `确认` +- `盘搜` +- `影巢` +- `原生` +以及推荐会话内 follow-up: +- `电影` +- `电视剧` +- `豆瓣` +- `热映` +- `番剧` + +注意:`workflow` 会直接执行只读工作流;涉及下载、订阅、解锁或转存的写入工作流会默认保存待确认执行的 `plan_id`。 + +当前 PT 主线默认仍走 `plan_id` 确认链路。即使偏好里开启了 `auto_ingest_enabled=true`,外部智能体也应先展示评分和风险,再等待用户确认执行计划。 + +首次交给外部智能体使用时,建议先运行 `preferences`。如果返回需要初始化偏好,智能体应询问用户:清晰度、杜比视界/HDR、字幕、电视剧是否全集优先、PT 最低做种、影巢积分上限、默认目录、是否允许高分资源自动入库。偏好会用于云盘和 PT 分源评分。 + +如果你希望“新会话默认就更保守或更激进”,不要在智能体侧硬编码阈值,直接到 Agent影视助手 插件设置里修改默认评分策略:`PT 最低做种数`、`建议确认分数线`、`自动入库分数线`、`默认允许高分自动入库`。 + +`route`、`pick`、`workflow` 等主响应会带上低 token 的 `preference_status`。如果其中 `needs_onboarding=true`,智能体应先完成偏好询问与保存,再继续自动选择或入库。 + +偏好也可以直接走主入口自然语言:`偏好` 查看,`保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库` 写入,`重置偏好` 清除。 + +如果用户已经提前说明“只用夸克”“没有 115”“不用盘搜”“只用 MP/PT”,也可以直接保存进偏好,例如: + +- `保存偏好 只有夸克 不用115` +- `保存偏好 只用盘搜 不用影巢` +- `保存偏好 只用 MP/PT` + +之后优先用 `智能搜索`: + +- `python3 scripts/aro_request.py route "智能搜索 蜘蛛侠"` +- `python3 scripts/aro_request.py route "资源决策 蜘蛛侠"` +- `python3 scripts/aro_request.py route "智能计划 蜘蛛侠"` +- `python3 scripts/aro_request.py route "智能执行 蜘蛛侠"` + +这条入口会先按偏好过滤可用源和可用云盘,再按默认顺序 `盘搜 -> 影巢 -> MP/PT` 做统一搜索决策;如果前面某一源已经给出足够高分、风险可控的候选,就不会继续无意义展开后面的源。 + +如果你已经做过一次 `智能搜索`,也可以直接在当前会话里发: + +- `python3 scripts/aro_request.py route "计划最佳"` +- `python3 scripts/aro_request.py route "执行最佳"` +- `python3 scripts/aro_request.py route "换影巢"` +- `python3 scripts/aro_request.py route "换盘搜"` +- `python3 scripts/aro_request.py route "换PT"` +- `python3 scripts/aro_request.py route "保守一点"` +- `python3 scripts/aro_request.py route "激进一点"` +- `python3 scripts/aro_request.py route "只用夸克"` +- `python3 scripts/aro_request.py route "只用115"` +- `python3 scripts/aro_request.py route "只走PT"` +- `python3 scripts/aro_request.py route "不用影巢"` +- `python3 scripts/aro_request.py route "按保存偏好"` + +它会按当前智能搜索会话里的首选结果,直接生成待确认 `plan_id`,但不会立刻执行下载、解锁或转存。 +如果用户已经明确要求立即执行,再用 `智能执行` 或 `执行最佳`;这两个入口会直接走写入链。 + +AI 失败样本链现在分两步: + +- `失败样本 / 工作清单 / 样本洞察`:只读诊断 +- `重放样本 3` 或会话内 `重放 3`:只生成待确认计划 +- `确认`:执行当前会话里最近一条 AI 重放计划 +- 重放后可直接继续:`诊断`、`入库状态` + +真正执行仍然要回复 `执行计划 `,不会直接裸重放。 + +搜索类响应可能带有 `score_summary`,包含 `best` 和 `top_recommendations`。外部智能体应优先读取这个结构化摘要,而不是解析长文本;存在 `hard_risk_reasons` 时不要自动执行,`risk_reasons` 只作为确认前需要解释的提醒。 + +`score_summary.decision` 是优先读取的下一步建议层,里面会给出 `label`、`decision_hint`、`preferred_command`、`fallback_command`、`compact_commands` 和 `recommended_commands`。外部智能体应优先复用前两档短命令,不要自己再拼另一套确认话术。 + +执行计划后的回执,以及后续的 `execution_followup`、`smart_followup`、`mp_lifecycle_status`、`mp_ingest_status`、`mp_recent_activity`,现在会统一附带 `followup_summary`。外部智能体应优先读取 `preferred_command`、`fallback_command` 和 `compact_commands` 来决定“接下来查下载、查入库还是查诊断”,不要再靠不同 message 文案分支判断。 + +从 `0.2.63` 开始,compact 主响应顶层也会直接给出统一的 `command_source`、`command_policy`、`preferred_requires_confirmation`、`fallback_requires_confirmation`、`can_auto_run_preferred`、`preferred_command`、`fallback_command`、`compact_commands`。优先级已经固定为: + +1. `error_summary` +2. `followup_summary` +3. `score_summary.decision` + +外部智能体如果只想要“下一条最短命令”,直接读取顶层字段即可,不必自己再判断嵌套结构来源;如果还要判断“这一步能不能直接执行”,则读取 `command_policy` 和两个 `*_requires_confirmation` 标志。 + +从 helper `0.1.30` 开始,`route / pick / workflow / plan-execute / followup` 也能直接把这层顶层字段压成 `--summary-only` / `--command-only` 输出。外部智能体如果不想自己解析 JSON,可以直接调用 helper。 + +从 helper `0.1.31` 开始,这些摘要还会继续保留: + +- `command_policy` +- `preferred_requires_confirmation` +- `fallback_requires_confirmation` +- `can_auto_run_preferred` + +也就是外部智能体不只知道“下一条命令是什么”,还知道“这条命令能不能直接跑,还是该先停下来确认”。 + +从 helper `0.1.32` 开始,`--summary-only` 会直接给出一层更适合自动续跑的决策字段: + +- `recommended_agent_behavior` +- `auto_run_command` +- `confirm_command` +- `display_command` +- `stop_after_auto` +- `reason` + +推荐解释: + +- `auto_continue`:可以直接执行 `auto_run_command` +- `auto_continue_then_wait_confirmation`:先执行 `auto_run_command`,然后停下来把 `confirm_command` 展示给用户确认 +- `wait_user_confirmation`:不要自动执行,先让用户确认 `confirm_command` +- `show_only`:只展示 `display_command` +- `stop`:当前没有适合继续自动执行的短命令 + +从 helper `0.1.33` 开始,这套决策字段不只覆盖 `route / pick / workflow / plan-execute / followup`,也会覆盖 `decide / auto / doctor / recover` 这类老摘要入口。外部智能体可以统一只读: + +- `recommended_agent_behavior` +- `auto_run_command` +- `confirm_command` + +如果原摘要本身已经带业务层 `reason`,helper 会额外补 `execution_reason`,避免把原原因覆盖掉。 + +推荐把外部智能体的执行分支压成这 5 类: + +- `auto_continue`:直接执行 `auto_run_command` +- `auto_continue_then_wait_confirmation`:先执行 `auto_run_command`,再向用户确认 `confirm_command` +- `wait_user_confirmation`:不要自动执行,先展示 `confirm_command` +- `show_only`:只展示 `display_command` +- `stop`:当前不要继续自动执行 + +推荐的最小启动流也已经固定: + +1. `startup` +2. `decide --summary-only` +3. `route "<用户原始指令>" --summary-only` +4. 按 `recommended_agent_behavior` 决定自动继续、确认或停止 +5. 涉及执行计划后,再走 `followup --summary-only` + +评分由插件内置规则执行。外部智能体如需解释规则,可读取 `scoring-policy` 或 `capabilities.scoring_policy`;不要在智能体侧重新打分,也不要绕过 `hard_risk_reasons`。 + +`config-check` 只检查连接配置来源和是否存在,不输出真实 API Key。 + +`readiness` 会一次运行配置检查、本地 selftest 和 MoviePilot 插件 selfcheck。 + +WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体接入时,可以直接复用: + +- [外部智能体接入 Agent影视助手](../../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- Skill 包内外部智能体接入文件:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` +- `PROMPTS.md` 里的外部智能体提示词段落 + +`decide` 是单次决策入口: + +- 有可恢复会话时,返回 `decision=continue_session` +- 没有可恢复会话时,返回 `decision=start_recipe` + +无论落到哪一边,低 token 摘要都会尽量附带下一步 helper 命令。 + +只需要下一步命令时,用: + +```bash +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py decide --command-only --confirmed +``` + +默认会在需要确认的场景输出查看命令;已经获得用户确认后,再加 `--confirmed` 输出执行命令。 + +如果已确定任务类型,可以直接指定 recipe 获取更具体的下一步命令: + +```bash +python3 scripts/aro_request.py decide --recipe mp_pt --command-only +python3 scripts/aro_request.py decide --recipe recommend --command-only +``` + +如果只想拿自动启动流的最小决策结果,直接用: + +```bash +python3 scripts/aro_request.py auto --summary-only +``` + +`doctor` 是只读诊断入口,会一次返回 `startup + selfcheck + sessions + recover` 的压缩结果,适合外部智能体在真正执行前做开场检查。 + +`feishu-health` 会检查 `AgentResourceOfficer` 内置飞书入口是否启用、长连接是否运行,以及飞书 SDK / 白名单 / 回复配置状态;MP 内置智能助手可直接使用 `agent_resource_officer_feishu_health`。 + +如果只想拿最省 token 的决策结果,直接用: + +```bash +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py recover --summary-only +``` + +它还会直接给出: + +- `helper_commands.inspect_helper_command` +- `helper_commands.execute_helper_command` + +## 恢复与排查 + +```bash +python3 scripts/aro_request.py sessions --limit 10 +python3 scripts/aro_request.py sessions --kind assistant_hdhive --limit 5 +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py recover --execute +python3 scripts/aro_request.py history --limit 10 +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans --limit 10 +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans --executed --include-actions --limit 5 +python3 scripts/aro_request.py plan-execute plan-xxx +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py followup plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +- `sessions` / `history` / `plans` / `recover` 默认不再强制绑到 `default` 会话。 +- 只有显式传 `--session` 或 `--session-id` 时,才会收窄到单个会话。 +- `followup` 会按最近已执行计划自动选择合适的只读后续动作,适合接在 `plan-execute` 后面。 +- `session-clear` / `sessions-clear` 是写入型清理命令,用于清理放弃的会话或 pending 115 恢复状态。 +- `plans-clear` 是写入型清理命令,优先使用 `--plan-id` 精确清理;批量清理时再使用 `--session`、`--executed`、`--unexecuted` 或 `--all-plans`。 + +## 偏好与评分 + +```bash +python3 scripts/aro_request.py preferences --session agent:demo +python3 scripts/aro_request.py preferences --session agent:demo --preferences-json '{"prefer_resolution":"4K","prefer_dolby_vision":true,"prefer_hdr":true,"prefer_chinese_subtitle":true,"prefer_complete_series":true,"pt_min_seeders":3,"hdhive_max_unlock_points":20,"auto_ingest_enabled":false}' +python3 scripts/aro_request.py route --text "保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库" --session agent:demo +python3 scripts/aro_request.py workflow --workflow mp_search --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode mp +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode hdhive +``` + +智能体也可以直接走自然语言路由: + +```bash +python3 scripts/aro_request.py route --text "看看最近有什么热门影视" +python3 scripts/aro_request.py route --text "豆瓣热门电影" +python3 scripts/aro_request.py route --text "今日番剧" +``` + +推荐列表出来后,可以用自然语言继续: + +```bash +python3 scripts/aro_request.py route --text "选择 1" +python3 scripts/aro_request.py route --text "选择 1 盘搜" +python3 scripts/aro_request.py route --text "选择1影巢" +``` + +MP 原生搜索结果出来后,也可以直接: + +```bash +python3 scripts/aro_request.py route --text "下载1" +python3 scripts/aro_request.py route --text "下载第1个" +python3 scripts/aro_request.py route --text "订阅蜘蛛侠" +python3 scripts/aro_request.py route --text "订阅并搜索蜘蛛侠" +python3 scripts/aro_request.py route --text "MP搜索 蜘蛛侠" --session agent:demo +python3 scripts/aro_request.py pick --choice 1 --session agent:demo +python3 scripts/aro_request.py route --text "计划选择 1" --session agent:demo +python3 scripts/aro_request.py route --text "最佳片源" --session agent:demo +python3 scripts/aro_request.py route --text "下载最佳" --session agent:demo +python3 scripts/aro_request.py route --text "执行计划" --session agent:demo +python3 scripts/aro_request.py route --text "执行 plan-xxxx" --session agent:demo +``` + +盘搜和影巢资源列表里的 `最佳片源`、`选择 1 详情` 是只读查看,不会转存或解锁。普通 `搜索/找 <片名>` 返回的盘搜列表,默认先按编号直接选;想先确认时再发 `选择 1 详情`。只有用户明确要求保留计划确认链时,才发 `计划选择 1`。 + +普通 `搜索/找 <片名>` 的返回应尽量原样展示资源官给出的编号列表,不要再二次改写成“资源状态”“推荐清单”“费用/评分/推荐星级”之类的摘要。最好的做法是保留原列表和下一步提示,只在前后补一两句极短说明。 + +`云盘搜索 <片名>` 也应尽量原样展示资源官给出的组合结果。不要把 `云盘搜索` 偷换成 `盘搜搜索`,也不要把插件已经给出的 `盘搜结果 / 影巢结果` 两段重新压成“剧集信息 / 推荐资源 / 分析结论”的导购摘要。优先保留: +- `盘搜结果` +- `影巢结果` +- 原始编号 +- 盘搜原始链接 +- 插件原生下一步提示 + +`云盘搜索` 返回后,不要自行改写成每个来源各自从 `1` 开始编号的小表格,也不要只摘“亮点”。如果插件返回了全局编号,就保留全局编号;如果插件提示“影巢候选未自动展开”,也应原样保留这句,而不是把它改成一句“影巢还有候选,需要可发影巢搜索”然后丢掉上文结构。 + +夸克转存失败时,不要自己补一段“可能是默认转存目录不存在或有问题”“换个 path=/ 试试”这类猜测。只有当插件明确指出路径问题时,才建议改路径;如果插件只返回 `夸克转存失败:无法转存到 /飞书`,更稳妥的表述应是“原因未明,先不要自行推断路径问题”。 + +下载任务也可以走同一入口。查询是读操作;暂停、恢复、删除会先返回 `plan_id`,确认后再执行: + +```bash +python3 scripts/aro_request.py route --text "下载任务" +python3 scripts/aro_request.py route --text "记录" +python3 scripts/aro_request.py route --text "记录 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_download_history --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py route --text "状态 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_lifecycle_status --keyword "蜘蛛侠" --limit 5 +python3 scripts/aro_request.py route --text "后续" +python3 scripts/aro_request.py route --text "跟进" +python3 scripts/aro_request.py route --text "跟进 蜘蛛侠" +python3 scripts/aro_request.py route --text "入库 蜘蛛侠" +python3 scripts/aro_request.py route --text "诊断 蜘蛛侠" +python3 scripts/aro_request.py route --text "最近" +python3 scripts/aro_request.py route --text "识别 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py route --text "暂停下载 1" +python3 scripts/aro_request.py route --text "恢复下载 1" +python3 scripts/aro_request.py route --text "删除下载 1" +``` + +PT 环境诊断也可以直接询问;站点结果只返回脱敏摘要,不会暴露 Cookie: + +```bash +python3 scripts/aro_request.py route --text "站点状态" +python3 scripts/aro_request.py route --text "下载器状态" +python3 scripts/aro_request.py workflow --workflow mp_sites --status active --limit 30 +python3 scripts/aro_request.py workflow --workflow mp_downloaders +``` + +MP 订阅也可以交给 Agent影视助手统一调度。查询是读操作;搜索、暂停、恢复、删除订阅会先返回 `plan_id`: + +```bash +python3 scripts/aro_request.py route --text "订阅列表" +python3 scripts/aro_request.py route --text "搜索订阅 1" +python3 scripts/aro_request.py route --text "暂停订阅 1" +python3 scripts/aro_request.py route --text "恢复订阅 1" +python3 scripts/aro_request.py route --text "删除订阅 1" +python3 scripts/aro_request.py workflow --workflow mp_subscribes --status all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_subscribe_control --control search --target 1 +``` + +MP 整理/入库历史是只读查询,适合让智能体确认下载后是否已经落库: + +```bash +python3 scripts/aro_request.py route --text "入库历史" +python3 scripts/aro_request.py route --text "入库失败 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_transfer_history --keyword "蜘蛛侠" --status all --limit 10 +``` + +- 云盘资源按清晰度、HDR/DV、字幕、完整度、目录和网盘类型评分;影巢额外受积分上限保护。 +- PT 资源按做种数、免费/促销、下载热度、清晰度、HDR/DV、字幕、标题匹配、站点和发布组评分;高分也默认先返回 `plan_id`,不会直接下载。 +- 下载、订阅、影巢解锁、网盘转存默认先生成 `plan_id`,确认后再执行。 + +## 说明 + +- 这是面向公开仓库的通用模板。 +- 重点使用 `AgentResourceOfficer` 的 `assistant/startup` 和 `assistant/request_templates`。 +- HTTP 调用使用 `?apikey=MP_API_TOKEN`。 +- 不包含个人路径、API Key、Cookie 或 Token。 +- 推荐搭配支持 Skill 和工具调度的外部智能体使用,例如腾讯 WorkBuddy、Hermes、OpenClaw(小龙虾),或其他兼容 Skill 工作流的客户端。 +- 版本记录见:`skills/agent-resource-officer/CHANGELOG.md`。 diff --git a/plugins.v2/skills/agent-resource-officer/SKILL.md b/plugins.v2/skills/agent-resource-officer/SKILL.md new file mode 100644 index 0000000..e0e5ca8 --- /dev/null +++ b/plugins.v2/skills/agent-resource-officer/SKILL.md @@ -0,0 +1,736 @@ +--- +name: agent-resource-officer +description: Control AgentResourceOfficer, the MoviePilot resource workflow hub, from an external agent. Use when an agent should route title-based resource commands including PanSou, HDHive, 115, Quark, MP/PT search, downloads, update checks, numbered choices, paging, cookie repair, startup/recovery state, request templates, or saved plans through AgentResourceOfficer instead of calling MoviePilot MCP search tools, TMDB, HDHive, 115, Quark, or PanSou APIs directly. +--- + +# AgentResourceOfficer Skill + +Use this skill when the user wants an external agent to operate MoviePilot title-based resource workflows through `AgentResourceOfficer`, including PanSou, HDHive, 115, Quark, MP/PT search, download, update-check, numbered picking, paging, and repair flows. + +The plugin is the capability layer. The agent should orchestrate, display choices, ask for confirmation when required, and call the stable assistant endpoints. + +## Configuration + +Public repository: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +To reproduce this skill on another machine, clone the repository and install the bundled skill: + +```bash +git clone https://github.com/liuyuexi1987/MoviePilot-Plugins.git +cd MoviePilot-Plugins +bash skills/agent-resource-officer/install.sh --dry-run +bash skills/agent-resource-officer/install.sh +``` + +Preferred local config: + +```text +~/.config/agent-resource-officer/config +``` + +Format: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=your_moviepilot_api_token +``` + +Set `ARO_BASE_URL` to the MoviePilot address reachable from the machine running the external agent. Use `http://127.0.0.1:3000` only when MoviePilot is on the same machine. + +If the user has multiple MoviePilot instances, `ARO_BASE_URL` decides which instance receives every resource command. Be especially careful with `下载` / `MP搜索` / `PT搜索`: they use the downloader configured inside that MoviePilot instance. A local Mac/Win MoviePilot can still control a NAS qBittorrent if its downloader points to the NAS, so do not assume "local MP" means "local download". If the current MoviePilot is only for cloud-drive/STRM workflows and its `/待整理` path is not a real PT download directory, do not execute PT download confirmations through it; ask the user to switch `ARO_BASE_URL` to the NAS MoviePilot that owns the normal download workflow. + +If the target MoviePilot plugin config sets `mp_download_save_path`, PT downloads will be submitted with that explicit MoviePilot `save_path`. Treat it as a server-side safety/configuration knob: do not invent this path in chat. It must match the target MoviePilot/NAS storage mapping, for example a valid `local:/...` path or another storage-prefixed path accepted by MoviePilot. + +## Routing Boundary + +MoviePilot official MCP is optional, not assumed. + +- Only use MoviePilot MCP when the current client has already connected the MCP endpoint and can actually see MoviePilot MCP tools in the active tool list. +- If MCP is not explicitly connected in the current client, continue to use `agent-resource-officer` helper/HTTP route flow and do not pretend MCP is available. +- Do not tell the user you are using MCP unless you truly invoked MoviePilot MCP tools in this session. +- MCP is only the preferred path for MoviePilot management/read-only queries, not for title-based resource workflow commands. +- For MoviePilot native read-only or light management tasks, prefer MCP first and do not probe old HTTP endpoints, `curl`, `raw GET`, or helper fallback before trying the matching `mcp__moviepilot__*` tool. +- Typical MCP-first tasks include: installed plugins, downloader status, site status, site userdata, download tasks, download history, transfer history, subscribe history, library latest, library exists, directory settings, workflows, schedulers, media detail, episode schedule, and similar MP-native queries. +- Keep title-based resource workflow abilities on the existing stable path: PanSou, HDHive, 115, Quark, MP/PT search commands, numbered picking, paging, cookie repair, update-check orchestration, and Feishu entry must use `agent-resource-officer` skill/helper unless the user explicitly asks for another route. +- If the user command is clearly a resource workflow command, do not call MCP, tool_search, curl, raw API probes, or MoviePilot native search first. Directly run the helper route/pick. This includes: `搜索`, `找`, `盘搜`, `盘搜搜索`, `云盘搜索`, `影巢`, `影巢搜索`, `MP搜索`, `PT搜索`, `转存`, `夸克转存`, `115转存`, `下载`, `更新`, `更新检查`, `检查`, `选择`, `详情`, `n`, `下一页`, and numbered follow-ups. +- If the user says `校准影视技能`, run `python3 scripts/aro_request.py calibrate` or `python3 scripts/aro_request.py route "校准影视技能"` first, apply the returned hard rules to the current session, then reply only `影视技能已校准。`. +- Preserve the title-confirmation gate for write commands. `下载 蜘蛛侠`, `转存 蜘蛛侠`, `夸克转存 蜘蛛侠`, and `115转存 蜘蛛侠` must first resolve MoviePilot/TMDB candidates when the title is ambiguous. Plain `转存 <片名>` means `115转存 <片名>`; only explicit `夸克转存 <片名>` should use Quark. `下载 <片名>` means MP/PT only: show candidates or PT resources first, and never auto-submit a download from the title command. If there are multiple title candidates, wait for the user to choose one; after selection, continue with the exact selected title and year, then search PT/PanSou/HDHive according to the original command. +- Before executing any confirmed PT download, remember that the actual save path is controlled by the target MoviePilot/qBittorrent downloader configuration, not by AgentResourceOfficer's 115/Quark transfer directory. If the connected MoviePilot is known to be a cloud-drive/STRM-only instance, or the user says its download path is the same `/待整理` used for cloud transfers, stop and ask them to switch to the NAS download MoviePilot before executing. +- Preserve detail intent in numbered follow-ups. If the user says `15详情`, `15 的详情`, `我要看看 15 的详情`, `十六详情`, or `详情十六`, call route/pick with that detail intent intact. Do not simplify it to `15`, `选择 15`, or any command that executes transfer/download. If you must normalize the text, normalize only to `选择 15 详情`. +- For explicit title searches such as `MP 搜索 罪无可逃`, the first and only initial command should be `python3 scripts/aro_request.py route "MP 搜索 罪无可逃" --session `. Do not call `search_media`, `search_torrents`, TMDB APIs, MoviePilot raw APIs, or MCP before this helper call. + +Environment overrides: + +- `ARO_BASE_URL` +- `MP_BASE_URL` +- `MOVIEPILOT_URL` +- `ARO_API_KEY` +- `MP_API_TOKEN` +- `ARO_HDHIVE_COOKIE_EXPORT_DIR` +- `ARO_HDHIVE_COOKIE_EXPORT_PYTHON` +- `ARO_HDHIVE_COOKIE_BROWSER` +- `ARO_HDHIVE_COOKIE_SITE_URL` +- `ARO_HDHIVE_COOKIE_RESTART_CONTAINER` +- `ARO_QUARK_COOKIE_EXPORT_DIR` +- `ARO_QUARK_COOKIE_EXPORT_PYTHON` +- `ARO_QUARK_COOKIE_BROWSER` +- `ARO_QUARK_COOKIE_SITE_URL` +- `ARO_QUARK_COOKIE_RESTART_CONTAINER` + +Never print API keys, cookies, or tokens back to the user. + +If this skill is installed from the `MoviePilot-Plugins` repository checkout, the helper will first try the bundled cookie export tools in: + +- `tools/hdhive-cookie-export/` +- `tools/quark-cookie-export/` + +The install helper copies these tools into the installed skill directory as `tools/...`, so a standalone installed skill can call `hdhive-cookie-refresh`, `hdhive-checkin-repair`, `quark-cookie-refresh`, and `quark-transfer-repair` directly. You can still override them with `ARO_HDHIVE_COOKIE_EXPORT_DIR` and `ARO_QUARK_COOKIE_EXPORT_DIR`. + +Optional install helper: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/agent-resource-officer +``` + +## Request Helper + +Prefer the bundled helper: + +```bash +python3 scripts/aro_request.py startup +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py doctor --limit 5 +python3 scripts/aro_request.py doctor --limit 5 --summary-only +python3 scripts/aro_request.py feishu-health +python3 scripts/aro_request.py recover --summary-only +python3 scripts/aro_request.py version +python3 scripts/aro_request.py selftest +python3 scripts/aro_request.py hdhive-cookie-refresh +python3 scripts/aro_request.py hdhive-checkin-repair +python3 scripts/aro_request.py quark-cookie-refresh +python3 scripts/aro_request.py quark-transfer-repair +python3 scripts/aro_request.py commands +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py config-check +python3 scripts/aro_request.py readiness +python3 scripts/aro_request.py selfcheck +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py sessions --kind assistant_hdhive --limit 5 +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py templates --recipe bootstrap +python3 scripts/aro_request.py route "盘搜搜索 大君夫人" +python3 scripts/aro_request.py pick 1 +``` + +The helper uses `?apikey=...`, which is the recommended HTTP auth mode for plugin assistant endpoints. + +Use `selftest` to validate local helper logic without connecting to MoviePilot: + +```bash +python3 scripts/aro_request.py selftest +``` + +Use `version` to print the local helper version: + +```bash +python3 scripts/aro_request.py version +``` + +Use `commands` when an external agent needs the local helper command catalog: + +```bash +python3 scripts/aro_request.py commands +``` + +The command catalog uses `schema_version=commands.v1`; `writes` is always boolean and details live in `write_condition`. + +Use `external-agent` when handing this Skill to WorkBuddy, Hermes, OpenClaw(小龙虾), a WeChat-side agent, or another external agent: + +```bash +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py calibrate +``` + +`external-agent` prints the compact prompt and minimal tool contract. `external-agent --full` prints the full bundled handoff guide. `workbuddy` remains a compatibility alias only; new integrations should use `external-agent`. + +`calibrate` prints a compact calibration card for long-lived external-agent threads. Use it when a WeChat/WorkBuddy/Claw/Hermes/OpenClaw session has been compressed or starts rewriting commands incorrectly, for example changing `下载 <片名>` into cloud transfer or changing `15详情` into execution. + +When a user says plain `搜索 <片名>` or `找 <片名>`, pass that text through to `route` first. Do not guess that the user meant HDHive, and do not continue an old result session by sending `选择 1` unless the user actually chose an item in the current round. Default plain search should start from PanSou. + +When the user clearly refers to a previously shown numbered result, for example `刚才那个 22`、`上次的 #22`、`把原来的 22 转存`、`下载 10`、`选择 14`, do not restart search first. Reuse the current session, or recover the latest matching session with `decide --summary-only` / `sessions` / `session`, then continue with `pick`. Only restart the search when the old session is truly gone and cannot be recovered. + +When a user says `转存 <片名>`, route that text directly first. Treat it as a 115-transfer intent, equivalent to `115转存 <片名>`: prefer PanSou + HDHive 115 resources, and let AgentResourceOfficer execute the one-stop transfer flow instead of rewriting it into a PT download request. Only use Quark when the user explicitly says `夸克转存`. + +When a user says `下载 <片名>`, route that text directly first. Treat it as an MP/PT search-and-download intent, not a browsing/listing intent. If the title is ambiguous, show MoviePilot/TMDB title candidates first. Once the title is unambiguous, the plugin should search PT internally and directly return up to three pending download plans for the best PT candidates instead of showing the full PT list. It must not auto-submit a real download from the title command. Only after the plugin has returned pending plans may the user confirm by replying the displayed方案编号 such as `1`, `2`, or `3`, or `执行计划`; route that reply as-is so the plugin can execute the matching pending plan. `下载1` means "generate/select download plan for result 1", not confirmation for an older saved plan. If there is no pending plan in the current session, a bare number must be treated as the current result-list continuation. + +When a user says `MP搜索 <片名>`, `MP 搜索 <片名>`, `PT搜索 <片名>`, or `PT 搜索 <片名>`, route that exact text directly first. Treat it as an explicit MoviePilot native/PT search request. Do not rewrite it into `搜索 <片名>`, `盘搜搜索 <片名>`, `云盘搜索 <片名>`, or smart search. + +If the same command includes a natural-language latest-episode intent such as `给我最新集`, `最新集`, or `最新一集`, still route the original text directly. AgentResourceOfficer will strip that suffix from the title, detect the highest episode in PT results, and show only candidates containing that latest episode. Do not add older episode batches back into the summary unless the user asks for all results. + +If the same command includes a clear episode filter such as `第4集`, `第四集`, `E04`, or `S01E04`, still route the original text directly. AgentResourceOfficer will strip the episode suffix from the title and show only candidates containing that target episode, then renumber the filtered list safely. Do not remove this intent or rewrite it as a generic title search. + +For `下载 <片名>` results, relay the plugin's returned message exactly like `MP搜索` / `PT搜索`. If the plugin returns a PT resource list, show the numbered resources, score lines, recommendation, and next-step hints. Never replace the list with a one-line summary such as `PT资源已列出,回编号选详情或下载`. In PT result lists, keep the distinction clear: `选择 N` is read-only detail/review, `下载N` generates a pending download plan, and only a later `执行计划` or matching number after that pending plan executes it. + +If `MP搜索` / `PT搜索` returns a MoviePilot media candidate list, do not choose for the user. Show the candidates, ask the user to reply with a number, then call `pick ` to continue the PT search. For ambiguous titles such as `蜘蛛侠`, this candidate step is expected and safer than assuming the 2002 movie. + +If the original `MP搜索` / `PT搜索` command included `最新集` / `给我最新集` and then returned a media candidate list, the user's numeric reply must be routed in the same helper session. Do not run a fresh bare `route "1"` in another/default session, and do not summarize older episode batches as latest results. The plugin will preserve the latest-episode filter after the candidate is selected; relay that returned message as-is. + +After `下载 <片名>` returns a title candidate list, preserve the same helper session and route the user's numeric reply exactly as the reply text, for example `python3 scripts/aro_request.py route "5" --session `. Do not reconstruct it as `下载 <候选标题 年份>`, because that loses the candidate session and can change behavior. If the selected title has no PT resources, say that MP/PT currently has no downloadable result; do not silently fall back to PanSou, HDHive, Quark, 115, or cloud transfer. Cloud resources require an explicit `云盘搜索` / `转存` / `夸克转存` / `115转存` command. + +For `MP搜索` / `PT搜索` results, relay the plugin's returned message exactly. Do not compress it into a new custom list such as `PT 资源共 N 条`, and do not rewrite release titles. Preserve the plugin's emoji markers (`🧲`, `🌱`, `🎁`, `💾`, `⭐`, etc.) and invisible breaks in dotted release names; they are intentional for WeChat/mobile readability. + +Do not renumber MP/PT result lists. If the plugin returns visible item numbers like `2, 4, 21, 29`, keep those exact numbers in the user-facing reply and in follow-up commands such as `选择 2` or `下载2`. Never rewrite them to `1, 2, 3, 4`, because MP/PT detail and download actions are keyed to the plugin's visible numbers. Do not append your own “当前最高分候选” or “回复选择 N” footer when the plugin message already includes recommendation and next-step hints. + +When the current client has no MoviePilot MCP tools, do not announce an MCP fallback for `MP搜索` / `PT搜索`. Just call `python3 scripts/aro_request.py route "<原始用户命令>" --session ` and relay the returned message. + +When a user says `云盘搜索 <片名>`, route that exact text first. Do not silently replace it with `盘搜搜索 <片名>`. Cloud search is a distinct entry that should compare PanSou and HDHive together; if HDHive stays ambiguous, preserve the plugin's own `影巢结果` hint instead of collapsing everything into a PanSou-only recommendation. + +When a user says `更新 <片名>`, `更新检查 <片名>`, `查更新 <片名>`, or `检查 <片名>`, route that text directly first and treat it as the update-check entry. Do not clear the session first, do not guess that the user meant HDHive candidate search, and do not replace it with a generic search flow. The update flow should first show official reference progress plus PanSou and HDHive latest-episode resources, then let the user choose a numbered resource if needed. + +For update-check results, relay the plugin's returned message exactly. Preserve the emoji sections and item lines such as `🟨 盘搜结果`, `🟦 影巢结果`, `🗄 #25 夸克`, `📺 #1 115`, `🕒05/02`, and `📌 E01-E09`. Do not transform them into field-table prose like `#: ... 来源: ... 详情: ... 日期: ...`, and do not replace the list with a summary. + +When a user says `刷新影巢Cookie`, do not route that phrase into AgentResourceOfficer. Treat it as a host-side repair action and run: + +```bash +python3 scripts/aro_request.py hdhive-cookie-refresh +``` + +This command exports the current HDHive webpage cookie from the local browser, writes it back into MoviePilot and AgentResourceOfficer, and restarts `moviepilot-v2`. + +When a user says `修复影巢签到`, do not route that phrase directly. Run: + +```bash +python3 scripts/aro_request.py hdhive-checkin-repair +``` + +This command refreshes the HDHive webpage cookie from the local browser export tool, restarts `moviepilot-v2`, then retries one HDHive sign-in through AgentResourceOfficer. + +When `影巢签到` or `影巢签到日志` clearly shows cookie/login failure, prefer the automatic repair flow instead of asking the user to hand-copy cookies. First remind the user to ensure they are logged into `https://hdhive.com` in Edge, then run `hdhive-checkin-repair`, and finally show the new sign-in result. + +When a user says `刷新夸克Cookie`, do not route that phrase into AgentResourceOfficer. Treat it as a host-side repair action and run: + +```bash +python3 scripts/aro_request.py quark-cookie-refresh +``` + +This command exports the current Quark webpage cookie from the local browser, writes it back into MoviePilot and AgentResourceOfficer, and restarts `moviepilot-v2`. + +When a user says `修复夸克转存`, do not route that phrase directly. Prefer: + +```bash +python3 scripts/aro_request.py quark-transfer-repair --retry-text "<刚才失败的原始转存命令>" +``` + +If there is no safe transfer command to retry, run `python3 scripts/aro_request.py quark-transfer-repair` first to refresh the cookie and verify Quark health, then ask the user to retry the original transfer. + +Only use the Quark automatic repair flow when the failure clearly points to login/cookie problems, for example `require login [guest]`, `夸克登录态已过期`, or `当前夸克登录态不足`. Do not trigger it for share-link restrictions, deleted links, or ordinary 403/41031 share bans. + +For ordinary search, cloud search, HDHive resource lists, and update-check lists, preserve the plugin's original numbering exactly. Do not reformat a numbered resource list into unnumbered prose, do not collapse numbered items into a separate summary, and do not move the actionable numbers only into a later recommendation paragraph. Smart recommendations are welcome after the original list, and can be as detailed as useful, as long as they reference the original item numbers and do not replace the list. + +The helper's default `route` and `pick` commands print a chat-friendly plain text `message`. Relay that output directly to the user. If you need to parse structured fields programmatically, add `--json-output`; do not parse the plain display text and then reconstruct your own resource list. + +For numbered detail follow-ups, keep the detail action. `15详情`, `15 的详情`, `我要看看 15 的详情`, `十六详情`, and `详情十六` are read-only detail requests. They must not be changed into `选择 15` or a direct transfer/download command. + +For PanSou result lists, keep the source section headings (`🟦 115 结果`, `🟨 夸克结果`) and do not repeat provider tags inside every item. Display items as `编号. emoji 标题` rather than `编号. [115] ...` or `编号. [quark] ...`. Dates should keep the clock marker, for example `— 🕒05/07` or the returned `display_datetime`. Preserve physical line breaks between the source heading and each numbered item; if the chat frontend renders Markdown and may collapse normal line breaks, wrap the resource list itself in a fenced `text` block or insert real blank lines after each source heading so Quark items do not collapse into one paragraph. + +Do not show raw 115/Quark share links in search result lists. Links belong in the copy-friendly detail card returned by `选择 编号 详情`. + +For HDHive/影巢 resource lists, use the same source grouping style: `🟦 115 结果` and `🟨 夸克结果`. Keep each resource as plain numbered items like `1. emoji 标题 · 积分 · 大小 · 集数 · 规格`, not `#1`. Put a real blank line between resource items in Markdown-like chat frontends so WorkBuddy does not collapse the list into one paragraph. A recommendation section is allowed at the end, but keep it after the original list and reference original numbers. If the user needs a shareable link or full metadata, tell them to use `选择 编号 详情`; the detail card is the copy-friendly view. + +After displaying a resource list, add or preserve a `智能建议` section when the data is enough to compare quality. Do not over-constrain the recommendation length; explain the tradeoffs naturally around common viewing and storage decisions such as picture quality, episode completeness, subtitle clarity, file size, source reliability, and whether the user explicitly wants 115 or Quark. Do not expose raw score formulas such as `4K +25` as the main explanation. The only hard rule is that recommendations must reference the original item numbers and must not replace or renumber the original list. + +For cloud search results, prefer the plugin's raw combined layout: keep the `盘搜结果` section, keep the `影巢结果` section, and keep raw links when the plugin returned them. Do not hide the source-specific sections behind your own summary. A short recommendation is allowed only after the raw list and next-step hint. + +For cloud search, never renumber items per source in your own prose. If the plugin returned global numbering like `1..16` plus `17..24`, preserve that exact numbering. Do not convert it into separate `115 1..6 / 夸克 1..10` local indices, and do not collapse the response into a custom “标题/画质/日期/链接” table that drops the plugin's next-step instructions. + +When `影巢搜索` or `云盘搜索` falls back to PanSou because HDHive returned no usable result, keep the plugin's original fallback text and numbered resource list. Do not rewrite it into your own progress bulletin like “有新集了”“现在两边都有了” or a custom compact table that hides links, numbering, or next-step hints. + +When a Quark transfer fails, do not invent a path diagnosis unless the plugin explicitly said so. If the plugin only returned `夸克转存失败:无法转存到 /飞书`, treat the cause as unknown and do not add guesses like “默认转存目录不存在” or “换成 path=/ 试试” on your own. Only recommend a different path when the plugin itself clearly pointed to a path problem or the user explicitly asked to try another path. + +Use `config-check` to verify connection settings without printing secrets: + +```bash +python3 scripts/aro_request.py config-check +``` + +Use `readiness` after configuration to run config check, local selftest, and live plugin selfcheck together: + +```bash +python3 scripts/aro_request.py readiness +``` + +Update-check examples: + +```bash +python3 scripts/aro_request.py route "更新 大君夫人" +python3 scripts/aro_request.py route "更新检查 大君夫人" +python3 scripts/aro_request.py route "检查 大君夫人" +``` + +Quark cleanup examples: + +```bash +python3 scripts/aro_request.py route "清空夸克默认转存目录" +python3 scripts/aro_request.py route "清空夸克默认目录" +``` + +Use Quark cleanup only when the user explicitly asked to clear the Quark default transfer directory. Treat it as a destructive cloud-drive write. It targets the current layer entries of the configured Quark default directory: files are deleted directly, and current-layer folders are deleted together with their contents. Do not infer it from vague cleanup requests, do not silently replace it with 115 cleanup, and do not grep helper source to decide whether this command is supported. + +115 cleanup examples: + +```bash +python3 scripts/aro_request.py route "清空115转存目录" +python3 scripts/aro_request.py route "清空115默认转存目录" +python3 scripts/aro_request.py route "清空115默认目录" +``` + +Use 115 cleanup only when the user explicitly asked to clear the 115 default transfer directory. Treat it as a destructive cloud-drive write. It targets the current layer entries of the configured 115 default directory: files are deleted directly, and current-layer folders are deleted together with their contents. Do not grep helper source to decide whether this command is supported; route the original phrase directly. + +For update requests, do not start with: + +```bash +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py route "影巢搜索 大君夫人" +``` + +unless the user explicitly asked to abandon the current state or explicitly asked for HDHive-only search. + +For ordinary search and update requests, do not start with: + +```bash +python3 scripts/aro_request.py session-clear default +``` + +unless the user explicitly asked to clear or reset the session. + +Use `feishu-health` only when diagnosing the built-in AgentResourceOfficer Feishu Channel: + +```bash +python3 scripts/aro_request.py feishu-health +``` + +For MoviePilot's built-in Agent, use the native tool `agent_resource_officer_feishu_health` instead of calling the Feishu health API manually. + +## Core Startup Flow + +Fast path: + +```bash +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py doctor --limit 5 +``` + +`auto` calls `startup`, reads `recommended_request_templates`, then fetches the recommended low-token recipe. + +`decide` is the single low-token decision entry: + +- if there is a resumable session, it returns `decision=continue_session` +- otherwise it returns `decision=start_recipe` + +If you want the automatic flow but only need the decision summary, prefer: + +```bash +python3 scripts/aro_request.py auto --summary-only +``` + +`doctor` is the read-only diagnostic entry. It combines: + +- `assistant/startup` +- `assistant/selfcheck` +- `assistant/sessions` +- `assistant/recover` + +Use it when an external agent needs one compact bootstrap/health/recovery snapshot before deciding whether to start a new task or continue an old one. + +It also returns local helper suggestions: + +- `helper_commands.inspect_helper_command` +- `helper_commands.execute_helper_command` + +For `auto --summary-only` and `decide --summary-only`, the start-recipe branch also returns: + +- `inspect_helper_command` +- `execute_helper_command` + +If a caller only wants the next helper command, use: + +```bash +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py auto --command-only +python3 scripts/aro_request.py recover --command-only +python3 scripts/aro_request.py decide --command-only --confirmed +``` + +`--command-only` prints an inspect command only when the next action itself requires confirmation. If the current recipe starts with a safe read step, such as `mp_pt` or `recommend`, it prints that executable read command directly even when later write steps still require confirmation. + +If token budget is tight, prefer: + +```bash +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py recover --summary-only +``` + +Manual path: + +1. Call startup: + +```bash +python3 scripts/aro_request.py startup +``` + +2. Read `recommended_request_templates`. + +3. Fetch templates by the recommended recipe: + +```bash +python3 scripts/aro_request.py templates --recipe continue +``` + +If startup has recoverable state, it may recommend `continue`. Otherwise it normally recommends `bootstrap`. + +## Recipes + +Supported recipe names and short aliases: + +- `bootstrap` -> `safe_bootstrap` +- `plan` -> `plan_then_confirm` +- `maintain` -> `maintenance_cycle` +- `continue` -> `continue_existing_session` +- `preferences` / `prefs` / `片源偏好` / `偏好画像` -> `preferences_onboarding` +- `mp_pt` / `mp` / `pt` -> `mp_pt_mainline` +- `recommend` / `热门` / `推荐` -> `mp_recommendation` +- `local_ingest` / `ingest` / `local` / `本地入库` / `入库诊断` -> `local_ingest` + +Use: + +```bash +python3 scripts/aro_request.py templates --recipe plan --policy-only +python3 scripts/aro_request.py templates --recipe preferences --policy-only +python3 scripts/aro_request.py templates --recipe mp_pt --policy-only +python3 scripts/aro_request.py templates --recipe recommend --policy-only +``` + +The response includes: + +- `recommended_recipe` +- `recommended_recipe_detail.first_call` +- `recommended_recipe_detail.calls` +- `first_confirmation_template` +- `confirmation_message` +- `auth.mode=query_apikey` +- `url_template` + +## Main Interaction Flow + +For natural-language resource work, use `route`: + +```bash +python3 scripts/aro_request.py route --text "MP搜索 蜘蛛侠" +python3 scripts/aro_request.py route --text "影巢搜索 蜘蛛侠" +python3 scripts/aro_request.py route --text "盘搜搜索 大君夫人" +python3 scripts/aro_request.py route --text "链接 https://pan.quark.cn/s/xxxx path=/飞书" +``` + +For numbered continuation, use `pick`. Positional and flagged forms are both supported: + +```bash +python3 scripts/aro_request.py pick 1 +python3 scripts/aro_request.py pick 11 --path /飞书 +python3 scripts/aro_request.py pick 1 详情 +python3 scripts/aro_request.py pick 详情 +python3 scripts/aro_request.py pick 下一页 +``` + +Common diagnostic helpers also support shorter positional forms: + +```bash +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +For session inspection and recovery: + +```bash +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py recover --execute +python3 scripts/aro_request.py templates --recipe followup --compact +python3 scripts/aro_request.py history --limit 10 +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans --limit 10 +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans --executed --include-actions --limit 5 +python3 scripts/aro_request.py plan-execute plan-xxx +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py followup plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +Notes: + +- `sessions`, `history`, `plans`, and `recover` no longer force `session=default` when you do not pass `--session`. +- Use `--session` or `--session-id` only when you want to narrow to one conversation. +- Use `sessions --kind ...` or `sessions --has-pending-p115` when you want recovery-oriented filtering. +- Use `followup` after `plan-execute` when you want the plugin to choose the correct read-only next step automatically. +- Use `session-clear` or `sessions-clear` to clear abandoned assistant state after user confirmation. +- Use `plans-clear --plan-id ...` for exact saved-plan cleanup. Treat bulk cleanup flags as write-side-effect operations requiring confirmation. +- For long-lived WeChat, WorkBuddy, Claw, Hermes, or OpenClaw threads, stale compressed context can cause bad rewrites such as changing `15详情` into `选择 15`. When that happens, clear the current ARO session and saved plans, then reload this skill. Do not run session cleanup before ordinary search or update-check commands, because normal numbered follow-up depends on session continuity. + +Long-thread cleanup example: + +```bash +python3 scripts/aro_request.py session-clear --session default +python3 scripts/aro_request.py plans-clear --session default +``` + +## Preferences And Scoring + +Before the first automated resource task in a new user profile, check preferences: + +```bash +python3 scripts/aro_request.py preferences --session agent:<用户ID> +python3 scripts/aro_request.py templates --recipe preferences --compact +python3 scripts/aro_request.py scoring-policy +``` + +Most assistant responses also include compact `preference_status`. If `preference_status.needs_onboarding=true`, pause automation, ask the user for preferences, then save them before choosing downloads, unlocks, or transfers. + +Search responses may include compact `score_summary`. Prefer `score_summary.best` and `score_summary.top_recommendations` over parsing the natural-language message. Treat `hard_risk_reasons` as blocking automation; treat `risk_reasons` as warnings to explain before asking for confirmation. If `score_level=confirm`, explain the reasons and ask the user before executing. + +If `needs_onboarding=true`, ask the user for a compact preference profile and save it: + +```bash +python3 scripts/aro_request.py preferences --session agent:<用户ID> --preferences-json '{"prefer_resolution":"4K","prefer_dolby_vision":true,"prefer_hdr":true,"prefer_chinese_subtitle":true,"prefer_complete_series":true,"prefer_cloud_provider":"115","pt_require_free":false,"pt_min_seeders":3,"hdhive_max_unlock_points":20,"p115_default_path":"/待整理","quark_default_path":"/飞书","auto_ingest_enabled":false,"auto_ingest_score_threshold":90}' +``` + +You may also manage preferences through the main natural-language route: + +```bash +python3 scripts/aro_request.py route --text "偏好" --session agent:<用户ID> +python3 scripts/aro_request.py route --text "保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库" --session agent:<用户ID> +python3 scripts/aro_request.py route --text "重置偏好" --session agent:<用户ID> +``` + +Scoring rules are source-specific and plugin-owned. Use `scoring-policy` or `capabilities` to read the current policy when you need to explain the rules to the user. Do not invent a separate score in the agent. + +- Cloud resources: HDHive, PanSou 115, PanSou Quark, direct 115/Quark links. Score quality, Dolby Vision/HDR, subtitles, completeness, file size, drive preference, and target directory. HDHive also checks point cost. +- PT resources: MoviePilot native site search/download/subscribe. Score seeders, free/promo status, volume factor, resolution, Dolby Vision/HDR, subtitles, release group/site, size, and title match. +- PT seeders are a hard gate. Default minimum is `3`; seeders `0` means never auto-download. +- HDHive point cost is a hard gate. Default max is `20`; unknown points cannot auto-unlock. +- Auto ingest is off by default. Even when `can_auto_execute=true`, the current PT interaction policy should still prefer `plan_id` first unless an internal system path explicitly executes the saved plan. + +For MP native workflows: + +```bash +python3 scripts/aro_request.py workflow --workflow mp_search --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_download_history --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_lifecycle_status --keyword "蜘蛛侠" --limit 5 +python3 scripts/aro_request.py workflow --workflow mp_ingest_status --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_ingest_failures --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_recent_activity --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_local_diagnose --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_subscribe --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_transfer_history --keyword "蜘蛛侠" --status all --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode mp +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode pansou +``` + +`mp_search_download`, `mp_subscribe`, and `mp_subscribe_and_search` are write-side-effect workflows. They should return a saved `plan_id` first; execute with `plan-execute` only after the user confirms. + +`mp_transfer_history` is read-only. Use it after downloads or transfers to check whether MoviePilot has already organized the media into the library. Prefer the structured `items` fields and path previews; do not ask for full local paths unless the user explicitly needs troubleshooting detail. + +`mp_download_history` is read-only. Use it before `mp_transfer_history` when the user asks whether a PT/native MP resource was ever submitted for download. It also reports a compact transfer status when the download hash can be linked to MoviePilot transfer history. + +`mp_lifecycle_status` is read-only and should be the default troubleshooting query for “where is this resource now?”. It combines active download tasks, download history, and transfer/import history in one call. + +`mp_ingest_status` is read-only and should be the shortest answer path for “has this PT/local resource entered the library yet?”. It returns a structured `diagnosis_summary` with `stage`, `confidence`, `evidence`, `risk_reasons`, `recommended_action`, and `follow_up_hint`. + +`mp_ingest_failures` is read-only and focuses on transfer/import failures. Use it when the user asks “why did this fail to ingest?” or wants the recent failed records without reading the full transfer history. + +`mp_recent_activity` is read-only and gives a quick view of recent downloads and recent ingest activity. Use it when there is no exact title yet and the user asks what MoviePilot did recently. + +`mp_local_diagnose` is read-only and should be the one-stop path for “为什么没入库 / where is it stuck locally?”. Prefer it after `mp_ingest_status` or execution follow-up when the plugin already detected failure clues. + +`mp_media_detail` is read-only. Use it before search/download/subscribe when the title is ambiguous or the agent needs to confirm MoviePilot's native media recognition, TMDB/Douban/IMDB IDs, year, and media type. + +`mp_search_detail` is read-only. Use it after or together with MP native search when the user wants to inspect a numbered PT candidate. It shows seeders, promotion, size, score reasons, and risks. Do not download from this detail step; ask for confirmation or generate a plan before downloading. + +`mp_search_best` is read-only and token-efficient. Use it when the user asks the agent to recommend the best PT candidate after MP native search. It searches, ranks by the plugin-owned score, and returns the best candidate detail. It still does not download. + +After an MP search session, `下载最佳` generates a saved download plan for the current highest-scoring PT candidate. It does not download immediately; after user confirmation, execute the returned `plan_id` with `plan-execute`, route the natural text `执行计划` / `执行 plan-...`, or route the same resource number again when the plugin prompt says that number can confirm the pending plan. Then prefer `followup` so the plugin itself can decide whether the best next read is download history, lifecycle, subscribes, or transfer history. + +Even if a PT candidate scores high, the current default interaction policy is still `plan_id` first. Treat `can_auto_execute` as a score signal for explanation only; do not assume `下载1` or `下载最佳` will bypass confirmation. + +For cloud-drive result sessions, `最佳片源` is read-only. It returns the highest-scoring PanSou or HDHive resource detail and must not transfer or unlock by itself. `选择 N 详情` is also read-only. For ordinary `搜索/找 <片名>` sessions, prefer direct numbered picks first and use `计划选择 N` only when the user explicitly wants a saved confirmation plan. Use direct `选择 N` for immediate transfer/unlock after the user confirms that intent. + +For ordinary `搜索/找 <片名>` sessions, relay the plugin's original numbered list and next-step hints first. You may add a smart recommendation after the list, including a shortlist or tradeoff explanation, but do not replace, renumber, or hide the original list body. + +`mp_recommend_search` is the low-token recommendation chain. Without `choice`, it returns a recommendation list and stores the session. With `choice`, it immediately continues the selected title into `mode=mp`, `mode=hdhive`, or `mode=pansou`. + +After a recommendation list, natural-language picks are valid: + +```text +选择 1 +计划选择 1 +选择 1 盘搜 +选择1影巢 +选 2 mp +``` + +After an MP native search result, natural-language write commands are valid. They still follow the plugin's confirmation/plan rules: + +```text +下载1 +下载第1个 +订阅蜘蛛侠 +订阅并搜索蜘蛛侠 +``` + +Download task management also uses the same route. Querying tasks is read-only. Pausing, resuming, and deleting tasks are write actions and should return a saved `plan_id` first: + +```text +下载任务 +记录 +记录 蜘蛛侠 +状态 蜘蛛侠 +入库 蜘蛛侠 +整理失败 蜘蛛侠 +最近 +最近下载 +诊断 蜘蛛侠 +后续 +跟进 +跟进 蜘蛛侠 +识别 蜘蛛侠 +选择 1 +最佳片源 +下载最佳 +暂停下载 1 +恢复下载 1 +删除下载 1 +``` + +PT environment diagnostics are read-only and safe. Site results are sanitized and must not expose cookies: + +```text +站点状态 +下载器状态 +``` + +MP subscription management follows the same rule. Querying subscriptions is read-only; searching, pausing, resuming, and deleting subscriptions are write actions and should return a saved `plan_id` first: + +```text +订阅列表 +搜索订阅 1 +暂停订阅 1 +恢复订阅 1 +删除订阅 1 +``` + +Transfer/import history is read-only and safe. Use it to answer “did this land in the library?”: + +```text +入库历史 +入库失败 蜘蛛侠 +整理成功 地狱乐 +``` + +Natural-language route examples that should call recommendations: + +```text +看看最近有什么热门影视 +热门电影 +豆瓣热门电影 +正在热映 +今日番剧 +``` + +## Confirmation Rules + +Do not execute confirmation-required calls silently. + +If `recommended_recipe_detail.confirmation_message` says a step needs confirmation, show that message to the user before executing that step. + +Common confirmation points: + +- `saved_plan_execute` +- `maintain_execute` +- `pick_continue` +- `mp_search_download` +- `mp_subscribe` +- `mp_subscribe_and_search` + +## Maintenance And Health + +Use selfcheck for protocol health: + +```bash +python3 scripts/aro_request.py selfcheck +``` + +Preview maintenance without writing: + +```bash +python3 scripts/aro_request.py maintain +``` + +Execute maintenance only after confirmation: + +```bash +python3 scripts/aro_request.py maintain --execute +``` + +## Guardrails + +- Do not call HDHive, 115, Quark, or PanSou raw APIs directly when `AgentResourceOfficer` can handle the workflow. +- Do not unlock paid resources or execute write-side-effect calls without explicit confirmation. +- Respect `hdhive_resource_enabled` and `hdhive_max_unlock_points` returned by readiness/capabilities. The default point limit is 20. If a HDHive resource is above the limit or the plugin cannot confirm its points, tell the user the exact point cost/risk and ask them to raise the limit or set it to 0 before retrying. Do not bypass the guardrail. +- Prefer `include_templates=false` for low token startup. +- Use full templates only when parameters are unclear. +- Keep user-facing output short: show options, ask for a number, report result. + +## Relationship To MoviePilot-Skill + +`MoviePilot-Skill` is useful for MP native API operations such as subscriptions, downloads, sites, storage, and dashboard data. + +This skill is for the resource workflow hub: + +- HDHive search and unlock +- PanSou search +- 115 and Quark share routing +- MP native search/download/subscribe/recommendation orchestration +- cloud/PT scoring and preference-aware automation advice +- 115 login/status/pending tasks +- session recovery +- recipe-guided assistant calls + +Use both together when needed, but keep their auth modes separate: + +- AgentResourceOfficer plugin endpoints: `?apikey=MP_API_TOKEN` +- MP native API skill: usually `X-API-KEY` diff --git a/plugins/agentresourceofficer/ARCHITECTURE.md b/plugins/agentresourceofficer/ARCHITECTURE.md new file mode 100644 index 0000000..8734421 --- /dev/null +++ b/plugins/agentresourceofficer/ARCHITECTURE.md @@ -0,0 +1,223 @@ +# Agent影视助手架构草案 + +`Agent影视助手` 是重构后的资源工作流主插件,重点不是把旧代码简单拼一起,而是把职责重新压平。 + +## 设计目标 + +- 一个插件承接“搜索 -> 选择 -> 解锁 -> 转存 -> 签到/用户态 -> 远程入口” +- 智能体、飞书、CLI、后续 MP Agent Tool 共享同一套执行服务 +- 会话交互与底层执行解耦,避免继续把大量业务逻辑堆在消息入口层 + +## 模块分层 + +### 1. adapters + +负责不同外部入口和外部平台接入: + +- `feishu` +- `hdhive` +- `quark` +- `pansou` +- 后续 `agent_tool` + +原则: + +- 只负责协议和输入输出转换 +- 不负责复杂业务编排 + +### 2. services + +负责核心业务能力: + +- `search_service` +- `unlock_service` +- `transfer_service` +- `signin_service` +- `user_service` + +原则: + +- 统一返回结构 +- 尽量不感知飞书、页面、CLI 等具体入口 + +### 3. session + +负责交互上下文: + +- 搜索候选缓存 +- 翻页状态 +- 选择上下文 +- 详情/审查补充信息(已支持候选页按需补主演) + +原则: + +- 入口层共享同一套会话数据 +- 后续优先支持内存 + 轻量持久化 + +### 4. models + +负责统一数据模型: + +- 搜索候选 +- 资源条目 +- 解锁结果 +- 转存结果 +- 用户信息 + +目标: + +- 减少旧插件之间字段名不一致的问题 + +## 首期配置模型 + +### 基础 + +- `enabled` +- `notify` +- `debug` + +### 影巢 + +- `hdhive_base_url` +- `hdhive_api_key` +- `hdhive_default_path` +- `hdhive_candidate_page_size` + +### 夸克 + +- `quark_cookie` +- `quark_default_path` +- `quark_timeout` +- `quark_auto_import_cookiecloud` + +### 飞书 + +- `feishu_enabled` +- `feishu_app_id` +- `feishu_app_secret` +- `feishu_verification_token` +- `feishu_allow_all` +- `feishu_allowed_chat_ids` +- `feishu_allowed_user_ids` + +### 智能体 / 工具层预留 + +- `agent_tools_enabled` +- `tool_debug` + +## 迁移映射 + +### 从 `QuarkShareSaver` + +优先迁入: + +- 分享链接解析 +- 目录创建 +- 转存执行 +- CookieCloud 自动导入 + +当前已开始拆出: + +- `services/quark_transfer.py` + +### 从 `P115StrmHelper` 协同层 + +当前已开始拆出: + +- `services/p115_transfer.py` + +### 从 `HdhiveOpenApi` + +随后迁入: + +- 搜索 +- 候选解析 +- 解锁 +- 用户信息 +- 配额 +- 分享管理 + +当前已开始拆出: + +- `services/hdhive_openapi.py` + +### 从 `HDHiveDailySign` + +补入: + +- 普通签到 +- 赌狗签到 +- 自动登录与状态记录 + +### 从 `FeishuCommandBridgeLong` + +最后收口: + +- 飞书长连接入口 +- 自然语言别名解析 +- 搜索/选择会话衔接 + +## 暂不迁入的内容 + +- `P115StrmHelper` 仍作为 115 落地执行层保留,不直接并入 `Agent影视助手` + +> 更新说明:PT 搜索、下载、订阅、推荐、入库追踪相关工作流已经收口到 `Agent影视助手` 主线,不再依赖旧桥接插件作为主入口。 + +## P115StrmHelper 兼容补丁 + +新版 MoviePilot 移除了旧版 `TransferOverwriteCheck` 事件时,部分 `P115StrmHelper` 版本会因为导入 `TransferOverwriteCheckEventData` 失败而无法加载,进而导致 115 自动转存不可用。 + +仓库提供了幂等补丁脚本: + +```bash +MP_CONTAINER=moviepilot-v2 ./scripts/patch-p115strmhelper-mp-compat.sh +``` + +补丁只跳过缺失事件的注册,不改动 `P115StrmHelper` 的分享转存主流程。运行环境已验证 `AgentResourceOfficer` 的 `p115/health` 可返回 `p115_ready=true`。 + +## 115 轻量直转层 + +`Agent影视助手` 从 `0.1.17` 开始支持 115 分享链接轻量直转 + 扫码会话登录: + +- 支持生成和轮询 `p115client` 同款 115 扫码二维码,拿到 `UID / CID / SEID / KID` 这类客户端会话后自动写回插件配置 +- 配置扫码得到的 115 会话时,直接用该会话创建 115 客户端并调用 `share_receive` +- 未配置独立扫码会话时,优先复用已加载的 115 客户端,不再必须走 `sharetransferhelper` +- 直转失败时回退 `P115StrmHelper` 的分享转存主流程 + +这个能力只负责“分享链接落到 115 目标目录”。STRM 生成、302、增量/全量同步、媒体库整理仍保持由 `P115StrmHelper` 承担。 +这里特意没有走网页版 CookieCloud,也没有直接拿 MP 系统内置的 `u115` OAuth Token 来代替扫码会话,因为分享转存链路仍然更适合复用 `p115client` 的客户端会话模型。 + +## 首个里程碑 + +第一个可用版本只追求三件事: + +1. 夸克分享链接直接转存 +2. 影巢搜索并解锁 +3. 飞书调用同一套执行服务 + +当前进度: + +- 已拆出夸克执行服务 +- 已拆出影巢基础 OpenAPI 服务 +- 已拆出 115 转存执行服务 +- 已补上 Agent影视助手 自己的统一智能入口(assistant route / pick) +- 主插件已具备: + - 夸克健康检查 + - 夸克转存 + - 影巢健康检查 + - 影巢搜索 + - 影巢关键词候选搜索 + - 影巢解锁 + - 115 依赖健康检查 + - 115 分享转存 + - 影巢解锁后自动路由到夸克执行层 + - 影巢解锁后自动路由到 115 执行层 + - 影巢会话搜索与按编号继续选择 + - 盘搜搜索与按编号继续执行 +- 统一智能入口对直链、盘搜、影巢三类输入的会话分流 +- 原生 Agent Tool 直接发起和轮询 115 扫码登录 +- 智能入口 `assistant/route` 可直接理解 `115登录` / `检查115登录` +- 扫码登录成功后可直接返回 115 运行状态摘要,便于飞书与 MP 智能助手继续执行 +- 智能入口与原生 Agent Tool 都可直接返回 `115状态` 摘要,不依赖是否存在待检查会话 +- 待继续的 115 任务已具备轻量持久化、时间/重试/错误摘要,并提供查看、继续、取消三个原生 Agent Tool 和标准 API +- `115状态` / `检查115登录` / `115帮助` 统一补充下一步建议,减少人工猜测下一条命令 diff --git a/plugins/agentresourceofficer/README.md b/plugins/agentresourceofficer/README.md new file mode 100644 index 0000000..59122a0 --- /dev/null +++ b/plugins/agentresourceofficer/README.md @@ -0,0 +1,212 @@ +# Agent影视助手 + +`Agent影视助手` 是这个仓库的主线插件,重点解决一件事: + +把 `飞书命令入口`、`外部智能体`、`盘搜`、`影巢`、`115`、`夸克`、`MoviePilot 原生搜索 / PT 下载` 收进同一套稳定工作流。 + +当前版本:`0.2.68` + +当前 helper 版本:`0.1.46` + +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + +如果你是第一次用这个仓库,先把这个插件跑通就够了。 + +--- + +## 适合谁 + +- 你想把飞书当成类似 `TG / 企业微信` 的资源命令入口。 +- 你想让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体稳定控制 MoviePilot。 +- 你想统一处理“找资源 -> 选资源 -> 转存到 115 / 夸克”的流程。 +- 你也想把 MoviePilot 原生 `MP搜索 / PT搜索 / 下载 / 订阅 / 更新检查` 放进同一套命令入口。 +- 你希望智能体不要自己乱拼影巢、盘搜、115、夸克接口,而是统一交给插件执行。 + +--- + +## 两种主要用法 + +### 1. 不使用外部智能体,只用飞书命令入口 + +如果你不想接外部智能体,只想要一个命令窗口,可以只配置飞书。 + +配好后,直接在飞书里发: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +这种用法更像 TG / 企业微信机器人入口:飞书负责收消息,插件负责执行。 + +### 2. 使用外部智能体 + +如果你要接 `OpenClaw`、`Hermes`、`WorkBuddy`,建议安装 `agent-resource-officer skill / helper`。 + +外部智能体负责理解用户需求和展示结果;资源搜索、转存、下载、签到、Cookie 修复都交给插件。 + +重点文档: + +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` + +### MCP 和 Skill 怎么分工 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,可以一起接。 + +- MCP 更适合查 MoviePilot 管理信息,比如插件列表、下载器状态、站点状态、历史记录、工作流。 +- `agent-resource-officer skill / helper` 更适合资源流,比如盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情和 Cookie 修复。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也建议优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +--- + +## 核心命令 + +### 搜索 + +| 命令 | 作用 | +|---|---| +| `搜索 <片名>` | 默认走盘搜 | +| `盘搜搜索 <片名>` | 只看盘搜 | +| `影巢搜索 <片名>` | 只看影巢 | +| `云盘搜索 <片名>` | 盘搜 + 影巢 | +| `MP搜索 <片名>` / `PT搜索 <片名>` | 走 MoviePilot 原生搜索 / PT 搜索 | + +### 转存 / 下载 + +| 命令 | 作用 | +|---|---| +| `转存 <片名>` | 默认等同 `115转存 <片名>` | +| `115转存 <片名>` | 搜索后优先转存到 115 | +| `夸克转存 <片名>` | 搜索后优先转存到夸克 | +| `下载 <片名>` | 走 MoviePilot 原生 PT 下载链,先生成下载计划 | + +注意: + +- `转存 <片名>` 默认是 115,不会自动改成夸克。 +- 只有明确说 `夸克转存 <片名>` 才走夸克。 +- `下载 <片名>` 是 PT 下载,不是云盘转存。 +- `下载1` 是给当前 PT 结果生成下载计划,不是确认旧计划。 +- 真正下载、转存、解锁、清空目录这类写入动作,都应先经过明确确认。 + +### 选择 / 翻页 + +```text +1 +1详情 +下载1 +n +``` + +- `1`:继续处理当前第 1 条结果。 +- `1详情`:查看第 1 条详情。 +- `下载1`:给第 1 条 PT 结果生成下载计划。 +- `n`:下一页。 + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 主要能力 + +### 云盘资源 + +- 盘搜搜索 +- 影巢搜索 / 解锁 +- 115 转存 +- 夸克转存 +- 云盘更新检查 +- 编号选择、详情、翻页 +- 智能建议与候选推荐 + +### MoviePilot 原生能力 + +- MP / PT 搜索 +- PT 下载计划 +- 订阅 +- 下载任务 +- 下载历史 +- 入库历史 +- 站点状态 / 下载器状态 +- 热门探索 / 推荐 + +### 账号与修复 + +- 115 扫码登录 / 状态检查 +- 影巢签到 / 签到日志 +- 影巢 Cookie 修复 +- 夸克 Cookie 修复 + +Cookie 修复会用到本机浏览器登录态。如果 MoviePilot 在 NAS、智能体在电脑上,修复命令读取的是智能体电脑上的浏览器 Cookie,再写回 NAS 上的 MoviePilot。 + +--- + +## 和旧插件的关系 + +`Agent影视助手` 是把旧的分散能力收成一条主线。 + +| 旧插件 | 主要用途 | 现在建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 夸克独立转存 | 主能力已收进 Agent影视助手 | +| `HDHiveDailySign` | 旧影巢签到兜底 | 新环境优先走 Agent影视助手修复链 | + +旧组合仍然能用,但更适合兼容老环境;新装建议优先用 `Agent影视助手`。 + +--- + +## 新手最容易踩的坑 + +### 外部智能体乱改命令 + +常见错误: + +- 把 `云盘搜索` 偷换成 `盘搜搜索` +- 把 `下载` 当成云盘转存 +- 把 `15详情` 当成 `选择 15` +- 重排插件返回的编号 + +解决方式:让智能体安装并读取 `agent-resource-officer skill`。长线程跑偏时,直接对智能体说: + +```text +校准影视技能 +``` + +### 跨机器地址填错 + +如果 MoviePilot 在 NAS,智能体在电脑上,`ARO_BASE_URL` 要填 NAS 地址: + +```text +ARO_BASE_URL=http://你的NAS地址:3000 +``` + +不要填 `127.0.0.1`,那只代表智能体自己这台机器。 + +### 夸克失败不一定是 Cookie 失效 + +分享受限、分享者封禁、`41031` 不一定是 Cookie 问题。只有明确提示登录态失效时,才优先走夸克 Cookie 修复。 + +--- + +## 进一步阅读 + +- [插件安装说明](../docs/PLUGIN_INSTALL.md) +- [外部智能体接入](../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- [跨机器部署](../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- 全部命令:`docs/ALL_COMMANDS.md` diff --git a/plugins/agentresourceofficer/__init__.py b/plugins/agentresourceofficer/__init__.py new file mode 100644 index 0000000..53ffc7d --- /dev/null +++ b/plugins/agentresourceofficer/__init__.py @@ -0,0 +1,26967 @@ +import asyncio +import concurrent.futures +import copy +import hmac +import json +import os +import re +import threading +import time +import uuid +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen + +from fastapi import Request +try: + from apscheduler.triggers.cron import CronTrigger +except Exception: + CronTrigger = None +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.log import logger +except Exception: + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None +try: + from app.agent.tools.manager import moviepilot_tool_manager +except Exception: + moviepilot_tool_manager = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None +try: + from app import schemas as app_schemas +except Exception: + app_schemas = None +from app.plugins import _PluginBase + +from .services.hdhive_openapi import HDHiveOpenApiService +from .services.p115_transfer import P115TransferService +from .services.quark_transfer import QuarkTransferService +from .feishu_channel import FeishuChannel +from .agenttool import ( + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantMaintainTool, + AssistantPickTool, + AssistantPlansClearTool, + AssistantPlansTool, + AssistantPulseTool, + AssistantReadinessTool, + AssistantRecoverTool, + AssistantRequestTemplatesTool, + AssistantRouteTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSelfcheckTool, + AssistantStartupTool, + AssistantToolboxTool, + AssistantWorkflowTool, + FeishuChannelHealthTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + P115CancelPendingTool, + P115PendingTool, + P115QRCodeCheckTool, + P115QRCodeStartTool, + P115ResumePendingTool, + P115StatusTool, + ShareRouteTool, +) + + +class _JsonRequestShim: + def __init__(self, request: Request, body: Dict[str, Any], method: str = "POST") -> None: + self.method = str(method or "POST").upper() + self.headers = request.headers + self.query_params = request.query_params + self._body = body + + async def json(self) -> Dict[str, Any]: + return self._body + + +class _RequestContextShim: + def __init__(self, headers: Optional[Dict[str, Any]] = None, query_params: Optional[Dict[str, Any]] = None) -> None: + default_headers = dict(headers or {}) + if settings is not None and not default_headers.get("Authorization"): + token = str(getattr(settings, "API_TOKEN", "") or "").strip() + if token: + default_headers["Authorization"] = f"Bearer {token}" + self.headers = default_headers + self.query_params = query_params or {} + + +class AgentResourceOfficer(_PluginBase): + plugin_name = "Agent影视助手" + plugin_desc = "统一承接影巢搜索/解锁、115 转存、夸克转存、飞书入口与智能体接口的资源工作流主插件。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png" + plugin_version = "0.2.68" + request_templates_schema_version = "request_templates.v1" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "agentresourceofficer_" + plugin_order = 40 + auth_level = 1 + + _enabled = False + _notify = True + _debug = False + _quark_cookie = "" + _quark_default_path = "/飞书" + _quark_timeout = 30 + _quark_auto_import_cookiecloud = True + _pansou_base_url = "http://127.0.0.1:805" + _pansou_timeout = 20 + _hdhive_api_key = "" + _hdhive_base_url = "https://hdhive.com" + _hdhive_timeout = 30 + _hdhive_default_path = "/待整理" + _assistant_result_page_size = 10 + _assistant_cloud_result_page_size = 20 + _hdhive_candidate_page_size = 20 + _hdhive_resource_enabled = True + _hdhive_max_unlock_points = 20 + _hdhive_checkin_enabled = False + _hdhive_checkin_gambler_mode = False + _hdhive_checkin_once = False + _hdhive_checkin_cron = "0 8 * * *" + _hdhive_checkin_cookie = "" + _hdhive_checkin_auto_login = True + _hdhive_checkin_username = "" + _hdhive_checkin_password = "" + _p115_default_path = "/待整理" + _p115_client_type = "alipaymini" + _p115_cookie = "" + _p115_prefer_direct = True + _mp_download_save_path = "" + _assistant_default_pt_min_seeders = 3 + _assistant_default_auto_ingest_enabled = False + _assistant_default_auto_ingest_score_threshold = 90 + _assistant_default_confirm_score_threshold = 70 + _feishu_enabled = False + _feishu_allow_all = False + _feishu_reply_enabled = True + _feishu_reply_receive_id_type = "chat_id" + _feishu_app_id = "" + _feishu_app_secret = "" + _feishu_verification_token = "" + _feishu_allowed_chat_ids: List[str] = [] + _feishu_allowed_user_ids: List[str] = [] + _feishu_command_whitelist: List[str] = [] + _feishu_command_aliases = "" + _feishu_command_mode = "resource_officer" + + _quark_service: Optional[QuarkTransferService] = None + _hdhive_service: Optional[HDHiveOpenApiService] = None + _p115_service: Optional[P115TransferService] = None + _feishu_channel: Optional[FeishuChannel] = None + _session_cache: Dict[str, Dict[str, Any]] = {} + _session_lock = threading.RLock() + _agent_tools_reloaded = False + _candidate_actor_cache: Dict[str, List[str]] = {} + _candidate_actor_cache_lock = threading.Lock() + _session_store_key = "assistant_session_cache" + _session_retention_seconds = 7 * 24 * 60 * 60 + _execution_history_store_key = "assistant_execution_history" + _execution_history_limit = 100 + _execution_history: List[Dict[str, Any]] = [] + _workflow_plan_store_key = "assistant_workflow_plans" + _workflow_plan_limit = 50 + _workflow_plans: Dict[str, Dict[str, Any]] = {} + _assistant_preferences_store_key = "assistant_preferences" + _assistant_preferences_limit = 100 + _assistant_preferences: Dict[str, Dict[str, Any]] = {} + _hdhive_checkin_history_store_key = "hdhive_checkin_history" + _hdhive_checkin_history_limit = 60 + _hdhive_checkin_history: List[Dict[str, Any]] = [] + _agent_tools_reload_lock = threading.Lock() + _agent_tools_reload_version = "" + _agent_tools_reload_at = 0.0 + + @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 _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_display_datetime(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if text.startswith("🕒"): + return text + match = re.match(r"^\d{4}[/-](\d{2}[/-]\d{2})(?:\b|$)", text) + if match: + text = match.group(1).replace("-", "/") + return f"🕒{text}" + + @staticmethod + def _normalize_search_prefix(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) + raw = re.sub(r"\s+", " ", raw).strip() + for pattern in ( + r"^(?:mp|pt)\s*搜索\s*(.*)$", + r"^原生\s*搜索\s*(.*)$", + ): + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return "mp", match.group(1).strip() + mappings = [ + ("更新检查", "update"), + ("更新搜索", "update"), + ("查更新", "update"), + ("更新", "update"), + ("资源决策", "smart_decision"), + ("智能决策", "smart_decision"), + ("智能执行", "smart_execute"), + ("智能搜执行", "smart_execute"), + ("智能计划", "smart_plan"), + ("智能搜计划", "smart_plan"), + ("云盘搜索", "cloud"), + ("云盘搜", "cloud"), + ("智能搜索", "smart"), + ("智能搜", "smart"), + ("MP搜索", "mp"), + ("MP 搜索", "mp"), + ("PT搜索", "mp"), + ("PT 搜索", "mp"), + ("pt搜索", "mp"), + ("pt 搜索", "mp"), + ("原生搜索", "mp"), + ("原生 搜索", "mp"), + ("搜索资源", "pansou"), + ("找资源", "pansou"), + ("搜索", "pansou"), + ("找", "pansou"), + ("1搜索", "pansou"), + ("2搜索", "hdhive"), + ("影巢搜索", "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 + if raw.startswith("检查 "): + remain = raw[len("检查"):].strip() + if remain: + return "update", remain + return "", raw + + @staticmethod + def _extract_smart_decision_intent(text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + patterns = [ + ("execute_now", [ + r"(?:直接|立即|马上|立刻)?确认执行$", + r"(?:直接|立即|马上|立刻)?确认$", + r"(?:直接|立即|马上|立刻)?执行$", + r"(?:直接|立即|马上|立刻)?下载$", + r"(?:直接|立即|马上|立刻)?转存$", + r"(?:直接|立即|马上|立刻)?解锁$", + r"(?:直接|立即|马上|立刻)?处理$", + ]), + ("make_plan", [ + r"(?:先)?生成计划$", + r"(?:先)?做计划$", + r"(?:先)?出计划$", + r"(?:先)?计划$", + r"(?:先)?待确认计划$", + ]), + ("show_detail", [ + r"详情$", + r"(?:先)?看详情$", + r"(?:先)?看一下$", + r"(?:先)?看看$", + r"(?:只)?看推荐$", + r"(?:只)?看结果$", + r"(?:先)?推荐一下$", + ]), + ] + compact = re.sub(r"\s+", "", raw) + for intent, tokens in patterns: + for token in tokens: + if re.search(token, compact, flags=re.IGNORECASE): + cleaned = re.sub(token, "", compact, flags=re.IGNORECASE).strip() + return cleaned or raw, intent + return raw, "" + + @classmethod + def _extract_mp_result_filter_intent(cls, text: str) -> Tuple[str, str]: + raw = str(text or "").strip() + if not raw: + return "", "" + episode_patterns = [ + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*s\d{1,2}\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*(\d{1,3})\s*集\s*(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*([零〇一二两三四五六七八九十]{1,4})\s*集\s*(?:资源|结果)?", + ] + for pattern in episode_patterns: + match = re.search(pattern, raw, flags=re.IGNORECASE) + if not match: + continue + raw_episode = match.group(1) + if str(raw_episode).isdigit(): + episode = int(raw_episode) + else: + episode = cls._parse_simple_cjk_number(str(raw_episode)) or 0 + if episode <= 0: + continue + cleaned = (raw[:match.start()] + " " + raw[match.end():]).strip(" ::,,。") + cleaned = re.sub(r"\s+", " ", cleaned).strip() + return cleaned or raw, f"episode:{episode}" + patterns = [ + r"(?:给我|帮我|只看|看看|看一下)?最新(?:一)?集(?:资源|结果)?", + r"(?:给我|帮我|只看|看看|看一下)?最新(?:两|2)集(?:资源|结果)?", + r"只(?:要|看)(?:当前)?最新", + ] + cleaned = raw + matched = False + for pattern in patterns: + new_value = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE) + if new_value != cleaned: + matched = True + cleaned = new_value + cleaned = re.sub(r"\s+", " ", cleaned).strip(" ::,,。") + return (cleaned or raw, "latest_episode") if matched else (raw, "") + + async def _assistant_smart_decision_followup_detail( + self, + request, + *, + session: str, + cache_key: str, + keyword: str, + compact: bool = False, + apikey: str = "", + media_type: str = "", + year: str = "", + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + ): + decision_result = await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + ) + state = self._load_session(cache_key) + if not isinstance(state, dict) or self._clean_text(state.get("kind")) != "assistant_smart_search": + return decision_result + return await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": apikey, + }) + ) + + @staticmethod + def _match_command_prefix(raw: str, prefixes: List[str]) -> Optional[Tuple[str, str]]: + text = str(raw or "").strip() + for prefix in prefixes: + if not text.startswith(prefix): + continue + remain = text[len(prefix):] + if not remain: + return prefix, "" + return prefix, remain.lstrip(" ::").strip() + return None + + @staticmethod + def _normalize_mp_recommend_request(value: Any, default_source: str = "tmdb_trending") -> Tuple[str, str]: + raw = str(value or "").strip() + compact = re.sub(r"[\s,。?!!?,、::]+", "", raw).lower() + allowed_sources = { + "tmdb_trending", + "tmdb_movies", + "tmdb_tvs", + "douban_hot", + "douban_movie_hot", + "douban_tv_hot", + "douban_showing", + "douban_movie_showing", + "douban_movie_top250", + "douban_tv_animation", + "bangumi_calendar", + } + if compact in allowed_sources: + if compact == "douban_showing": + return "douban_movie_showing", "movie" + return compact, "all" + source_aliases = { + "trending": ("tmdb_trending", "all"), + "tmdb": ("tmdb_trending", "all"), + "tmdb热门": ("tmdb_trending", "all"), + "tmdb电影": ("tmdb_movies", "movie"), + "tmdb剧集": ("tmdb_tvs", "tv"), + "tmdb电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "豆瓣电影": ("douban_movie_hot", "movie"), + "豆瓣热门电影": ("douban_movie_hot", "movie"), + "豆瓣热映": ("douban_movie_showing", "movie"), + "豆瓣电视剧": ("douban_tv_hot", "tv"), + "豆瓣剧集": ("douban_tv_hot", "tv"), + "豆瓣top250": ("douban_movie_top250", "movie"), + "top250": ("douban_movie_top250", "movie"), + "douban_showing": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "热映": ("douban_movie_showing", "movie"), + "院线": ("douban_movie_showing", "movie"), + "bangumi": ("bangumi_calendar", "tv"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "每日放送": ("bangumi_calendar", "tv"), + "动画番剧": ("bangumi_calendar", "tv"), + } + if compact in source_aliases: + return source_aliases[compact] + if "top250" in compact: + return "douban_movie_top250", "movie" + if any(token in compact for token in ["bangumi", "番剧", "每日放送", "今日放送", "今日动画"]): + return "bangumi_calendar", "tv" + if any(token in compact for token in ["正在热映", "热映", "院线"]): + return "douban_movie_showing", "movie" + if "豆瓣" in compact: + if "动画" in compact: + return "douban_tv_animation", "tv" + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "douban_tv_hot", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "douban_movie_hot", "movie" + return "douban_hot", "all" + if "tmdb" in compact: + if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "movie"]): + return "tmdb_movies", "movie" + return "tmdb_trending", "all" + if any(token in compact for token in ["电视剧", "剧集", "剧集推荐", "热门剧", "热门电视剧"]): + return "tmdb_tvs", "tv" + if any(token in compact for token in ["电影", "影视电影", "热门电影"]): + return "tmdb_movies", "movie" + return default_source or "tmdb_trending", "all" + + @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_path(mapped) + + @staticmethod + def _normalize_pick_action(value: Any) -> str: + text = str(value or "").strip().lower() + if text in {"继续决策", "继续资源决策", "decision_continue"}: + return "decision_continue" + if text in {"换影巢", "切换影巢", "走影巢", "用影巢", "decision_hdhive"}: + return "decision_hdhive" + if text in {"换盘搜", "切换盘搜", "走盘搜", "用盘搜", "decision_pansou"}: + return "decision_pansou" + if text in {"换pt", "换原生", "换mp", "切换pt", "切换原生", "切换mp", "走pt", "走原生", "走mp", "decision_mp_pt"}: + return "decision_mp_pt" + if text in {"保守一点", "更保守", "保守模式", "decision_conservative"}: + return "decision_conservative" + if text in {"激进一点", "更激进", "激进模式", "decision_aggressive"}: + return "decision_aggressive" + if text in {"只用夸克", "只有夸克", "仅夸克", "只要夸克", "decision_only_quark"}: + return "decision_only_quark" + if text in {"只用115", "只有115", "仅115", "只要115", "decision_only_115"}: + return "decision_only_115" + if text in {"两者都可", "云盘都可", "115和夸克都可", "115夸克都可", "decision_cloud_both"}: + return "decision_cloud_both" + if text in {"只走pt", "只走原生", "只用pt", "只用原生", "只走mp", "只用mp", "decision_only_mp_pt"}: + return "decision_only_mp_pt" + if text in {"只走盘搜", "只用盘搜", "decision_only_pansou"}: + return "decision_only_pansou" + if text in {"只走影巢", "只用影巢", "decision_only_hdhive"}: + return "decision_only_hdhive" + if text in {"不用盘搜", "关闭盘搜", "禁用盘搜", "decision_disable_pansou"}: + return "decision_disable_pansou" + if text in {"不用影巢", "关闭影巢", "禁用影巢", "decision_disable_hdhive"}: + return "decision_disable_hdhive" + if text in {"不用pt", "不用原生", "关闭pt", "关闭原生", "关闭mp", "decision_disable_mp_pt"}: + return "decision_disable_mp_pt" + if text in {"按保存偏好", "恢复偏好", "恢复默认偏好", "清除会话偏好", "decision_reset_preferences"}: + return "decision_reset_preferences" + if text in {"确认执行", "确认执行吧", "就执行吧", "直接来", "执行它", "确认处理", "确认转存", "确认解锁"}: + return "best_execute" + if text in {"先计划", "先做计划", "先生成计划", "先出计划", "还是先计划"}: + return "best_plan" + if text in {"先看详情", "先看推荐", "先看结果", "看最佳", "看看最佳", "先看一下"}: + return "best" + if text in {"best_execute", "execute_best", "执行最佳", "最佳执行", "立即执行最佳", "直接执行最佳"}: + return "best_execute" + if text in {"best_plan", "plan_best", "计划最佳", "最佳计划", "计划推荐", "推荐计划", "最优计划"}: + return "best_plan" + if text in {"detail", "details", "review", "详情", "审查"}: + return "detail" + if text in {"best", "best_result", "recommend_best", "最佳", "最佳片源", "推荐片源", "推荐下载", "最优"}: + return "best" + if text in {"plan", "dry_run", "make_plan", "计划", "生成计划", "计划选择", "计划处理", "转存计划", "解锁计划"}: + return "plan" + if text in {"n", "next", "next_page", "下一页", "下页"} or text.startswith("n "): + return "next_page" + return "" + + @staticmethod + def _normalize_smart_search_short_action(value: Any, *, state_kind: str = "") -> str: + if str(state_kind or "").strip() != "assistant_smart_search": + return "" + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return "best" + if compact in {"计划", "做计划", "plan"}: + return "best_plan" + if compact in {"确认", "执行", "确认吧", "执行吧", "确定执行", "execute", "run"}: + return "best_execute" + return "" + + @staticmethod + def _is_pending_plan_confirmation_text(value: Any) -> bool: + raw = str(value or "").strip() + if not raw: + return False + compact = re.sub(r"\s+", "", raw).lower() + if re.search(r"\d", compact): + return False + return compact in { + "确认", + "确认吧", + "确认执行", + "确认执行吧", + "执行", + "执行吧", + "execute", + "run", + "确定", + "确定执行", + "执行下载", + "确认下载", + "开始下载", + "下载吧", + } + + @classmethod + def _parse_pending_plan_numeric_confirmation(cls, value: Any) -> int: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + if not raw: + return 0 + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + # Only a bare number or explicit execution wording may confirm a saved plan. + # Keep "下载1" for generating/reviewing the PT download plan in the current + # search result; otherwise it can accidentally execute an older pending plan. + match = re.fullmatch(r"(?:执行|确认|执行计划|确认计划)?(\d+)", compact, flags=re.IGNORECASE) + if not match: + return 0 + return cls._safe_int(match.group(1), 0) + + def _is_pending_plan_numeric_confirmation(self, value: Any, plan: Optional[Dict[str, Any]]) -> bool: + index = self._parse_pending_plan_numeric_confirmation(value) + if index <= 0 or not isinstance(plan, dict): + return False + expected_choices: List[int] = [] + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + expected_choices.append(self._safe_int(execute_body.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(execute_body.get("choice") or execute_body.get("index"), 0)) + for action in plan.get("actions") or []: + if isinstance(action, dict): + expected_choices.append(self._safe_int(action.get("plan_rank"), 0)) + expected_choices.append(self._safe_int(action.get("choice") or action.get("index"), 0)) + return index in {choice for choice in expected_choices if choice > 0} + + @classmethod + def _normalize_ai_reingest_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + compact = re.sub(r"\s+", "", str(value or "").strip().lower()) + result: Dict[str, Any] = {} + ai_session_kinds = { + "assistant_ai_failed_samples", + "assistant_ai_sample_worklist", + "assistant_ai_sample_insights", + "assistant_ai_replay", + } + saved_plan = dict((current_state.get("saved_plan") or {}).get("latest") or {}) + saved_workflow = cls._clean_text(saved_plan.get("workflow")) + has_pending_ai_replay_plan = ( + bool((current_state.get("saved_plan") or {}).get("has_pending")) + and saved_workflow == "ai_replay_failed_sample" + ) + if has_pending_ai_replay_plan and compact in {"确认", "确认吧", "执行", "执行吧", "确定", "确定执行", "run", "execute"}: + return {"action": "execute_plan"} + if kind not in ai_session_kinds: + return {} + keyword = cls._clean_text(current_state.get("keyword")) + if keyword and compact in {"诊断", "本地诊断", "重新诊断", "看诊断"}: + return {"action": "mp_local_diagnose", "keyword": keyword} + if keyword and compact in {"入库状态", "看入库状态", "状态"}: + return {"action": "mp_ingest_status", "keyword": keyword} + if compact in {"工作清单", "返回工作清单", "回工作清单"}: + return {"action": "ai_sample_worklist", "keyword": keyword} + if compact in {"失败样本", "返回失败样本", "回失败样本"}: + return {"action": "ai_failed_samples", "keyword": keyword} + if compact in {"样本洞察", "洞察", "返回样本洞察", "回样本洞察"}: + return {"action": "ai_sample_insights", "keyword": keyword} + raw = cls._clean_text(value) + replay_match = re.match(r"^\s*(重放|重识别|重跑)\s*(\d+)(?:\s+(.*))?$", raw) + if replay_match: + result = { + "action": "ai_replay_failed_sample", + "sample_index": replay_match.group(2), + "keyword": "", + "mode": "", + "remove_if_resolved": "true", + } + remain_text = cls._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + result["remove_if_resolved"] = "false" + return result + return {} + + @classmethod + def _normalize_mp_recommend_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:决策|资源决策|智能决策)\s*(\d+)\s*$", {"mode": "smart_decision"}), + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + (r"^\s*(?:确认|执行|直接执行)\s*(\d+)\s*$", {"mode": "smart_execute"}), + (r"^\s*(?:盘搜|ps)\s*(\d+)\s*$", {"mode": "pansou"}), + (r"^\s*(?:影巢|yc)\s*(\d+)\s*$", {"mode": "hdhive"}), + (r"^\s*(?:原生|mp|pt)\s*(\d+)\s*$", {"mode": "mp"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + selected_index = cls._safe_int(current_state.get("selected_index"), 0) + if selected_index <= 0: + items = current_state.get("items") if isinstance(current_state.get("items"), list) else [] + if items: + selected_index = 1 + if selected_index > 0: + no_index_aliases = { + "详情": {"action": "detail"}, + "查看详情": {"action": "detail"}, + "看详情": {"action": "detail"}, + "决策": {"mode": "smart_decision"}, + "资源决策": {"mode": "smart_decision"}, + "智能决策": {"mode": "smart_decision"}, + "计划": {"action": "plan"}, + "生成计划": {"action": "plan"}, + "先计划": {"action": "plan"}, + "确认": {"mode": "smart_execute"}, + "执行": {"mode": "smart_execute"}, + "直接执行": {"mode": "smart_execute"}, + "盘搜": {"mode": "pansou"}, + "ps": {"mode": "pansou"}, + "影巢": {"mode": "hdhive"}, + "yc": {"mode": "hdhive"}, + "原生": {"mode": "mp"}, + "mp": {"mode": "mp"}, + "pt": {"mode": "mp"}, + } + shortcut = no_index_aliases.get(raw) + if shortcut: + return {"index": selected_index, **shortcut} + return {} + + @classmethod + def _normalize_mp_recommend_followup( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + followup_aliases = { + "电影": ("tmdb_movies", "movie"), + "热门电影": ("tmdb_movies", "movie"), + "电视剧": ("tmdb_tvs", "tv"), + "热门电视剧": ("tmdb_tvs", "tv"), + "豆瓣": ("douban_hot", "all"), + "豆瓣热门": ("douban_hot", "all"), + "热映": ("douban_movie_showing", "movie"), + "正在热映": ("douban_movie_showing", "movie"), + "番剧": ("bangumi_calendar", "tv"), + "今日番剧": ("bangumi_calendar", "tv"), + "tmdb": ("tmdb_trending", "all"), + "热门": ("tmdb_trending", "all"), + } + source_pair = followup_aliases.get(compact) + if not source_pair: + return {} + source_name, media_type = source_pair + return {"action": "mp_recommendations", "keyword": source_name, "type": media_type} + + @classmethod + def _normalize_recommend_handoff_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact not in {"回推荐", "回榜单", "返回推荐", "返回榜单", "推荐", "榜单"}: + switch_mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp", + "mp": "mp", + } + switch_mode = switch_mode_aliases.get(compact) + if not switch_mode: + return {} + return {"action": "switch_recommend_handoff_source", "mode": switch_mode} + return {"action": "return_to_recommend"} + + @classmethod + def _normalize_recommend_handoff_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + if compact in {"决策", "资源决策", "智能决策"}: + return {"action": "return_to_smart_decision"} + if kind in {"assistant_pansou", "assistant_mp"}: + if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: + return {"route_action": "best"} + if compact in {"计划", "做计划", "plan"}: + return {"route_action": "best_plan"} + if compact in {"确认", "确认吧", "确认执行", "执行", "执行吧", "确定执行", "run", "execute"}: + return {"action": "confirm_recommend_handoff"} + return {} + + @classmethod + def _normalize_recommend_source_compound_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + kind = cls._clean_text(current_state.get("kind")) + if kind not in {"assistant_mp_recommend", "assistant_pansou", "assistant_mp", "assistant_hdhive"}: + return {} + compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() + source_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "原生": "mp", + "mp": "mp", + } + action_aliases = { + "详情": "best", + "看详情": "best", + "计划": "best_plan", + "确认": "best_execute", + "执行": "best_execute", + } + for alias, mode in source_aliases.items(): + if not compact.startswith(alias): + continue + remain = compact[len(alias):] + followup_action = action_aliases.get(remain) + if not followup_action: + continue + return { + "action": "recommend_source_compound", + "mode": mode, + "followup_action": followup_action, + } + return {} + + @classmethod + def _normalize_mp_recommend_direct_intent(cls, value: Any) -> Dict[str, Any]: + raw = cls._clean_text(value) + if not raw: + return {} + prefix_match = cls._match_command_prefix(raw, ["智能发现", "热门发现", "热门推荐", "推荐"]) + if not prefix_match: + return {} + _, remain = prefix_match + remain = cls._clean_text(remain) + if not remain: + return {} + suffix_aliases: List[Tuple[str, Dict[str, str]]] = [ + ("资源决策", {"mode": "smart_decision"}), + ("智能决策", {"mode": "smart_decision"}), + ("查看详情", {"action": "detail"}), + ("看详情", {"action": "detail"}), + ("确认执行", {"mode": "smart_execute"}), + ("直接执行", {"mode": "smart_execute"}), + ("生成计划", {"action": "plan"}), + ("先计划", {"action": "plan"}), + ("详情", {"action": "detail"}), + ("决策", {"mode": "smart_decision"}), + ("计划", {"action": "plan"}), + ("确认", {"mode": "smart_execute"}), + ("执行", {"mode": "smart_execute"}), + ("盘搜", {"mode": "pansou"}), + ("影巢", {"mode": "hdhive"}), + ("原生", {"mode": "mp"}), + ] + for suffix, action_payload in suffix_aliases: + separator = f" {suffix}" + if remain.endswith(separator): + keyword = cls._clean_text(remain[: -len(separator)]) + elif remain == suffix: + keyword = "" + else: + continue + if not keyword: + return {} + return {"keyword": keyword, "index": 1, **action_payload} + return {} + + @staticmethod + def _normalize_pick_mode(value: Any) -> str: + text = str(value or "").strip().lower() + compact = re.sub(r"\s+", "", text) + if not compact: + return "" + if any(token in compact for token in ["smart_execute", "smartexecute", "直接执行", "确认执行", "立即执行", "确认", "执行"]): + return "smart_execute" + if any(token in compact for token in ["smart_plan", "smartplan", "生成计划", "先计划", "计划"]): + return "smart_plan" + if any(token in compact for token in ["smart_decision", "smartdecision", "智能决策", "资源决策", "决策"]): + return "smart_decision" + if re.search(r"(^|[^a-z])smart($|[^a-z])", text): + return "smart_decision" + if any(token in compact for token in ["hdhive", "影巢", "影潮", "走影巢", "用影巢"]): + return "hdhive" + if re.search(r"(^|[^a-z])yc($|[^a-z])", text): + return "hdhive" + if any(token in compact for token in ["pansou", "盘搜", "走盘搜", "用盘搜"]): + return "pansou" + if re.search(r"(^|[^a-z])ps($|[^a-z])", text): + return "pansou" + if any(token in compact for token in ["mp", "原生", "moviepilot", "站点", "pt"]): + return "mp" + return "" + + @staticmethod + def _normalize_fullwidth_digits(value: Any) -> str: + return str(value or "").translate(str.maketrans("0123456789", "0123456789")) + + @staticmethod + def _parse_chinese_pick_number(value: Any) -> int: + text = str(value or "").strip() + if not text: + return 0 + text = text.replace("两", "二").replace("〇", "零") + digits = { + "零": 0, + "一": 1, + "二": 2, + "三": 3, + "四": 4, + "五": 5, + "六": 6, + "七": 7, + "八": 8, + "九": 9, + } + if text.isdigit(): + return int(text) + if any(ch not in digits and ch not in {"十", "百"} for ch in text): + return 0 + if len(text) == 1: + return digits.get(text, 0) + total = 0 + remainder = text + if "百" in remainder: + left, _, remainder = remainder.partition("百") + hundreds = 1 if not left else digits.get(left, 0) + if hundreds <= 0: + return 0 + total += hundreds * 100 + if "十" in remainder: + left, _, right = remainder.partition("十") + tens = 1 if not left else digits.get(left, 0) + ones = 0 if not right else digits.get(right, 0) + if tens <= 0 or (right and ones <= 0): + return 0 + total += tens * 10 + ones + return total + if total and len(remainder) == 1: + return total + digits.get(remainder, 0) + return total + + @classmethod + def _normalize_pick_action_fragment(cls, value: Any) -> str: + compact = re.sub(r"[\s的地得一下]+", "", str(value or "").strip().lower()) + if not compact: + return "" + action = cls._normalize_pick_action(compact) + if action: + return action + if any(token in compact for token in ["详情", "明细", "查看", "看看", "看下", "看一下"]): + return "detail" + if "审查" in compact: + return "detail" + if "下载" in compact: + return "plan" + if any(token in compact for token in ["计划", "dryrun", "makeplan"]): + return "plan" + return "" + + @classmethod + def _parse_compact_pick_text(cls, value: Any) -> Tuple[int, str]: + raw = cls._normalize_fullwidth_digits(value) + compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) + if not compact: + return 0, "" + chinese_number = r"[零〇一二两三四五六七八九十百]+" + number_token = rf"(?:\d+|{chinese_number})" + + def number_value(token: str) -> int: + return cls._safe_int(token, 0) if token.isdigit() else cls._parse_chinese_pick_number(token) + + # Short forms: "16详情", "十六详情", "16", "十六". + match = re.fullmatch(rf"({number_token})(.*)", compact) + if match: + index = number_value(match.group(1)) + suffix = match.group(2) + action = cls._normalize_pick_action_fragment(suffix) + if index > 0 and (not suffix or action): + return index, action + + # Reversed short forms: "详情16", "详情十六", "计划16". + match = re.fullmatch(rf"(.+?)({number_token})", compact) + if match: + action = cls._normalize_pick_action_fragment(match.group(1)) + index = number_value(match.group(2)) + if index > 0 and action: + return index, action + + # Natural phrases: "我要看看 16 的详情", "帮我看第十六个详情". + natural_action = cls._normalize_pick_action_fragment(compact) + if natural_action: + digit_match = re.search(r"\d+", compact) + if digit_match: + return cls._safe_int(digit_match.group(0), 0), natural_action + for cn_match in re.finditer(chinese_number, compact): + token = cn_match.group(0) + # Avoid treating "看一下详情" as "pick 1 detail". + if len(token) == 1 and token in {"一", "二", "三"}: + continue + index = cls._parse_chinese_pick_number(token) + if index > 0: + return index, natural_action + return 0, "" + + @classmethod + def _parse_pick_text(cls, value: Any) -> Tuple[int, str, str, str]: + raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) + action = cls._normalize_pick_action(raw) + if action: + return 0, "", action, "" + alias_pattern = r"^(?:/smart_pick|smart_pick|计划选择|计划处理|生成计划|转存计划|解锁计划|计划(?=\s*\d)|选择|选|继续|pick|plan|dry_run|make_plan)\s*" + alias_match = re.match(alias_pattern, raw, flags=re.IGNORECASE) + digit_match = re.match(r"^(\d+)(.*)$", raw) + compact_index, compact_action = cls._parse_compact_pick_text(raw) + if compact_index > 0 and not alias_match: + return compact_index, "", compact_action, "" + if not alias_match and not digit_match: + return 0, "", "", "" + if digit_match and not alias_match: + suffix = cls._clean_text(digit_match.group(2)) + if suffix and not ( + suffix.startswith("/") + or "=" in suffix + or cls._normalize_pick_mode(suffix) + or cls._normalize_pick_action(suffix) + or suffix.lower() in {"n", "next"} + ): + return 0, "", "", "" + text = re.sub(alias_pattern, "", raw, flags=re.IGNORECASE).strip() + if alias_match and cls._normalize_pick_action(alias_match.group(0).strip()) == "plan": + action = "plan" + index = 0 + path = "" + mode = "" + pick_action = action or "" + match = re.search(r"\d+", text) + if match: + index = cls._safe_int(match.group(0), 0) + elif text: + compact_index, compact_action = cls._parse_compact_pick_text(text) + if compact_index > 0: + index = compact_index + if compact_action: + pick_action = compact_action + for token in text.split(): + if "=" not in token: + if not pick_action: + pick_action = cls._normalize_pick_action(token) + continue + key, token_value = token.split("=", 1) + key = key.strip().lower() + token_value = token_value.strip() + if key in {"path", "dir", "目录", "位置"} and token_value: + path = cls._resolve_pan_path_value(token_value) + elif key in {"mode", "search_mode", "target", "方式", "来源", "渠道"} and token_value: + mode = cls._normalize_pick_mode(token_value) + if not mode: + mode = cls._normalize_pick_mode(text) + if not pick_action: + suffix = re.sub(r"\d+", " ", text, count=1).strip() + pick_action = cls._normalize_pick_action(suffix) + return index, path, pick_action, mode + + def init_plugin(self, config: Optional[Dict[str, Any]] = None): + config = config or {} + self._enabled = bool(config.get("enabled", False)) + self._notify = bool(config.get("notify", True)) + self._debug = bool(config.get("debug", False)) + self._quark_cookie = self._clean_text(config.get("quark_cookie")) + self._quark_default_path = self._normalize_path(config.get("quark_default_path") or "/飞书") + self._quark_timeout = self._safe_int(config.get("quark_timeout"), 30) + self._quark_auto_import_cookiecloud = bool(config.get("quark_auto_import_cookiecloud", True)) + self._pansou_base_url = self._clean_text(config.get("pansou_base_url") or "http://127.0.0.1:805").rstrip("/") + self._pansou_timeout = max(3, min(120, self._safe_int(config.get("pansou_timeout"), 20))) + self._hdhive_api_key = self._clean_text(config.get("hdhive_api_key")) + self._hdhive_base_url = self._clean_text(config.get("hdhive_base_url") or "https://hdhive.com").rstrip("/") + self._hdhive_timeout = self._safe_int(config.get("hdhive_timeout"), 30) + self._hdhive_default_path = self._normalize_path(config.get("hdhive_default_path") or "/待整理") + self._hdhive_candidate_page_size = max(5, min(20, self._safe_int(config.get("hdhive_candidate_page_size"), type(self)._hdhive_candidate_page_size))) + self._hdhive_resource_enabled = bool(config.get("hdhive_resource_enabled", True)) + self._hdhive_max_unlock_points = max(0, self._safe_int(config.get("hdhive_max_unlock_points"), 20)) + self._hdhive_checkin_enabled = bool(config.get("hdhive_checkin_enabled", False)) + self._hdhive_checkin_gambler_mode = bool(config.get("hdhive_checkin_gambler_mode", False)) + self._hdhive_checkin_once = bool(config.get("hdhive_checkin_once", False)) + self._hdhive_checkin_cron = self._clean_text(config.get("hdhive_checkin_cron") or "0 8 * * *") + self._hdhive_checkin_cookie = self._clean_text(config.get("hdhive_checkin_cookie")) + self._hdhive_checkin_auto_login = bool(config.get("hdhive_checkin_auto_login", True)) + self._hdhive_checkin_username = self._clean_text(config.get("hdhive_checkin_username")) + self._hdhive_checkin_password = self._clean_text(config.get("hdhive_checkin_password")) + self._p115_default_path = self._normalize_path(config.get("p115_default_path") or "/待整理") + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(config.get("p115_client_type")) + self._p115_cookie = self._clean_text(config.get("p115_cookie")) + self._p115_prefer_direct = bool(config.get("p115_prefer_direct", True)) + self._mp_download_save_path = self._clean_text(config.get("mp_download_save_path")) + self._assistant_default_pt_min_seeders = max(0, self._safe_int(config.get("assistant_default_pt_min_seeders"), 3)) + self._assistant_default_auto_ingest_enabled = bool(config.get("assistant_default_auto_ingest_enabled", False)) + self._assistant_default_auto_ingest_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_auto_ingest_score_threshold"), 90))) + self._assistant_default_confirm_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_confirm_score_threshold"), 70))) + self._feishu_enabled = bool(config.get("feishu_enabled", False)) + self._feishu_allow_all = bool(config.get("feishu_allow_all", False)) + self._feishu_reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self._feishu_reply_receive_id_type = self._clean_text(config.get("feishu_reply_receive_id_type") or "chat_id") + self._feishu_app_id = self._clean_text(config.get("feishu_app_id")) + self._feishu_app_secret = self._clean_text(config.get("feishu_app_secret")) + self._feishu_verification_token = self._clean_text(config.get("feishu_verification_token")) + self._feishu_allowed_chat_ids = FeishuChannel.split_lines(config.get("feishu_allowed_chat_ids")) + self._feishu_allowed_user_ids = FeishuChannel.split_lines(config.get("feishu_allowed_user_ids")) + self._feishu_command_whitelist = FeishuChannel.merge_command_whitelist( + FeishuChannel.split_commands(config.get("feishu_command_whitelist")) + ) + self._feishu_command_aliases = FeishuChannel.merge_command_aliases( + self._clean_text(config.get("feishu_command_aliases")) + ) + self._feishu_command_mode = self._clean_text(config.get("feishu_command_mode") or "resource_officer") + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + self._restore_persisted_sessions() + self._restore_execution_history() + self._restore_hdhive_checkin_history() + self._restore_workflow_plans() + self._restore_assistant_preferences() + self._agent_tools_reloaded = False + self._ensure_feishu_channel().configure(self._build_config()) + if self._enabled and self._feishu_enabled: + self._feishu_channel.start() + elif self._feishu_channel is not None: + self._feishu_channel.stop() + self._maybe_run_hdhive_checkin_once() + + def get_state(self) -> bool: + if self._enabled: + self._maybe_reload_agent_tools_once() + return self._enabled + + def get_agent_tools(self) -> List[type]: + return [ + AssistantCapabilitiesTool, + AssistantExecuteActionTool, + AssistantExecuteActionsTool, + AssistantExecutePlanTool, + AssistantPlansTool, + AssistantPlansClearTool, + AssistantRecoverTool, + AssistantPulseTool, + AssistantStartupTool, + AssistantMaintainTool, + AssistantToolboxTool, + AssistantRequestTemplatesTool, + AssistantSelfcheckTool, + AssistantReadinessTool, + FeishuChannelHealthTool, + AssistantHistoryTool, + AssistantHelpTool, + AssistantRouteTool, + AssistantPickTool, + AssistantWorkflowTool, + AssistantSessionsTool, + AssistantSessionStateTool, + AssistantSessionClearTool, + AssistantSessionsClearTool, + HDHiveSearchSessionTool, + HDHiveSessionPickTool, + ShareRouteTool, + P115QRCodeStartTool, + P115QRCodeCheckTool, + P115StatusTool, + P115PendingTool, + P115ResumePendingTool, + P115CancelPendingTool, + ] + + @staticmethod + def _reload_agent_tools() -> None: + if moviepilot_tool_manager is None: + return + try: + moviepilot_tool_manager._load_tools() + except Exception: + return + + def _maybe_reload_agent_tools_once(self) -> None: + if moviepilot_tool_manager is None: + return + now = time.time() + with self.__class__._agent_tools_reload_lock: + if ( + self.__class__._agent_tools_reload_version == self.plugin_version + and now - self.__class__._agent_tools_reload_at < 600 + ): + return + # Mark before reloading, because tool loading can query plugin states recursively. + self.__class__._agent_tools_reload_version = self.plugin_version + self.__class__._agent_tools_reload_at = now + self._reload_agent_tools() + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _parse_optional_bool(value: Any) -> Optional[bool]: + if value is None: + return None + if isinstance(value, bool): + return value + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off"}: + return False + return None + + @classmethod + def _parse_bool_value(cls, value: Any, default: bool = False) -> bool: + parsed = cls._parse_optional_bool(value) + return bool(default) if parsed is None else bool(parsed) + + @staticmethod + def _normalize_path(value: Any) -> str: + return QuarkTransferService.normalize_path(value) + + @staticmethod + def _friendly_hdhive_error(message: str, capability: str) -> str: + text = str(message or "").strip() + lowered = text.lower() + if "premium" in lowered or "仅对 premium 用户开放" in text: + if capability == "checkin": + return "影巢 OpenAPI 签到当前需要 Premium 用户;普通用户可配置网页 Cookie 或账号密码启用网页签到兜底。" + return f"影巢 OpenAPI 的{capability}接口当前需要 Premium 用户。" + return text or f"影巢 {capability} 接口调用失败" + + @staticmethod + def _is_hdhive_premium_limited(message: str) -> bool: + text = str(message or "").strip() + lowered = text.lower() + return "premium" in lowered or "仅对 premium 用户开放" in text + + @staticmethod + def _read_json_file(path: Path) -> Optional[Dict[str, Any]]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + @classmethod + def _hdhive_daily_sign_config_paths(cls) -> List[Path]: + return [ + Path("/config/plugins/hdhivedailysign.json"), + Path("/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json"), + ] + + @classmethod + def _hdhive_daily_sign_user_info_paths(cls) -> List[Path]: + return [ + Path("/config/logs/plugins/hdhivedailysign_user_info.json"), + Path("/Applications/Dockge/moviepilotv2/config/logs/plugins/hdhivedailysign_user_info.json"), + ] + + @classmethod + def _load_hdhive_daily_sign_config(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_config_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _load_hdhive_daily_sign_user_info(cls) -> Dict[str, Any]: + for path in cls._hdhive_daily_sign_user_info_paths(): + if not path.exists(): + continue + data = cls._read_json_file(path) + if data: + return data + return {} + + @classmethod + def _build_hdhive_account_snapshot(cls, snapshot: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(snapshot, dict) or not snapshot: + return {} + return { + "id": snapshot.get("id"), + "nickname": snapshot.get("nickname"), + "username": snapshot.get("nickname"), + "avatar_url": snapshot.get("avatar_url"), + "created_at": snapshot.get("created_at"), + "is_vip": False, + "source": "hdhivedailysign_snapshot", + "user_meta": { + "points": snapshot.get("points"), + "signin_days_total": snapshot.get("signin_days_total"), + }, + "warnings_nums": snapshot.get("warnings_nums"), + } + + @classmethod + def _extract_hdhive_account_fields(cls, payload: Dict[str, Any]) -> Dict[str, Any]: + data = payload if isinstance(payload, dict) else {} + meta = data.get("user_meta") if isinstance(data.get("user_meta"), dict) else {} + return { + "nickname": data.get("nickname") or data.get("username") or "—", + "points": meta.get("points", data.get("points", "—")), + "signin_days_total": meta.get("signin_days_total", data.get("signin_days_total", "—")), + "is_vip": bool(data.get("is_vip")), + } + + def _get_hdhive_fallback_cookie(self) -> str: + own_cookie = self._clean_text(self._hdhive_checkin_cookie) + if own_cookie: + return own_cookie + config = self._load_hdhive_daily_sign_config() + return self._clean_text(config.get("cookie")) + + def _refresh_hdhive_checkin_cookie(self) -> Tuple[bool, str, str]: + if not self._hdhive_checkin_auto_login: + return False, "", "未启用影巢自动登录刷新 Cookie" + service = self._ensure_hdhive_service() + # Playwright sync API cannot run inside MoviePilot's asyncio loop; keep login isolated. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit( + service.login_for_cookie, + username=self._hdhive_checkin_username, + password=self._hdhive_checkin_password, + ) + try: + login_ok, cookie_string, login_message = future.result(timeout=max(60, self._hdhive_timeout * 4)) + except Exception as exc: + return False, "", f"影巢自动登录超时或异常: {exc}" + if not login_ok or not cookie_string: + return False, "", login_message or "影巢自动登录失败" + self._hdhive_checkin_cookie = cookie_string + try: + self.update_config(self._build_config()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢自动登录已获取 Cookie,但保存配置失败:{exc}") + return True, cookie_string, login_message or "影巢自动登录成功" + + def _run_hdhive_checkin(self, *, is_gambler: Optional[bool] = None, trigger: str = "Agent影视助手") -> Dict[str, Any]: + if not self._hdhive_checkin_enabled: + return self._hdhive_checkin_disabled_response() + service = self._ensure_hdhive_service() + final_gambler_mode = self._hdhive_checkin_gambler_mode if is_gambler is None else bool(is_gambler) + checkin_ok, result, checkin_message = service.perform_checkin( + is_gambler=final_gambler_mode, + trigger=trigger, + ) + if checkin_ok: + final_result = {"success": True, "message": result.get("message") or "success", "data": result} + self._record_hdhive_checkin_history(trigger=trigger, is_gambler=final_gambler_mode, result=final_result) + return final_result + + raw_message = result.get("message") or checkin_message + checkin_status_code = self._safe_int(result.get("status_code"), 0) if isinstance(result, dict) else 0 + should_try_web_fallback = ( + self._is_hdhive_premium_limited(raw_message) + or checkin_status_code in (404, 405) + or "405 not allowed" in self._clean_text(raw_message).lower() + or " None: + if not self._enabled or not self._hdhive_checkin_once: + return + self._hdhive_checkin_once = False + try: + self.update_config(self._build_config({"hdhive_checkin_once": False})) + except Exception as exc: + logger.warning(f"[Agent影视助手] 重置“立即影巢签到”开关失败:{exc}") + + def _run_once() -> None: + try: + result = self._run_hdhive_checkin(trigger="Agent影视助手 插件页立即签到") + status = "成功" if result.get("success") else "失败" + logger.info(f"[Agent影视助手] 插件页立即影巢签到{status}: {result.get('message')}") + except Exception as exc: + logger.error(f"[Agent影视助手] 插件页立即影巢签到异常: {exc}") + + threading.Thread(target=_run_once, name="aro-hdhive-checkin-once", daemon=True).start() + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled or not self._hdhive_checkin_enabled or not self._hdhive_checkin_cron: + return [] + if CronTrigger is None: + logger.warning("[Agent影视助手] apscheduler 不可用,无法注册影巢定时签到") + return [] + try: + trigger = CronTrigger.from_crontab(self._hdhive_checkin_cron) + except Exception as exc: + logger.warning(f"[Agent影视助手] 影巢签到 Cron 配置无效:{self._hdhive_checkin_cron} {exc}") + return [] + return [{ + "id": "agentresourceofficer_hdhive_checkin", + "name": "Agent影视助手影巢签到", + "trigger": trigger, + "func": self._scheduled_hdhive_checkin, + "kwargs": {}, + }] + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "debug": self._debug, + "quark_cookie": self._quark_cookie, + "quark_default_path": self._quark_default_path, + "quark_timeout": self._quark_timeout, + "quark_auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + "pansou_base_url": self._pansou_base_url, + "pansou_timeout": self._pansou_timeout, + "hdhive_api_key": self._hdhive_api_key, + "hdhive_base_url": self._hdhive_base_url, + "hdhive_timeout": self._hdhive_timeout, + "hdhive_default_path": self._hdhive_default_path, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "hdhive_checkin_once": self._hdhive_checkin_once, + "hdhive_checkin_cron": self._hdhive_checkin_cron, + "hdhive_checkin_cookie": self._hdhive_checkin_cookie, + "hdhive_checkin_auto_login": self._hdhive_checkin_auto_login, + "hdhive_checkin_username": self._hdhive_checkin_username, + "hdhive_checkin_password": self._hdhive_checkin_password, + "p115_default_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie": self._p115_cookie, + "p115_prefer_direct": self._p115_prefer_direct, + "mp_download_save_path": self._mp_download_save_path, + "assistant_default_pt_min_seeders": self._assistant_default_pt_min_seeders, + "assistant_default_auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "assistant_default_auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "assistant_default_confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "feishu_enabled": self._feishu_enabled, + "feishu_allow_all": self._feishu_allow_all, + "feishu_reply_enabled": self._feishu_reply_enabled, + "feishu_reply_receive_id_type": self._feishu_reply_receive_id_type, + "feishu_app_id": self._feishu_app_id, + "feishu_app_secret": self._feishu_app_secret, + "feishu_verification_token": self._feishu_verification_token, + "feishu_allowed_chat_ids": "\n".join(self._feishu_allowed_chat_ids), + "feishu_allowed_user_ids": "\n".join(self._feishu_allowed_user_ids), + "feishu_command_whitelist": ",".join(self._feishu_command_whitelist), + "feishu_command_aliases": self._feishu_command_aliases, + "feishu_command_mode": self._feishu_command_mode, + } + if overrides: + config.update(overrides) + return config + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + async def _request_payload(self, request: Request) -> Dict[str, Any]: + if str(getattr(request, "method", "") or "").upper() == "GET": + return {} + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _ensure_quark_service(self) -> QuarkTransferService: + if self._quark_service is None: + self._quark_service = QuarkTransferService( + cookie=self._quark_cookie, + timeout=self._quark_timeout, + default_target_path=self._quark_default_path, + auto_import_cookiecloud=self._quark_auto_import_cookiecloud, + cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, + ) + else: + self._quark_service.set_cookie(self._quark_cookie) + self._quark_service.timeout = max(10, self._safe_int(self._quark_timeout, 30)) + self._quark_service.default_target_path = self._quark_default_path + self._quark_service.auto_import_cookiecloud = self._quark_auto_import_cookiecloud + self._quark_service.cookie_refresh_callback = self._refresh_quark_cookie_from_cookiecloud + return self._quark_service + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _refresh_quark_cookie_from_cookiecloud(self) -> str: + cookie, _message = self._load_cookiecloud_quark_cookie() + if cookie: + self._quark_cookie = cookie + return cookie + + def _ensure_hdhive_service(self) -> HDHiveOpenApiService: + if self._hdhive_service is None: + self._hdhive_service = HDHiveOpenApiService( + api_key=self._hdhive_api_key, + base_url=self._hdhive_base_url, + timeout=self._hdhive_timeout, + ) + else: + self._hdhive_service.api_key = self._hdhive_api_key + self._hdhive_service.base_url = self._hdhive_base_url + self._hdhive_service.timeout = self._hdhive_timeout + return self._hdhive_service + + def _ensure_p115_service(self) -> P115TransferService: + if self._p115_service is None: + self._p115_service = P115TransferService( + default_target_path=self._p115_default_path, + cookie=self._p115_cookie, + prefer_direct=self._p115_prefer_direct, + ) + else: + self._p115_service.default_target_path = self._p115_default_path + self._p115_service.set_cookie(self._p115_cookie) + self._p115_service.prefer_direct = self._p115_prefer_direct + return self._p115_service + + def _apply_runtime_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = self._build_config(overrides) + self.update_config(config) + self.init_plugin(config) + return config + + @staticmethod + def _p115_client_type_items() -> List[Dict[str, str]]: + return [ + {"title": "支付宝小程序(推荐)", "value": "alipaymini"}, + {"title": "115 Android", "value": "115android"}, + {"title": "115 iOS", "value": "115ios"}, + {"title": "115 iPad", "value": "115ipad"}, + {"title": "115 TV", "value": "tv"}, + {"title": "微信小程序", "value": "wechatmini"}, + {"title": "Web", "value": "web"}, + ] + + @classmethod + def _p115_client_type_title(cls, value: str) -> str: + final_value = P115TransferService.normalize_qrcode_client_type(value) + for item in cls._p115_client_type_items(): + if item.get("value") == final_value: + return str(item.get("title") or final_value) + return final_value + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + if self._feishu_channel is not None: + self._feishu_channel.stop() + return + + def _ensure_feishu_channel(self) -> FeishuChannel: + if self._feishu_channel is None: + self._feishu_channel = FeishuChannel(self) + return self._feishu_channel + + def get_api(self) -> List[Dict[str, Any]]: + return [ + { + "path": "/quark/health", + "endpoint": self.api_quark_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的夸克配置", + }, + { + "path": "/quark/transfer", + "endpoint": self.api_quark_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行夸克分享转存", + }, + { + "path": "/hdhive/health", + "endpoint": self.api_hdhive_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的影巢配置", + }, + { + "path": "/hdhive/account", + "endpoint": self.api_hdhive_account, + "methods": ["GET"], + "summary": "获取影巢当前账号信息", + }, + { + "path": "/hdhive/checkin", + "endpoint": self.api_hdhive_checkin, + "methods": ["POST"], + "summary": "执行影巢普通签到或赌狗签到", + }, + { + "path": "/hdhive/checkin/history", + "endpoint": self.api_hdhive_checkin_history, + "methods": ["GET"], + "summary": "查看插件保存的影巢签到日志", + }, + { + "path": "/hdhive/quota", + "endpoint": self.api_hdhive_quota, + "methods": ["GET"], + "summary": "获取影巢当前配额信息", + }, + { + "path": "/hdhive/usage_today", + "endpoint": self.api_hdhive_usage_today, + "methods": ["GET"], + "summary": "获取影巢今日用量统计", + }, + { + "path": "/hdhive/weekly_free_quota", + "endpoint": self.api_hdhive_weekly_free_quota, + "methods": ["GET"], + "summary": "获取影巢每周免费解锁额度", + }, + { + "path": "/hdhive/search", + "endpoint": self.api_hdhive_search, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源搜索", + }, + { + "path": "/hdhive/search_by_keyword", + "endpoint": self.api_hdhive_search_by_keyword, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢关键词候选搜索", + }, + { + "path": "/hdhive/unlock", + "endpoint": self.api_hdhive_unlock, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行影巢资源解锁", + }, + { + "path": "/hdhive/unlock_and_route", + "endpoint": self.api_hdhive_unlock_and_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 解锁影巢资源并尝试自动路由到对应网盘执行层", + }, + { + "path": "/p115/health", + "endpoint": self.api_p115_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 转存依赖状态", + }, + { + "path": "/p115/qrcode", + "endpoint": self.api_p115_qrcode, + "methods": ["GET"], + "summary": "获取 Agent影视助手 的 115 扫码登录二维码", + }, + { + "path": "/p115/qrcode/check", + "endpoint": self.api_p115_qrcode_check, + "methods": ["GET"], + "summary": "检查 Agent影视助手 的 115 扫码登录状态", + }, + { + "path": "/p115/transfer", + "endpoint": self.api_p115_transfer, + "methods": ["POST"], + "summary": "通过 Agent影视助手 执行 115 分享转存", + }, + { + "path": "/p115/pending", + "endpoint": self.api_p115_pending, + "methods": ["GET", "POST"], + "summary": "查看指定会话中待继续的 115 任务", + }, + { + "path": "/p115/pending/resume", + "endpoint": self.api_p115_pending_resume, + "methods": ["POST"], + "summary": "继续执行指定会话中待处理的 115 任务", + }, + { + "path": "/p115/pending/cancel", + "endpoint": self.api_p115_pending_cancel, + "methods": ["POST"], + "summary": "取消指定会话中待处理的 115 任务", + }, + { + "path": "/share/route", + "endpoint": self.api_share_route, + "methods": ["POST"], + "summary": "通过 Agent影视助手 自动识别 115 / 夸克分享链接并执行对应转存", + }, + { + "path": "/feishu/health", + "endpoint": self.api_feishu_health, + "methods": ["GET"], + "summary": "检查 Agent影视助手 内置飞书入口状态", + }, + { + "path": "/assistant/route", + "endpoint": self.api_assistant_route, + "methods": ["POST"], + "summary": "统一智能入口:盘搜 / 影巢 / 直链分享", + }, + { + "path": "/assistant/pick", + "endpoint": self.api_assistant_pick, + "methods": ["POST"], + "summary": "统一智能入口的按编号继续执行", + }, + { + "path": "/assistant/capabilities", + "endpoint": self.api_assistant_capabilities, + "methods": ["GET"], + "summary": "查看统一智能入口支持的结构化参数、默认值与推荐调用方式", + }, + { + "path": "/assistant/readiness", + "endpoint": self.api_assistant_readiness, + "methods": ["GET"], + "summary": "检查 Agent影视助手 是否已准备好给外部智能体调用", + }, + { + "path": "/assistant/pulse", + "endpoint": self.api_assistant_pulse, + "methods": ["GET"], + "summary": "轻量启动探针:返回版本、关键服务状态、警告和最佳恢复建议", + }, + { + "path": "/assistant/startup", + "endpoint": self.api_assistant_startup, + "methods": ["GET"], + "summary": "启动聚合包:一次返回 pulse、自检、核心工具、端点、默认目录和恢复建议", + }, + { + "path": "/assistant/maintain", + "endpoint": self.api_assistant_maintain, + "methods": ["GET", "POST"], + "summary": "低风险维护:查看或执行过期会话、已执行计划清理", + }, + { + "path": "/assistant/toolbox", + "endpoint": self.api_assistant_toolbox, + "methods": ["GET"], + "summary": "轻量工具清单:返回推荐工具、端点、工作流、动作名、默认目录和命令示例", + }, + { + "path": "/assistant/request_templates", + "endpoint": self.api_assistant_request_templates, + "methods": ["GET", "POST"], + "summary": "轻量请求模板:返回外部智能体常用 assistant 请求模板", + }, + { + "path": "/assistant/selfcheck", + "endpoint": self.api_assistant_selfcheck, + "methods": ["GET"], + "summary": "轻量自检:确认 compact 协议、模板默认 compact 和布尔字符串解析是否正常", + }, + { + "path": "/assistant/history", + "endpoint": self.api_assistant_history, + "methods": ["GET"], + "summary": "查看最近执行历史,便于外部智能体判断上一步是否完成", + }, + { + "path": "/assistant/action", + "endpoint": self.api_assistant_action, + "methods": ["POST"], + "summary": "直接执行统一智能入口返回的动作模板名,适合外部智能体无映射继续执行", + }, + { + "path": "/assistant/actions", + "endpoint": self.api_assistant_actions, + "methods": ["POST"], + "summary": "批量执行多个动作模板,适合外部智能体一次请求串起多步工作流,减少往返", + }, + { + "path": "/assistant/workflow", + "endpoint": self.api_assistant_workflow, + "methods": ["GET", "POST"], + "summary": "运行预设工作流,适合外部智能体用更短参数完成常见资源任务", + }, + { + "path": "/assistant/preferences", + "endpoint": self.api_assistant_preferences, + "methods": ["GET", "POST", "DELETE"], + "summary": "读取、保存或重置智能体片源偏好画像,用于云盘和 PT 分源评分", + }, + { + "path": "/assistant/plan/execute", + "endpoint": self.api_assistant_plan_execute, + "methods": ["POST"], + "summary": "执行 dry_run 保存的工作流计划,避免外部智能体重复携带大 JSON", + }, + { + "path": "/assistant/plans", + "endpoint": self.api_assistant_plans, + "methods": ["GET"], + "summary": "查看 dry_run 保存的工作流计划,便于断线恢复和选择 plan_id", + }, + { + "path": "/assistant/plans/clear", + "endpoint": self.api_assistant_plans_clear, + "methods": ["POST"], + "summary": "清理 dry_run 保存的工作流计划", + }, + { + "path": "/assistant/recover", + "endpoint": self.api_assistant_recover, + "methods": ["GET", "POST"], + "summary": "查看或直接执行当前最推荐的恢复动作,给外部智能体提供单入口续跑能力", + }, + { + "path": "/assistant/session", + "endpoint": self.api_assistant_session_state, + "methods": ["GET", "POST"], + "summary": "查看统一智能入口当前会话状态与建议动作", + }, + { + "path": "/assistant/session/clear", + "endpoint": self.api_assistant_session_clear, + "methods": ["POST"], + "summary": "清理统一智能入口当前会话缓存", + }, + { + "path": "/assistant/sessions", + "endpoint": self.api_assistant_sessions, + "methods": ["GET"], + "summary": "列出当前活跃的统一智能入口会话,便于外部智能体恢复和接续", + }, + { + "path": "/assistant/sessions/clear", + "endpoint": self.api_assistant_sessions_clear, + "methods": ["POST"], + "summary": "按 session_id、类型或过滤条件批量清理统一智能入口会话", + }, + { + "path": "/session/hdhive/search", + "endpoint": self.api_session_hdhive_search, + "methods": ["POST"], + "summary": "创建影巢搜索会话并返回候选影片列表", + }, + { + "path": "/session/hdhive/pick", + "endpoint": self.api_session_hdhive_pick, + "methods": ["POST"], + "summary": "按编号继续影巢会话:候选选片或资源解锁落盘", + }, + ] + + def _build_hdhive_page_summary(self) -> str: + if not self._enabled: + return "插件未启用" + if not self._hdhive_api_key: + return "影巢 API Key 未配置" + service = self._ensure_hdhive_service() + account_ok, account_result, account_message = service.fetch_me() + quota_ok, quota_result, _quota_message = service.fetch_quota() + usage_ok, usage_result, _usage_message = service.fetch_usage_today() + + account = account_result.get("data") or {} + account_source = "hdhive_openapi" + if not account_ok and self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + account = fallback_account + account_ok = True + account_source = "hdhivedailysign_snapshot" + account_fields = self._extract_hdhive_account_fields(account) + quota = quota_result.get("data") or {} + usage = usage_result.get("data") or {} + + return ( + f"影巢账号:{'可用' if account_ok else '异常'}" + f"\n资源入口:{'开启' if self._hdhive_resource_enabled else '关闭'}" + f"\n单资源积分上限:{self._hdhive_max_unlock_points if self._hdhive_max_unlock_points > 0 else '不限制'}" + f"\n签到入口:{'开启' if self._hdhive_checkin_enabled else '关闭'}" + f"\n昵称:{account_fields.get('nickname', '—')}" + f"\n积分:{account_fields.get('points', '—')}" + f"\nVIP:{'是' if account_fields.get('is_vip') else '否'}" + f"\n累计签到:{account_fields.get('signin_days_total', '—')}" + f"\n今日剩余配额:{quota.get('endpoint_remaining', '—')}" + f"\n今日总调用:{usage.get('total_calls', '—')}" + f"\n账号来源:{'网页快照' if account_source == 'hdhivedailysign_snapshot' else 'OpenAPI'}" + ) + + def get_page(self) -> List[dict]: + quark_ready = "已配置" if self._quark_cookie else "未配置" + hdhive_ready = "已配置" if self._hdhive_api_key else "未配置" + p115_health_ok, p115_health, _p115_health_message = self._ensure_p115_service().health() + cookie_state = p115_health.get("cookie_state") or {} + if cookie_state.get("valid"): + p115_ready = "已配置扫码会话" + elif cookie_state.get("configured"): + p115_ready = "已配置但不是扫码会话" + else: + p115_ready = "复用 115 助手客户端" + hdhive_summary = self._build_hdhive_page_summary() + feishu_health = self._ensure_feishu_channel().health() + feishu_state = "已启用" if feishu_health.get("enabled") else "未启用" + feishu_running = "运行中" if feishu_health.get("running") else "未运行" + hdhive_lines = [line.strip() for line in str(hdhive_summary or "").splitlines() if line.strip()] + hdhive_compact_lines = hdhive_lines[:4] + if len(hdhive_lines) >= 6: + hdhive_compact_lines.append(f"{hdhive_lines[4]} / {hdhive_lines[5]}") + p115_cookie_message = cookie_state.get("message") or "当前会话可直接用于 115 直转" + + def text_line(text: str, css_class: str = "text-body-2 py-1") -> Dict[str, Any]: + return { + "component": "div", + "props": {"class": css_class}, + "text": text, + } + + def status_card(title: str, subtitle: str, lines: List[str], color: str = "primary") -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"variant": "tonal", "color": color, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1"}, + "text": title, + }, + { + "component": "VCardSubtitle", + "props": {"class": "text-body-2"}, + "text": subtitle, + }, + { + "component": "VCardText", + "props": {"class": "py-2"}, + "content": [text_line(line, "text-body-2 py-0") for line in lines], + }, + ], + } + + def section_card(title: str, lines: List[str], compact: bool = False) -> Dict[str, Any]: + return { + "component": "VCard", + "props": {"flat": True, "border": True, "class": "h-100"}, + "content": [ + { + "component": "VCardTitle", + "props": {"class": "text-subtitle-1 font-weight-bold pb-1" if compact else "text-subtitle-1 font-weight-bold"}, + "text": title, + }, + { + "component": "VCardText", + "props": {"class": "py-2"} if compact else {}, + "content": [text_line(line, "text-body-2 py-0") for line in lines] if compact else [text_line(line) for line in lines], + }, + ], + } + + return [ + { + "component": "VContainer", + "props": {"fluid": True, "class": "pa-0"}, + "content": [ + { + "component": "VRow", + "props": {"dense": True}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "影巢", + hdhive_ready, + [ + f"默认目录:{self._hdhive_default_path}", + "能力:搜索 / 解锁 / 签到", + "API:/hdhive/account /checkin /quota", + ], + "success" if self._hdhive_api_key else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "115", + "可用" if p115_health_ok else "待修复", + [ + f"默认目录:{self._p115_default_path}", + f"登录方式:{p115_ready}", + f"扫码客户端:{self._p115_client_type_title(self._p115_client_type)}", + ], + "success" if p115_health_ok else "error", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "夸克", + quark_ready, + [ + f"默认目录:{self._quark_default_path}", + "能力:分享链接转存", + "入口:通用分享路由", + ], + "success" if self._quark_cookie else "warning", + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + status_card( + "飞书", + f"{feishu_state},长连接:{feishu_running}", + [ + "模式:内置 Channel", + "健康检查:/feishu/health", + "建议:只保留一个飞书入口监听", + ], + "success" if feishu_health.get("running") else "secondary", + ) + ], + }, + ], + }, + { + "component": "VRow", + "props": {"dense": True, "class": "mt-3"}, + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "智能体入口", + [ + "统一路由:/assistant/route", + "继续选择:/assistant/pick", + "工作流:/assistant/workflow", + "计划执行:/assistant/plan/execute", + "Agent Tool:搜索/选择、115 扫码、待任务查看/继续/取消、通用分享路由", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "账号与签到", + hdhive_compact_lines + + [ + f"115 Cookie:{p115_cookie_message}", + ], + compact=True, + ) + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + section_card( + "盘搜服务", + [ + f"API 地址:{self._pansou_base_url}", + f"请求超时:{self._pansou_timeout} 秒", + "用法:发送“盘搜搜索 片名”“ps片名”或“1片名”。", + "说明:插件只负责调用 PanSou API,本机需要先运行 PanSou 服务。", + ], + compact=True, + ) + ], + } + ], + }, + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "class": "mt-4 mb-1", + "title": "统一资源入口", + }, + "content": [ + text_line( + "Agent影视助手支持三种接入模式:飞书直接发命令、外部智能体调用 skill/helper、MP 内置智能体调用 Agent Tool。", + "text-body-2 mb-3", + ), + text_line( + "不接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2 mb-3", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "如果你只想直接用插件或飞书入口,不需要额外安装 skill。\n" + "直接使用这些命令即可:搜索 片名 / 云盘搜索 片名 / 转存 片名 / 下载 片名 / 更新检查 片名。" + ), + }, + text_line( + "接外部智能体", + "text-subtitle-2 font-weight-bold mb-2", + ), + { + "component": "div", + "props": { + "class": "pa-3 rounded text-body-2", + "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", + }, + "text": ( + "插件页不再直接放大段接入提示词,避免复制到旧配置。\n" + "请按快速开始主页和外部智能体接入文档配置:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins\n\n" + "外部智能体接入文档:\n" + "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md\n\n" + "如果客户端支持 MoviePilot 官方 MCP,也请按文档里的分工接入;资源流仍优先使用 agent-resource-officer skill/helper。\n" + "长会话跑偏时,可以直接对智能体说:校准影视技能。" + ), + }, + ], + }, + ], + } + ] + + @staticmethod + def get_render_mode() -> Tuple[str, Optional[str]]: + return "vuetify", None + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + form = [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "插件把资源搜索、链接转存、扫码登录、飞书消息和智能体调用集中到一个入口。首次使用先配置默认目录、影巢 OpenAPI、夸克会话,以及需要的飞书机器人信息。调试模式仅排查问题时打开。", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "enabled", + "label": "启用插件", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "notify", + "label": "发送通知", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "debug", + "label": "调试模式", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "下面这组是智能体默认评分策略,只影响还没有保存个人偏好的新会话。高分不代表一定执行;遇到影巢高积分、PT 低做种这类硬风险时,插件仍会拦截。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "mp_download_save_path", + "label": "PT 下载保存路径(可选)", + "placeholder": "MP 和 qB 在同一台机器可留空;不在同一台机器时填 qB 默认下载路径,如 /media/downloads/qb", + "hint": "只影响“下载 / MP搜索 / PT搜索”。MP 与 qB 分离时,填 qB WebUI 里的默认保存路径;同机一般不用填。", + "persistentHint": True, + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_pt_min_seeders", + "label": "PT 最低做种数", + "type": "number", + "placeholder": "3", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_confirm_score_threshold", + "label": "建议确认分数线", + "type": "number", + "placeholder": "70", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "assistant_default_auto_ingest_score_threshold", + "label": "自动入库分数线", + "type": "number", + "placeholder": "90", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "assistant_default_auto_ingest_enabled", + "label": "默认允许高分自动入库", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢用于资源搜索、解锁、配额查询和签到。资源入口关闭后,智能体和飞书都不会执行影巢搜索、解锁或转存;单资源积分上限默认 20 分,超过就拦截提醒,填 0 表示不限制。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_api_key", + "label": "影巢 API Key", + "rows": 2, + "placeholder": "填写影巢 OpenAPI 的 API Key", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_resource_enabled", + "label": "启用影巢资源搜索/解锁", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_max_unlock_points", + "label": "单资源积分上限", + "type": "number", + "placeholder": "20;填 0 不限制", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "text": "建议保留积分上限,避免智能体一步到位时误选高积分资源。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_base_url", + "label": "影巢 Base URL", + "placeholder": "https://hdhive.com", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_timeout", + "label": "影巢超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_default_path", + "label": "影巢默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_candidate_page_size", + "label": "候选页大小", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "影巢签到支持 OpenAPI 与网页兜底两种方式。OpenAPI 签到需要 Premium;普通用户建议优先使用本机“影巢Cookie导出.command”自动写回完整网页登录 Cookie。手工复制 Cookie 容易漏字段,导致看起来已填写但签到仍失败。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_enabled", + "label": "启用影巢签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_gambler_mode", + "label": "默认赌狗签到", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_once", + "label": "立即运行一次", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_cron", + "label": "影巢签到 Cron", + "placeholder": "0 8 * * *", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "hdhive_checkin_cookie", + "label": "影巢网页 Cookie(非 Premium 兜底)", + "rows": 3, + "placeholder": "不建议手工填写;优先在 Edge 登录 hdhive.com 后运行“影巢Cookie导出.command”自动写回", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "hdhive_checkin_auto_login", + "label": "自动刷新 Cookie", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_username", + "label": "影巢用户名/邮箱", + "placeholder": "用于 Cookie 失效时自动登录", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "hdhive_checkin_password", + "label": "影巢密码", + "type": "password", + "placeholder": "仅保存在 MoviePilot 本机配置中", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "盘搜用于聚合公开网盘分享结果。请填写 MoviePilot 容器视角下可访问的 API 地址,不要按外部智能体机器的视角填写。普通“搜索/找片”默认先盘搜;“云盘搜索”固定比较盘搜 + 影巢。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_base_url", + "label": "盘搜 API 地址", + "placeholder": "http://host.docker.internal:805", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "pansou_timeout", + "label": "盘搜超时(秒)", + "type": "number", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "夸克用于转存 pan.quark.cn 分享链接。优先使用 CookieCloud 或有效网页登录 Cookie;只有明确出现“require login [guest]”“夸克登录态已过期”“当前夸克登录态不足”时,才建议走 Cookie 修复。分享受限、41031、分享者封禁不属于 Cookie 失效。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "quark_cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "可手工填写,但更推荐 CookieCloud 或本机夸克Cookie导出工具自动写回", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_default_path", + "label": "夸克默认目录", + "placeholder": "/飞书", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "quark_timeout", + "label": "夸克超时(秒)", + "type": "number", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "quark_auto_import_cookiecloud", + "label": "允许自动刷新 Cookie", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "115 建议走扫码会话,不建议填网页版 Cookie。插件支持 /p115/qrcode 和 /p115/qrcode/check 两步扫码登录;手填 Cookie 仅作为高级兜底。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "p115_default_path", + "label": "115 默认目录", + "placeholder": "/待整理", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "p115_client_type", + "label": "115 扫码客户端类型", + "items": self._p115_client_type_items(), + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "p115_prefer_direct", + "label": "115 优先直转", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "p115_cookie", + "label": "115 扫码会话 Cookie(高级,可选)", + "rows": 3, + "placeholder": "仅支持 UID/CID/SEID/KID 这类扫码客户端 Cookie;普通网页版 Cookie 不建议粘贴到这里", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": "飞书入口默认关闭。开启后可以在飞书里发送搜索、云盘搜索、转存、夸克转存、下载、更新检查、115 登录和影巢签到等命令;同一个飞书机器人建议只配置一个接收入口。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_enabled", + "label": "启用内置飞书入口", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_allow_all", + "label": "允许所有飞书会话", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "feishu_reply_enabled", + "label": "发送飞书回复", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_id", + "label": "飞书 App ID", + "placeholder": "cli_xxxxxxxxx", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_app_secret", + "label": "飞书 App Secret", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "feishu_verification_token", + "label": "Verification Token", + "type": "password", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "feishu_reply_receive_id_type", + "label": "回复 ID 类型", + "items": [ + {"title": "群聊 chat_id", "value": "chat_id"}, + {"title": "用户 open_id", "value": "open_id"}, + {"title": "用户 union_id", "value": "union_id"}, + {"title": "用户 user_id", "value": "user_id"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_chat_ids", + "label": "允许的群聊 Chat ID", + "rows": 3, + "placeholder": "一个一行;allow_all 关闭时生效", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_allowed_user_ids", + "label": "允许的用户 Open ID", + "rows": 3, + "placeholder": "一个一行", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_whitelist", + "label": "飞书命令白名单", + "rows": 3, + "placeholder": "逗号或换行分隔;留空时会自动合并当前主线命令。旧 STRM/刮削命令不再默认暴露,如需兼容旧环境可手动加入。", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "feishu_command_aliases", + "label": "飞书命令别名", + "rows": 5, + "placeholder": FeishuChannel.default_command_aliases(), + "hint": "默认别名已统一走 Agent影视助手 route/pick:转存默认 115,夸克转存需显式发送;旧 STRM/刮削别名如需保留请手动添加。", + }, + } + ], + }, + ], + }, + ], + } + ] + return form, self._build_config() + + async def api_feishu_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + channel = self._ensure_feishu_channel() + return { + "success": True, + "message": "Agent影视助手 内置飞书入口状态", + "data": { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + }, + } + + async def api_quark_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_quark_service() + cookie_ok, cookie_message = service.check_cookie() + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "quark_cookie_configured": bool(self._quark_cookie), + "quark_cookie_valid": cookie_ok, + "default_target_path": self._quark_default_path, + "message": "" if cookie_ok else cookie_message, + }, + } + + async def api_quark_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_text = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_quark_service() + transfer_ok, result, transfer_message = service.transfer_share( + share_text, + access_code=access_code, + target_path=target_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_hdhive_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_hdhive_service() + ping_ok, result, ping_message, _status_code = service.request("GET", "/api/open/ping") + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "hdhive_api_key_configured": bool(self._hdhive_api_key), + "hdhive_ping_ok": ping_ok, + "base_url": self._hdhive_base_url, + "default_target_path": self._hdhive_default_path, + "resource_enabled": self._hdhive_resource_enabled, + "max_unlock_points": self._hdhive_max_unlock_points, + "checkin_enabled": self._hdhive_checkin_enabled, + "checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "checkin_cron": self._hdhive_checkin_cron, + "checkin_web_cookie_configured": bool(self._hdhive_checkin_cookie), + "checkin_auto_login_enabled": self._hdhive_checkin_auto_login, + "checkin_username_configured": bool(self._hdhive_checkin_username), + "message": "" if ping_ok else ping_message, + "raw": result, + }, + } + + async def api_hdhive_account(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + account_ok, result, account_message = service.fetch_me() + if not account_ok: + if self._is_hdhive_premium_limited(account_message): + fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) + if fallback_account: + return { + "success": True, + "message": "当前返回的是网页用户快照", + "data": fallback_account, + } + return {"success": False, "message": self._friendly_hdhive_error(account_message, "账号"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_checkin(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + is_gambler = self._parse_bool_value(body.get("is_gambler"), self._hdhive_checkin_gambler_mode) + return self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 API") + + async def api_hdhive_checkin_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._hdhive_checkin_history_public_data(limit=limit) + return { + "success": True, + "message": self._format_hdhive_checkin_history_text(limit=limit), + "data": data, + } + + async def api_hdhive_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + quota_ok, result, quota_message = service.fetch_quota() + if not quota_ok: + return {"success": False, "message": self._friendly_hdhive_error(quota_message, "配额"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_usage_today(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + usage_ok, result, usage_message = service.fetch_usage_today() + if not usage_ok: + return {"success": False, "message": self._friendly_hdhive_error(usage_message, "今日用量"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_weekly_free_quota(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + service = self._ensure_hdhive_service() + weekly_ok, result, weekly_message = service.fetch_weekly_free_quota() + if not weekly_ok: + return {"success": False, "message": self._friendly_hdhive_error(weekly_message, "每周免费额度"), "data": result} + return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} + + async def api_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + media_type = self._clean_text(body.get("media_type") or body.get("type") or "movie").lower() + tmdb_id = self._clean_text(body.get("tmdb_id")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = service.search_resources(media_type=media_type, tmdb_id=tmdb_id) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_search_by_keyword(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + candidate_limit = self._safe_int(body.get("candidate_limit"), self._hdhive_candidate_page_size) + result_limit = self._safe_int(body.get("limit"), 12) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + result_limit=result_limit, + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + return {"success": True, "message": "success", "data": result} + + async def api_hdhive_unlock(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(body) + if not points_ok: + return {"success": False, "message": points_message, "data": {"resource_guard": points_data}} + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return {"success": False, "message": unlock_message, "data": result} + return {"success": True, "message": "success", "data": result} + + @staticmethod + def _new_session_id(prefix: str = "aro") -> str: + return f"{prefix}-{uuid.uuid4().hex[:12]}" + + def _save_session(self, session_id: str, payload: Dict[str, Any]) -> None: + payload = dict(payload) + payload["updated_at"] = int(time.time()) + with self._session_lock: + self._session_cache[session_id] = payload + self._persist_relevant_sessions() + + def _load_session(self, session_id: str) -> Optional[Dict[str, Any]]: + with self._session_lock: + session = self._session_cache.get(session_id) + if not session: + return None + return dict(session) + + def _persist_relevant_sessions(self) -> None: + try: + data: Dict[str, Dict[str, Any]] = {} + with self._session_lock: + for session_id, payload in (self._session_cache or {}).items(): + session = dict(payload or {}) + if self._is_session_expired(session): + continue + if str(session_id).startswith("assistant::") or session.get("pending_p115") or str(session.get("kind") or "").strip() == "assistant_p115_login": + data[session_id] = session + self.save_data(key=self._session_store_key, value=data) + except Exception: + pass + + def _restore_persisted_sessions(self) -> None: + try: + restored = self.get_data(self._session_store_key) or {} + if isinstance(restored, dict): + with self._session_lock: + for session_id, payload in restored.items(): + if isinstance(payload, dict) and not self._is_session_expired(payload): + self._session_cache[str(session_id)] = dict(payload) + except Exception: + pass + + def _persist_execution_history(self) -> None: + try: + history = list(self._execution_history or [])[-self._execution_history_limit:] + self._execution_history = history + self.save_data(key=self._execution_history_store_key, value=history) + except Exception: + pass + + def _restore_execution_history(self) -> None: + try: + restored = self.get_data(self._execution_history_store_key) or [] + if isinstance(restored, list): + self._execution_history = [ + dict(item) + for item in restored[-self._execution_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._execution_history = [] + + def _persist_hdhive_checkin_history(self) -> None: + try: + history = list(self._hdhive_checkin_history or [])[-self._hdhive_checkin_history_limit:] + self._hdhive_checkin_history = history + self.save_data(key=self._hdhive_checkin_history_store_key, value=history) + except Exception: + pass + + def _restore_hdhive_checkin_history(self) -> None: + try: + restored = self.get_data(self._hdhive_checkin_history_store_key) or [] + if isinstance(restored, list): + self._hdhive_checkin_history = [ + dict(item) + for item in restored[-self._hdhive_checkin_history_limit:] + if isinstance(item, dict) + ] + except Exception: + self._hdhive_checkin_history = [] + + def _record_hdhive_checkin_history( + self, + *, + trigger: str, + is_gambler: bool, + result: Dict[str, Any], + ) -> None: + timestamp = int(time.time()) + payload = dict(result or {}) + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + entry = { + "id": self._new_session_id("hdhive-sign"), + "time": timestamp, + "time_text": self._format_unix_time(timestamp), + "trigger": self._clean_text(trigger), + "mode": "赌狗签到" if is_gambler else "普通签到", + "is_gambler": bool(is_gambler), + "success": bool(payload.get("success")), + "message_head": self._assistant_result_message_head(payload.get("message")), + "status": self._clean_text(data.get("status")) or ("成功" if payload.get("success") else "失败"), + "source": self._clean_text(data.get("source")), + "status_code": data.get("status_code"), + "login_retry": bool(((data.get("login") or {}) if isinstance(data.get("login"), dict) else {}).get("ok")), + "web_fallback": bool(data.get("web_fallback")) if isinstance(data, dict) else False, + } + self._hdhive_checkin_history.append(entry) + self._hdhive_checkin_history = self._hdhive_checkin_history[-self._hdhive_checkin_history_limit:] + self._persist_hdhive_checkin_history() + + def _hdhive_checkin_history_public_data(self, *, limit: int = 20) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), self._hdhive_checkin_history_limit) + items = list(reversed(self._hdhive_checkin_history or []))[:max_limit] + return { + "total": len(self._hdhive_checkin_history or []), + "limit": max_limit, + "items": items, + } + + def _format_hdhive_checkin_history_text(self, *, limit: int = 10) -> str: + data = self._hdhive_checkin_history_public_data(limit=limit) + items = data.get("items") or [] + if not items: + return "暂无影巢签到日志。" + lines = [f"影巢签到日志:最近 {len(items)} 条"] + for idx, item in enumerate(items, start=1): + ok_text = "成功" if item.get("success") else "失败" + parts = [ + f"{idx}. {item.get('time_text') or ''}", + f"{item.get('mode') or ''}", + ok_text, + ] + if item.get("trigger"): + parts.append(f"来源:{item.get('trigger')}") + if item.get("login_retry"): + parts.append("已自动刷新Cookie") + message = self._clean_text(item.get("message_head")) + if message: + parts.append(message) + lines.append(" | ".join(part for part in parts if part)) + return "\n".join(lines) + + def _hdhive_resource_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢资源入口已关闭:当前不会执行影巢搜索、解锁或转存。可在插件设置中开启“影巢资源搜索/解锁”。", + "data": { + "provider": "hdhive", + "resource_enabled": False, + "error_code": "hdhive_resource_disabled", + }, + } + + def _ensure_hdhive_resource_enabled(self) -> Tuple[bool, Dict[str, Any]]: + if self._hdhive_resource_enabled: + return True, {} + return False, self._hdhive_resource_disabled_response() + + def _hdhive_checkin_disabled_response(self) -> Dict[str, Any]: + return { + "success": False, + "message": "影巢签到入口已关闭:如需执行签到,请先在插件设置中开启“影巢签到”。", + "data": { + "provider": "hdhive", + "checkin_enabled": False, + "error_code": "hdhive_checkin_disabled", + }, + } + + @staticmethod + def _resource_has_free_marker(item: Optional[Dict[str, Any]]) -> bool: + if not isinstance(item, dict) or not item: + return False + for key in ["unlock_points", "cost", "points", "price", "point_text"]: + text = str(item.get(key) or "").strip().lower() + if text in {"0", "free", "免费", "0分"}: + return True + blob = " ".join( + str(item.get(key) or "") + for key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "note", + "source", + "source_name", + ] + ).lower() + return "免费" in blob or " free " in f" {blob} " + + @staticmethod + def _resource_points_value(item: Optional[Dict[str, Any]]) -> Optional[int]: + if not item: + return None + raw = item.get("unlock_points") + if raw is None: + raw = item.get("cost") + if raw is None: + raw = item.get("points") + text = str(raw or "").strip() + if not text: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + if text.lower() == "free" or text == "免费": + return 0 + match = re.search(r"-?\d+", text) + if not match: + return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None + try: + return int(match.group(0)) + except Exception: + return None + + def _check_hdhive_unlock_points_limit(self, resource: Optional[Dict[str, Any]]) -> Tuple[bool, str, Dict[str, Any]]: + limit = max(0, self._safe_int(self._hdhive_max_unlock_points, 20)) + if limit <= 0: + return True, "", {"limit": limit, "points": None, "limited": False} + points = self._resource_points_value(resource) + title = self._clean_text((resource or {}).get("title") or (resource or {}).get("matched_title") or "该资源") + if points is None: + return True, "", {"limit": limit, "points": None, "limited": False, "reason": "unknown_points", "title": title} + if points > limit: + return False, ( + f"已阻止影巢解锁:{title} 需要 {points} 分,超过当前单资源积分上限 {limit} 分。" + "如确认要解锁,请提高上限或临时设为 0。" + ), {"limit": limit, "points": points, "limited": True, "reason": "points_over_limit"} + return True, "", {"limit": limit, "points": points, "limited": False} + + def _default_assistant_preferences(self) -> Dict[str, Any]: + return { + "schema_version": "preferences.v1", + "initialized": False, + "prefer_resolution": "4K", + "prefer_dolby_vision": True, + "prefer_hdr": True, + "prefer_chinese_subtitle": True, + "prefer_complete_series": True, + "prefer_cloud_provider": "", + "enable_pansou": True, + "enable_hdhive": True, + "enable_mp_pt": True, + "has_quark": True, + "has_115": True, + "cloud_default_path": self._hdhive_default_path, + "quark_default_path": self._quark_default_path, + "p115_default_path": self._p115_default_path, + "pt_require_free": False, + "pt_min_seeders": self._assistant_default_pt_min_seeders, + "pt_prefer_free": True, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, + "auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, + "confirm_score_threshold": self._assistant_default_confirm_score_threshold, + "updated_at": 0, + } + + def _normalize_preference_key(self, session: Any = None, user_key: Any = None) -> str: + key = self._clean_text(user_key) + if key: + return key + session_name = self._clean_text(session) or "default" + return f"session:{session_name}" + + def _normalize_assistant_preferences(self, value: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + defaults = self._default_assistant_preferences() + payload = dict(value or {}) + if "resolution_priority" in payload and "prefer_resolution" not in payload: + choices = payload.get("resolution_priority") + if isinstance(choices, (list, tuple)) and choices: + payload["prefer_resolution"] = choices[0] + else: + payload["prefer_resolution"] = choices + if "subtitle_priority" in payload and "prefer_chinese_subtitle" not in payload: + subtitle_text = " ".join(str(item) for item in payload.get("subtitle_priority") or []) if isinstance(payload.get("subtitle_priority"), (list, tuple)) else str(payload.get("subtitle_priority") or "") + payload["prefer_chinese_subtitle"] = bool(re.search(r"中文|中字|简中|繁中|双语|chinese", subtitle_text, flags=re.IGNORECASE)) + if "pt_free_only" in payload and "pt_require_free" not in payload: + payload["pt_require_free"] = payload.get("pt_free_only") + if "preferred_cloud_drive" in payload and "prefer_cloud_provider" not in payload: + providers = payload.get("preferred_cloud_drive") + payload["prefer_cloud_provider"] = providers[0] if isinstance(providers, (list, tuple)) and providers else providers + if "available_sources" in payload: + sources = payload.get("available_sources") + if isinstance(sources, (list, tuple, set)): + source_list = [self._clean_text(item).lower() for item in sources] + if "enable_pansou" not in payload: + payload["enable_pansou"] = "pansou" in source_list or "盘搜" in source_list + if "enable_hdhive" not in payload: + payload["enable_hdhive"] = any(item in source_list for item in ["hdhive", "影巢", "影潮"]) + if "enable_mp_pt" not in payload: + payload["enable_mp_pt"] = any(item in source_list for item in ["mp_pt", "mp", "pt", "原生", "moviepilot"]) + if "available_providers" in payload: + providers = payload.get("available_providers") + if isinstance(providers, (list, tuple, set)): + provider_list = [self._clean_text(item).lower() for item in providers] + if "has_115" not in payload: + payload["has_115"] = "115" in provider_list + if "has_quark" not in payload: + payload["has_quark"] = "quark" in provider_list or "夸克" in provider_list + if "auto_ingest" in payload and "auto_ingest_enabled" not in payload: + payload["auto_ingest_enabled"] = payload.get("auto_ingest") + if "auto_execute_score_threshold" in payload and "auto_ingest_score_threshold" not in payload: + payload["auto_ingest_score_threshold"] = payload.get("auto_execute_score_threshold") + normalized = {**defaults, **payload} + normalized["schema_version"] = "preferences.v1" + normalized["initialized"] = bool(normalized.get("initialized")) + normalized["prefer_resolution"] = self._clean_text(normalized.get("prefer_resolution") or defaults["prefer_resolution"]).upper() + normalized["prefer_dolby_vision"] = self._parse_bool_value(normalized.get("prefer_dolby_vision"), True) + normalized["prefer_hdr"] = self._parse_bool_value(normalized.get("prefer_hdr"), True) + normalized["prefer_chinese_subtitle"] = self._parse_bool_value(normalized.get("prefer_chinese_subtitle"), True) + normalized["prefer_complete_series"] = self._parse_bool_value(normalized.get("prefer_complete_series"), True) + normalized["prefer_cloud_provider"] = self._clean_text(normalized.get("prefer_cloud_provider")).lower() + normalized["enable_pansou"] = self._parse_bool_value(normalized.get("enable_pansou"), True) + normalized["enable_hdhive"] = self._parse_bool_value(normalized.get("enable_hdhive"), True) + normalized["enable_mp_pt"] = self._parse_bool_value(normalized.get("enable_mp_pt"), True) + normalized["has_quark"] = self._parse_bool_value(normalized.get("has_quark"), True) + normalized["has_115"] = self._parse_bool_value(normalized.get("has_115"), True) + normalized["cloud_default_path"] = self._normalize_path(normalized.get("cloud_default_path") or self._hdhive_default_path) + normalized["quark_default_path"] = self._normalize_path(normalized.get("quark_default_path") or self._quark_default_path) + normalized["p115_default_path"] = self._normalize_path(normalized.get("p115_default_path") or self._p115_default_path) + normalized["pt_require_free"] = self._parse_bool_value(normalized.get("pt_require_free"), False) + normalized["pt_min_seeders"] = max(0, self._safe_int(normalized.get("pt_min_seeders"), 3)) + normalized["pt_prefer_free"] = self._parse_bool_value(normalized.get("pt_prefer_free"), True) + normalized["hdhive_max_unlock_points"] = max(0, self._safe_int(normalized.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + normalized["auto_ingest_enabled"] = self._parse_bool_value(normalized.get("auto_ingest_enabled"), False) + normalized["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("auto_ingest_score_threshold"), 90))) + normalized["confirm_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("confirm_score_threshold"), 70))) + normalized["updated_at"] = self._safe_int(normalized.get("updated_at"), 0) + return normalized + + def _restore_assistant_preferences(self) -> None: + try: + restored = self.get_data(self._assistant_preferences_store_key) or {} + if isinstance(restored, dict): + self._assistant_preferences = { + self._clean_text(key): self._normalize_assistant_preferences(value) + for key, value in restored.items() + if self._clean_text(key) and isinstance(value, dict) + } + except Exception: + self._assistant_preferences = {} + + def _persist_assistant_preferences(self) -> None: + try: + items = list((self._assistant_preferences or {}).items())[-self._assistant_preferences_limit:] + self._assistant_preferences = {key: value for key, value in items if key} + self.save_data(key=self._assistant_preferences_store_key, value=self._assistant_preferences) + except Exception: + pass + + def _assistant_preferences_public_data(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + questions = [ + "你更偏好 4K 还是 1080P?", + "是否优先杜比视界 / HDR?", + "是否必须中文字幕?", + "电视剧是否优先全集或完整季?", + "你可用哪些源?例如:只用盘搜、只用影巢、只用 MP/PT,或者关闭其中某些源。", + "你可用哪些云盘?例如:只有夸克、只有 115、两者都可。", + "PT 资源最低做种数是多少?默认 3。", + "影巢单资源最多愿意消耗多少积分?默认 20。", + "是否允许 90 分以上资源自动入库?默认关闭。", + ] + return { + "schema_version": "preferences.v1", + "key": key, + "initialized": bool(preferences.get("initialized")), + "preferences": preferences, + "needs_onboarding": not bool(preferences.get("initialized")), + "onboarding_questions": questions if not bool(preferences.get("initialized")) else [], + "defaults": self._default_assistant_preferences(), + } + + def _save_assistant_preferences( + self, + *, + session: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + current = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + incoming = dict(preferences or {}) + merged = {**current, **incoming} + merged["initialized"] = self._parse_bool_value(incoming.get("initialized"), True) if "initialized" in incoming else True + merged["updated_at"] = int(time.time()) + normalized = self._normalize_assistant_preferences(merged) + self._assistant_preferences[key] = normalized + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _reset_assistant_preferences(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + self._assistant_preferences.pop(key, None) + self._persist_assistant_preferences() + return self._assistant_preferences_public_data(session=session, user_key=key) + + def _assistant_preferences_status_brief(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: + data = self._assistant_preferences_public_data(session=session, user_key=user_key) + prefs = dict(data.get("preferences") or {}) + brief = { + "key": data.get("key"), + "initialized": bool(data.get("initialized")), + "needs_onboarding": bool(data.get("needs_onboarding")), + "summary": { + "prefer_resolution": self._clean_text(prefs.get("prefer_resolution")), + "prefer_dolby_vision": bool(prefs.get("prefer_dolby_vision")), + "prefer_hdr": bool(prefs.get("prefer_hdr")), + "prefer_chinese_subtitle": bool(prefs.get("prefer_chinese_subtitle")), + "prefer_complete_series": bool(prefs.get("prefer_complete_series")), + "prefer_cloud_provider": self._clean_text(prefs.get("prefer_cloud_provider")), + "enable_pansou": bool(prefs.get("enable_pansou")), + "enable_hdhive": bool(prefs.get("enable_hdhive")), + "enable_mp_pt": bool(prefs.get("enable_mp_pt")), + "has_quark": bool(prefs.get("has_quark")), + "has_115": bool(prefs.get("has_115")), + "pt_min_seeders": self._safe_int(prefs.get("pt_min_seeders"), 3), + "pt_require_free": bool(prefs.get("pt_require_free")), + "hdhive_max_unlock_points": self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points), + "auto_ingest_enabled": bool(prefs.get("auto_ingest_enabled")), + "auto_ingest_score_threshold": self._safe_int(prefs.get("auto_ingest_score_threshold"), 90), + }, + } + if brief["needs_onboarding"]: + brief["onboarding_questions"] = data.get("onboarding_questions") or [] + brief["recommended_action"] = "ask_user_preferences_then_save" + return brief + + def _assistant_default_preferences_template(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "prefer_resolution": prefs.get("prefer_resolution"), + "prefer_dolby_vision": prefs.get("prefer_dolby_vision"), + "prefer_hdr": prefs.get("prefer_hdr"), + "prefer_chinese_subtitle": prefs.get("prefer_chinese_subtitle"), + "prefer_complete_series": prefs.get("prefer_complete_series"), + "prefer_cloud_provider": prefs.get("prefer_cloud_provider"), + "enable_pansou": prefs.get("enable_pansou"), + "enable_hdhive": prefs.get("enable_hdhive"), + "enable_mp_pt": prefs.get("enable_mp_pt"), + "has_quark": prefs.get("has_quark"), + "has_115": prefs.get("has_115"), + "pt_require_free": prefs.get("pt_require_free"), + "pt_min_seeders": prefs.get("pt_min_seeders"), + "hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "p115_default_path": prefs.get("p115_default_path"), + "quark_default_path": prefs.get("quark_default_path"), + "auto_ingest_enabled": prefs.get("auto_ingest_enabled"), + "auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + } + + def _assistant_preferences_for_session(self, session: str = "", user_key: str = "") -> Dict[str, Any]: + key = self._normalize_preference_key(session=session, user_key=user_key) + return self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) + + def _assistant_source_enabled(self, preferences: Optional[Dict[str, Any]], source_type: str) -> bool: + prefs = self._normalize_assistant_preferences(preferences) + source = self._clean_text(source_type).lower() + if source == "pansou": + return bool(prefs.get("enable_pansou")) + if source == "hdhive": + return bool(prefs.get("enable_hdhive")) + if source in {"mp", "mp_pt", "pt"}: + return bool(prefs.get("enable_mp_pt")) + return True + + def _assistant_available_cloud_providers(self, preferences: Optional[Dict[str, Any]]) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + providers: List[str] = [] + if self._parse_bool_value(prefs.get("has_115"), True): + providers.append("115") + if self._parse_bool_value(prefs.get("has_quark"), True): + providers.append("quark") + return providers + + def _assistant_filter_cloud_items_by_preferences( + self, + items: Optional[List[Dict[str, Any]]], + preferences: Optional[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + available = set(self._assistant_available_cloud_providers(preferences)) + source_items = [dict(item or {}) for item in (items or []) if isinstance(item, dict)] + if not available: + return [] + if available == {"115", "quark"}: + return source_items + + filtered: List[Dict[str, Any]] = [] + for item in source_items: + provider = self._clean_text( + item.get("pan_type") + or item.get("provider") + or item.get("channel") + or item.get("network") + or item.get("netdisk") + or item.get("cloud") + ).lower() + if provider in {"quark", "夸克", "quarkcloud"}: + provider = "quark" + elif provider in {"115", "115cloud", "115网盘"}: + provider = "115" + if provider in available: + filtered.append(item) + return filtered + + def _assistant_smart_search_source_order( + self, + preferences: Optional[Dict[str, Any]], + source_order: Optional[List[str]] = None, + ) -> List[str]: + prefs = self._normalize_assistant_preferences(preferences) + requested = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + order = requested or ["pansou", "hdhive", "mp_pt"] + normalized: List[str] = [] + for item in order: + if item in {"mp", "pt"}: + item = "mp_pt" + if item not in {"pansou", "hdhive", "mp_pt"} or item in normalized: + continue + if self._assistant_source_enabled(prefs, item): + normalized.append(item) + return normalized + + def _assistant_smart_source_availability( + self, + preferences: Optional[Dict[str, Any]], + *, + source_order: Optional[List[str]] = None, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + prefs = self._normalize_assistant_preferences(preferences) + available_cloud = self._assistant_available_cloud_providers(prefs) + order = self._assistant_smart_search_source_order(prefs, source_order=source_order) + source_labels = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + available: List[Dict[str, Any]] = [] + blocked: List[Dict[str, Any]] = [] + for source in ["pansou", "hdhive", "mp_pt"]: + entry = { + "source_type": source, + "label": source_labels[source], + } + if source not in order: + if not self._assistant_source_enabled(prefs, source): + blocked.append({**entry, "reason": f"当前偏好已关闭{source_labels[source]}"}) + else: + blocked.append({**entry, "reason": "当前决策顺序未包含该源"}) + continue + if source in {"pansou", "hdhive"} and not available_cloud: + blocked.append({**entry, "reason": "当前偏好未启用任何云盘"}) + continue + available.append({ + **entry, + "provider_scope": list(available_cloud) if source in {"pansou", "hdhive"} else ["pt"], + }) + if "115" not in available_cloud: + blocked.append({"source_type": "115", "label": "115", "reason": "当前偏好未启用 115"}) + if "quark" not in available_cloud: + blocked.append({"source_type": "quark", "label": "夸克", "reason": "当前偏好未启用夸克"}) + return available, blocked + + def _assistant_smart_decision_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + profile = self._clean_text(decision_profile).lower() + if not profile: + return prefs + updated = dict(prefs) + confirm_threshold = self._safe_int( + updated.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + if profile == "conservative": + updated["confirm_score_threshold"] = max(confirm_threshold, min(95, confirm_threshold + 10)) + elif profile == "aggressive": + updated["confirm_score_threshold"] = max(40, min(100, confirm_threshold - 10)) + return self._normalize_assistant_preferences(updated) + + def _assistant_smart_session_overrides_summary(self, overrides: Optional[Dict[str, Any]]) -> str: + raw = dict(overrides or {}) + if not raw: + return "" + payload = self._normalize_assistant_preferences(raw) + parts: List[str] = [] + if "has_quark" in raw or "has_115" in raw: + if payload.get("has_quark") and not payload.get("has_115"): + parts.append("只用夸克") + elif payload.get("has_115") and not payload.get("has_quark"): + parts.append("只用115") + elif payload.get("has_115") and payload.get("has_quark"): + parts.append("115 / 夸克都可") + if "enable_pansou" in raw or "enable_hdhive" in raw or "enable_mp_pt" in raw: + enabled_sources: List[str] = [] + if payload.get("enable_pansou"): + enabled_sources.append("盘搜") + if payload.get("enable_hdhive"): + enabled_sources.append("影巢") + if payload.get("enable_mp_pt"): + enabled_sources.append("MP/PT") + if enabled_sources: + parts.append("仅会话源:" + " / ".join(enabled_sources)) + return ";".join(parts[:2]) + + def _assistant_smart_merge_session_preferences( + self, + preferences: Optional[Dict[str, Any]], + *, + session_overrides: Optional[Dict[str, Any]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + merged = { + **self._normalize_assistant_preferences(preferences), + **dict(session_overrides or {}), + } + return self._assistant_smart_decision_preferences( + merged, + decision_profile=decision_profile, + ) + + def _assistant_smart_apply_session_override( + self, + session_overrides: Optional[Dict[str, Any]], + *, + adjust_action: str, + source_order: Optional[List[str]] = None, + ) -> Tuple[Dict[str, Any], List[str]]: + overrides = dict(session_overrides or {}) + order = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] + normalized_action = self._clean_text(adjust_action).lower() + if normalized_action == "decision_only_quark": + overrides["has_quark"] = True + overrides["has_115"] = False + overrides["prefer_cloud_provider"] = "quark" + elif normalized_action == "decision_only_115": + overrides["has_quark"] = False + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "115" + elif normalized_action == "decision_cloud_both": + overrides["has_quark"] = True + overrides["has_115"] = True + overrides["prefer_cloud_provider"] = "" + elif normalized_action == "decision_only_mp_pt": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = True + order = ["mp_pt"] + elif normalized_action == "decision_only_pansou": + overrides["enable_pansou"] = True + overrides["enable_hdhive"] = False + overrides["enable_mp_pt"] = False + order = ["pansou"] + elif normalized_action == "decision_only_hdhive": + overrides["enable_pansou"] = False + overrides["enable_hdhive"] = True + overrides["enable_mp_pt"] = False + order = ["hdhive"] + elif normalized_action == "decision_disable_pansou": + overrides["enable_pansou"] = False + elif normalized_action == "decision_disable_hdhive": + overrides["enable_hdhive"] = False + elif normalized_action == "decision_disable_mp_pt": + overrides["enable_mp_pt"] = False + elif normalized_action == "decision_reset_preferences": + overrides = {} + order = [] + return overrides, order + + def _parse_assistant_preferences_text(self, text: str) -> Dict[str, Any]: + raw = self._clean_text(text) + compact = re.sub(r"\s+", "", raw).lower() + payload: Dict[str, Any] = {} + if re.search(r"1080|fhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "1080P" + elif re.search(r"4k|2160|uhd", raw, flags=re.IGNORECASE): + payload["prefer_resolution"] = "4K" + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}(杜比|dv)", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = False + elif re.search(r"杜比|dv|dolby", raw, flags=re.IGNORECASE): + payload["prefer_dolby_vision"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,4}hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = False + elif re.search(r"hdr", raw, flags=re.IGNORECASE): + payload["prefer_hdr"] = True + + if re.search(r"(不要|不需要|关闭|禁用).{0,6}(中字|中文|字幕)", raw, flags=re.IGNORECASE) or re.search(r"无字幕也可|字幕无所谓", raw): + payload["prefer_chinese_subtitle"] = False + elif re.search(r"中字|中文|简中|繁中|双语字幕|字幕", raw, flags=re.IGNORECASE): + payload["prefer_chinese_subtitle"] = True + + if re.search(r"不强求全集|不要全集|单集也可|更新也可", raw): + payload["prefer_complete_series"] = False + elif re.search(r"全集|完整季|整季|完结", raw): + payload["prefer_complete_series"] = True + + if re.search(r"夸克优先|优先夸克|quark", raw, flags=re.IGNORECASE): + payload["prefer_cloud_provider"] = "quark" + elif re.search(r"115优先|优先115", raw): + payload["prefer_cloud_provider"] = "115" + + if re.search(r"只用盘搜|仅用盘搜|只搜盘搜", raw): + payload["enable_pansou"] = True + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = False + elif re.search(r"只用影巢|仅用影巢|只搜影巢|只用影潮", raw): + payload["enable_pansou"] = False + payload["enable_hdhive"] = True + payload["enable_mp_pt"] = False + elif re.search(r"只用原生|只用pt|仅用pt|只搜pt|只搜原生|只要原生", raw, flags=re.IGNORECASE): + payload["enable_pansou"] = False + payload["enable_hdhive"] = False + payload["enable_mp_pt"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}盘搜", raw): + payload["enable_pansou"] = False + elif re.search(r"(启用|使用|打开).{0,4}盘搜", raw): + payload["enable_pansou"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = False + elif re.search(r"(启用|使用|打开).{0,4}(影巢|影潮)", raw): + payload["enable_hdhive"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = False + elif re.search(r"(启用|使用|打开).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): + payload["enable_mp_pt"] = True + + if re.search(r"只有夸克|仅有夸克|只用夸克|没有115|不用115", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + payload["has_115"] = False + elif re.search(r"只有115|仅有115|只用115|没有夸克|不用夸克", raw, flags=re.IGNORECASE): + payload["has_115"] = True + payload["has_quark"] = False + if re.search(r"(不要|不用|关闭|禁用).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = False + elif re.search(r"(启用|使用|打开).{0,4}夸克", raw, flags=re.IGNORECASE): + payload["has_quark"] = True + if re.search(r"(不要|不用|关闭|禁用).{0,4}115", raw): + payload["has_115"] = False + elif re.search(r"(启用|使用|打开).{0,4}115", raw): + payload["has_115"] = True + + if re.search(r"pt.{0,4}(只要|必须).{0,4}免费|只下免费|只要免费", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = True + elif re.search(r"pt.{0,4}(不限|不强求).{0,4}免费|不只要免费|免费不强求", raw, flags=re.IGNORECASE): + payload["pt_require_free"] = False + + seed_match = re.search(r"(?:做种|种子|seeders?|seeder)[^\d]{0,8}(\d+)", raw, flags=re.IGNORECASE) + if seed_match: + payload["pt_min_seeders"] = self._safe_int(seed_match.group(1), 3) + + points_match = re.search(r"(?:影巢|积分|解锁)[^\d]{0,10}(\d+)", raw) + if points_match: + payload["hdhive_max_unlock_points"] = self._safe_int(points_match.group(1), self._hdhive_max_unlock_points) + + if re.search(r"(不|不要|关闭|禁用).{0,4}自动入库", raw): + payload["auto_ingest_enabled"] = False + elif re.search(r"自动入库|自动下载|自动转存", raw): + payload["auto_ingest_enabled"] = True + + threshold_match = re.search(r"(?:自动入库(?:评分|分数|阈值)|评分阈值|分数阈值|阈值)[^\d]{0,10}(\d{2,3})", raw) + if threshold_match: + payload["auto_ingest_score_threshold"] = self._safe_int(threshold_match.group(1), 90) + + for key, target in [ + ("115目录", "p115_default_path"), + ("p115目录", "p115_default_path"), + ("夸克目录", "quark_default_path"), + ("quark目录", "quark_default_path"), + ("云盘目录", "cloud_default_path"), + ]: + match = re.search(rf"{re.escape(key)}\s*=\s*([^\s,,]+)", raw, flags=re.IGNORECASE) + if match: + payload[target] = self._normalize_path(match.group(1)) + + if "initialized" not in payload and payload: + payload["initialized"] = True + if "auto_ingest_score_threshold" in payload: + payload["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(payload["auto_ingest_score_threshold"], 90))) + if "pt_min_seeders" in payload: + payload["pt_min_seeders"] = max(0, self._safe_int(payload["pt_min_seeders"], 3)) + if "hdhive_max_unlock_points" in payload: + payload["hdhive_max_unlock_points"] = max(0, self._safe_int(payload["hdhive_max_unlock_points"], self._hdhive_max_unlock_points)) + return payload + + @staticmethod + def _score_text_blob(item: Any) -> str: + if isinstance(item, dict): + parts: List[str] = [] + for value in item.values(): + if isinstance(value, (dict, list, tuple)): + parts.append(AgentResourceOfficer._score_text_blob(value)) + elif value is not None: + parts.append(str(value)) + return " ".join(parts).lower() + if isinstance(item, (list, tuple)): + return " ".join(AgentResourceOfficer._score_text_blob(value) for value in item).lower() + return str(item or "").lower() + + @staticmethod + def _score_has_any(text: str, keywords: List[str]) -> bool: + return any(keyword.lower() in text for keyword in keywords) + + @classmethod + def _score_quality_rank(cls, item: Any) -> int: + text = cls._score_text_blob(item) + rank = 0 + if cls._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + rank += 60 + if cls._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比", "臻彩"]): + rank += 45 + if cls._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + rank += 35 + if cls._score_has_any(text, ["4k", "2160", "uhd"]): + rank += 25 + if cls._score_has_any(text, ["高码率", "remux", "原盘", "web-dl", "webrip"]): + rank += 15 + if cls._score_has_any(text, ["e01", "s01", "全集", "全季", "更新至", "更至"]): + rank += 10 + if cls._score_has_any(text, ["中字", "简中", "繁中", "内封", "内嵌", "字幕"]): + rank += 8 + return rank + + @staticmethod + def _extract_series_progress(text: str) -> Dict[str, int]: + blob = str(text or "").lower() + result = {"max_episode": 0, "episode_count": 0} + episodes: List[int] = [] + for pattern in ( + r"\be(\d{1,3})\b", + r"第\s*(\d{1,3})\s*集", + r"更新(?:至)?\s*(?:ep|e)?\s*0*(\d{1,3})", + r"更(?:至)?\s*0*(\d{1,3})\s*集", + ): + for match in re.finditer(pattern, blob, flags=re.IGNORECASE): + try: + episodes.append(int(match.group(1))) + except Exception: + continue + for pattern in ( + r"e0*(\d{1,3})\s*[-~—]\s*e?0*(\d{1,3})", + r"第?\s*0*(\d{1,3})\s*[-~—]\s*0*(\d{1,3})\s*集", + ): + match = re.search(pattern, blob, flags=re.IGNORECASE) + if not match: + continue + try: + start_ep = int(match.group(1)) + end_ep = int(match.group(2)) + except Exception: + continue + if end_ep >= start_ep > 0: + result["episode_count"] = max(result["episode_count"], end_ep - start_ep + 1) + episodes.extend([start_ep, end_ep]) + if episodes: + result["max_episode"] = max(episodes) + if result["episode_count"] <= 0 and len(set(episodes)) >= 2: + sorted_eps = sorted(set(episodes)) + if sorted_eps == list(range(sorted_eps[0], sorted_eps[-1] + 1)): + result["episode_count"] = len(sorted_eps) + return result + + @staticmethod + def _score_level(score: int) -> str: + if score >= 90: + return "excellent" + if score >= 70: + return "confirm" + return "low" + + def _score_decision( + self, + *, + score: int, + risk_reasons: List[str], + hard_risk_reasons: Optional[List[str]] = None, + preferences: Dict[str, Any], + default_action: str, + ) -> Dict[str, Any]: + threshold = max(1, min(100, self._safe_int(preferences.get("auto_ingest_score_threshold"), 90))) + confirm_threshold = max(1, min(100, self._safe_int(preferences.get("confirm_score_threshold"), 70))) + auto_enabled = self._parse_bool_value(preferences.get("auto_ingest_enabled"), False) + hard_risks = [self._clean_text(item) for item in (hard_risk_reasons or []) if self._clean_text(item)] + hard_risk = bool(hard_risks) + can_auto = bool(auto_enabled and not hard_risk and score >= threshold) + if can_auto: + recommended = default_action + elif score >= confirm_threshold and not hard_risk: + recommended = "ask_confirm" + else: + recommended = "do_not_auto" + return { + "score": score, + "score_level": self._score_level(score), + "risk_reasons": risk_reasons, + "hard_risk_reasons": hard_risks, + "can_auto_execute": can_auto, + "recommended_action": recommended, + "auto_ingest_enabled": auto_enabled, + "auto_ingest_score_threshold": threshold, + "confirm_score_threshold": confirm_threshold, + } + + def _score_cloud_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + provider = self._clean_text(item.get("pan_type") or item.get("channel")).lower() + media_type = self._clean_text(item.get("media_type") or item.get("type")).lower() + series_like = ( + media_type in {"tv", "series", "电视剧", "剧集", "番剧"} + or bool(re.search(r"\bs\d{1,2}\b|\be\d{1,3}\b|season|第\s*\d+\s*集|[全整]季|全集|完结|更至|更新至|短剧|剧集", text, flags=re.IGNORECASE)) + ) + series_progress = self._extract_series_progress(text) + + if "2160" in text or "4k" in text or "uhd" in text: + score += 25 + reasons.append("4K/UHD +25") + elif "1080" in text: + score += 16 + reasons.append("1080P +16") + elif "720" in text: + score += 6 + reasons.append("720P +6") + if resolution_pref and resolution_pref in text: + score += 5 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +5") + + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + score += 18 + reasons.append("杜比视界 +18") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + score += 12 + reasons.append("HDR +12") + if self._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): + score += 4 + reasons.append("高帧率 +4") + + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "内封简繁", "双语", "官中"]): + score += 14 + reasons.append("中文字幕 +14") + elif self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True): + score -= 5 + risks.append("未识别到中文字幕") + + completeness_detected = self._score_has_any(text, ["全集", "全季", "完结", "complete", "全 ", "更至", "更0", "更新至"]) + if not completeness_detected and series_like and series_progress.get("episode_count", 0) >= 3: + completeness_detected = True + + if completeness_detected: + score += 12 + reasons.append("完整度信息 +12") + elif series_like and self._parse_bool_value(prefs.get("prefer_complete_series"), True): + score -= 6 + risks.append("未识别到全集/更新完整度") + + if series_like and series_progress.get("max_episode", 0) > 0: + progress_bonus = min(10, series_progress["max_episode"]) + if progress_bonus > 0: + score += progress_bonus + reasons.append(f"更新到 E{series_progress['max_episode']:02d} +{progress_bonus}") + if series_progress["max_episode"] >= 7: + latest_bonus = min(4, series_progress["max_episode"] - 6) + if latest_bonus > 0: + score += latest_bonus + reasons.append(f"更新进度领先 +{latest_bonus}") + if series_like and series_progress.get("episode_count", 0) >= 3: + pack_bonus = min(12, series_progress["episode_count"] + 2) + score += pack_bonus + reasons.append(f"连续剧集包 +{pack_bonus}") + if series_progress["episode_count"] >= series_progress.get("max_episode", 0) >= 3: + score += 4 + reasons.append("从 E01 连续覆盖到当前 +4") + + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + + prefer_provider = self._clean_text(prefs.get("prefer_cloud_provider")).lower() + if prefer_provider and provider and prefer_provider == provider: + score += 5 + reasons.append(f"匹配网盘偏好 {provider} +5") + if provider == "115" and not self._parse_bool_value(prefs.get("has_115"), True): + message = "当前偏好未启用 115" + risks.append(message) + hard_risks.append(message) + score -= 30 + if provider == "quark" and not self._parse_bool_value(prefs.get("has_quark"), True): + message = "当前偏好未启用夸克" + risks.append(message) + hard_risks.append(message) + score -= 30 + if target_path and target_path in { + self._clean_text(prefs.get("cloud_default_path")), + self._clean_text(prefs.get("p115_default_path")), + self._clean_text(prefs.get("quark_default_path")), + }: + score += 3 + reasons.append("匹配默认目录 +3") + + if source_type == "hdhive": + limit = max(0, self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) + points = self._resource_points_value(item) + if points == 0: + score += 10 + reasons.append("影巢免费 +10") + elif points is None and limit > 0: + reasons.append("影巢未标积分") + elif limit > 0 and points is not None and points > limit: + score -= 30 + message = f"影巢积分 {points} 超过上限 {limit},禁止自动解锁" + risks.append(message) + hard_risks.append(message) + elif points is not None: + score += max(0, 10 - points) + reasons.append(f"影巢积分 {points}") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_ingest_cloud", + ) + return { + **decision, + "source_type": source_type, + "score_reasons": reasons, + } + + def _score_pt_resource( + self, + item: Dict[str, Any], + *, + preferences: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + text = self._score_text_blob(item) + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else item + meta = item.get("meta_info") if isinstance(item.get("meta_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + score = 20 + reasons: List[str] = [] + risks: List[str] = [] + hard_risks: List[str] = [] + min_seeders = max(0, self._safe_int(prefs.get("pt_min_seeders"), 3)) + seeders = self._safe_int(torrent.get("seeders"), 0) + peers = self._safe_int(torrent.get("peers"), 0) + volume = self._clean_text(torrent.get("volume_factor") or item.get("volume_factor")).lower() + title = self._clean_text(torrent.get("title")) + site_name = self._clean_text(torrent.get("site_name")) + group_name = self._clean_text(meta.get("resource_team")) + media_title = self._clean_text(media.get("title")).lower() + media_year = self._clean_text(media.get("year")) + resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() + prefer_dovi = self._parse_bool_value(prefs.get("prefer_dolby_vision"), True) + prefer_hdr = self._parse_bool_value(prefs.get("prefer_hdr"), True) + prefer_subtitle = self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True) + prefer_complete = self._parse_bool_value(prefs.get("prefer_complete_series"), True) + + if seeders <= 0: + message = "做种数 0,禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 35 + elif seeders < min_seeders: + message = f"做种数 {seeders},低于阈值 {min_seeders},禁止自动下载" + risks.append(message) + hard_risks.append(message) + score -= 25 + elif seeders >= 20: + score += 22 + reasons.append(f"做种数 {seeders} +22") + elif seeders >= 10: + score += 16 + reasons.append(f"做种数 {seeders} +16") + else: + score += 10 + reasons.append(f"做种数 {seeders} +10") + + if peers >= seeders * 5 and seeders < 5: + score -= 8 + risks.append("下载需求高但做种偏低") + elif peers >= max(8, seeders): + score += 6 + reasons.append(f"下载热度 {peers} +6") + elif peers >= 3: + score += 3 + reasons.append(f"下载热度 {peers} +3") + + if self._score_has_any(volume, ["free", "免费", "2xfree", "2x free", "freeleech"]): + score += 18 + reasons.append("免费/促销 +18") + elif self._parse_bool_value(prefs.get("pt_require_free"), False): + score -= 20 + message = "用户要求 PT 免费资源" + risks.append(message) + hard_risks.append(message) + elif self._parse_bool_value(prefs.get("pt_prefer_free"), True): + reasons.append("普通 PT 资源,未额外扣分") + + if "2160" in text or "4k" in text or "uhd" in text: + score += 20 + reasons.append("4K/UHD +20") + elif "1080" in text: + score += 12 + reasons.append("1080P +12") + elif "720" in text: + score += 4 + reasons.append("720P +4") + if resolution_pref and resolution_pref in text: + score += 6 + reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +6") + has_dovi = self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]) + has_hdr = self._score_has_any(text, ["hdr10", "hdr", "hlg"]) + if has_dovi: + score += 14 + reasons.append("杜比视界 +14") + elif prefer_dovi: + score -= 4 + risks.append("未识别到杜比视界") + if has_hdr: + score += 9 + reasons.append("HDR +9") + elif prefer_hdr: + score -= 2 + risks.append("未识别到 HDR") + if self._score_has_any(text, ["中字", "简中", "繁中", "双语", "内封", "字幕"]): + score += 10 + reasons.append("字幕信息 +10") + elif prefer_subtitle: + score -= 5 + risks.append("未识别到中文字幕") + if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): + score += 8 + reasons.append("片源质量标识 +8") + if self._score_has_any(text, ["s01", "season", "全集", "全季", "complete", "完结", "更新至", "更至"]): + score += 6 + reasons.append("季/全集标识 +6") + elif prefer_complete and self._score_has_any(text, ["s01", "s02", "e01", "第1集", "season"]): + score -= 4 + risks.append("未识别到全集/完整季") + + if media_title: + if media_title in title.lower(): + score += 8 + reasons.append("标题匹配媒体名 +8") + else: + score -= 4 + risks.append("标题与识别媒体名匹配一般") + if media_year and media_year in title: + score += 4 + reasons.append(f"年份匹配 {media_year} +4") + if site_name: + score += 2 + reasons.append(f"站点标识 {site_name} +2") + if group_name: + score += 2 + reasons.append(f"发布组 {group_name} +2") + + size_text = self._clean_text(torrent.get("size") or item.get("size")) + size_value = 0.0 + size_match = re.search(r"(\d+(?:\.\d+)?)\s*(tb|gb|mb)", size_text, flags=re.IGNORECASE) + if size_match: + size_value = float(size_match.group(1)) + unit = size_match.group(2).lower() + if unit == "tb": + size_value *= 1024 + elif unit == "mb": + size_value /= 1024 + if size_value > 0: + if self._score_has_any(text, ["2160", "4k", "uhd", "remux", "原盘"]) and size_value < 8: + score -= 6 + risks.append(f"体积 {size_text} 偏小,需留意是否压制过度") + elif self._score_has_any(text, ["1080", "web-dl", "bluray"]) and size_value >= 4: + score += 3 + reasons.append(f"体积 {size_text} 较充足 +3") + if peers >= 30 and seeders >= min_seeders: + score += 4 + reasons.append("热度稳定 +4") + + final_score = max(0, min(100, score)) + decision = self._score_decision( + score=final_score, + risk_reasons=risks, + hard_risk_reasons=hard_risks, + preferences=prefs, + default_action="auto_download_pt", + ) + return { + **decision, + "source_type": "pt", + "score_reasons": reasons, + "seeders": seeders, + "peers": peers, + "min_seeders": min_seeders, + "volume_factor": volume, + "site_name": site_name, + "resource_team": group_name, + } + + def _attach_cloud_scores( + self, + items: List[Dict[str, Any]], + *, + preferences: Optional[Dict[str, Any]] = None, + source_type: str = "cloud", + target_path: str = "", + ) -> List[Dict[str, Any]]: + return [ + { + **dict(item or {}), + "score": self._score_cloud_resource( + dict(item or {}), + preferences=preferences, + source_type=source_type, + target_path=target_path, + ), + } + for item in items + if isinstance(item, dict) + ] + + def _rank_pansou_items( + self, + items: List[Dict[str, Any]], + *, + channel_order: Optional[List[str]] = None, + limit_per_channel: int = 20, + ) -> List[Dict[str, Any]]: + grouped: Dict[str, List[Dict[str, Any]]] = {} + for item in items: + if not isinstance(item, dict): + continue + channel = self._normalize_pansou_channel_name(item.get("channel")) + grouped.setdefault(channel, []).append(dict(item)) + + def sort_key(entry: Dict[str, Any]) -> Tuple[Any, ...]: + score = entry.get("score") if isinstance(entry.get("score"), dict) else {} + score_value = self._safe_int(score.get("score"), 0) + hard_risk_count = len(score.get("hard_risk_reasons") or []) + risk_count = len(score.get("risk_reasons") or []) + dt = self._clean_text(entry.get("datetime")) + note = self._clean_text(entry.get("note")) + return ( + score_value, + self._score_quality_rank(entry), + -hard_risk_count, + -risk_count, + dt, + len(note), + ) + + ordered_channels = channel_order or ["115", "quark"] + ranked: List[Dict[str, Any]] = [] + for channel in ordered_channels: + channel_items = sorted(grouped.get(channel) or [], key=sort_key, reverse=True) + ranked.extend(channel_items[:max(1, limit_per_channel)]) + for index, item in enumerate(ranked, start=1): + item["index"] = index + return ranked + + def _public_pansou_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_item = { + "index": self._safe_int(item.get("index"), 0), + "channel": self._normalize_pansou_channel_name(item.get("channel")), + "note": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + "datetime": self._clean_text(item.get("datetime")), + "display_datetime": self._format_pansou_display_datetime(item.get("datetime")), + "password": self._clean_text(item.get("password")), + "url": self._clean_text(item.get("url")), + "brief_summary": self._pansou_item_brief_summary(item), + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + public_item[extra_key] = value + public_items.append(public_item) + return public_items + + @staticmethod + def _public_hdhive_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + public_items: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + public_items.append({ + "index": AgentResourceOfficer._safe_int(item.get("index"), 0), + "cloud_index": AgentResourceOfficer._safe_int(item.get("cloud_index"), 0), + "title": AgentResourceOfficer._clean_text(item.get("title")), + "pan_type": AgentResourceOfficer._clean_text(item.get("pan_type")), + "share_size": AgentResourceOfficer._list_text(item.get("share_size") or item.get("size")), + "source": AgentResourceOfficer._list_text(item.get("source")), + "subtitle_text": AgentResourceOfficer._resource_subtitle_text(item), + "episode_text": AgentResourceOfficer._resource_episode_text(item), + "remark": AgentResourceOfficer._resource_remark_text(item), + "points_text": AgentResourceOfficer._resource_points_text(item), + "video_resolution": item.get("video_resolution") or [], + "slug": AgentResourceOfficer._clean_text(item.get("slug")), + }) + return public_items + + @staticmethod + def _prepend_search_note(message: str, note: str) -> str: + clean_message = str(message or "").strip() + clean_note = str(note or "").strip() + if not clean_note: + return clean_message + if not clean_message: + return clean_note + return f"{clean_note}\n\n{clean_message}" + + def _assistant_finalize_pansou_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + items: List[Dict[str, Any]], + total: int, + target_path: str, + action_name: str, + search_scope: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_pansou_text(keyword, items, total, page=1, page_size=page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + public_items = self._public_pansou_items(items) + result_data = { + "action": action_name, + "ok": True, + "items": public_items, + "total": self._safe_int(total, len(items)), + "page": 1, + "page_size": page_size, + "total_pages": max(1, (len(items) + page_size - 1) // page_size) if items else 1, + "decision_summary": self._assistant_pansou_entry_summary(items), + "search_scope": search_scope, + } + if recommend_handoff: + saved_state = self._load_session(cache_key) or {} + result_data.update(self._assistant_recommend_handoff_short_metadata(saved_state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(saved_state) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data=result_data), + } + + @staticmethod + def _pick_cloud_hdhive_candidate( + keyword: str, + candidates: List[Dict[str, Any]], + year: str = "", + ) -> Dict[str, Any]: + clean_year = AgentResourceOfficer._clean_text(year)[:4] + if not candidates: + return {} + if len(candidates) == 1: + return dict(candidates[0] or {}) + if not clean_year: + return {} + matched = [ + dict(item or {}) + for item in candidates + if AgentResourceOfficer._clean_text((item or {}).get("year"))[:4] == clean_year + ] + if len(matched) == 1: + return matched[0] + return {} + + def _format_pansou_body_lines(self, items: List[Dict[str, Any]], total: int) -> List[str]: + count_115 = len([x for x in items if x.get("channel") == "115"]) + count_quark = len([x for x in items if x.get("channel") == "quark"]) + lines = [f"共找到 {total} 条结果,当前展示 115 {count_115} 条、夸克 {count_quark} 条:"] + seen_115 = False + seen_quark = False + for cached in items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + return lines + + @classmethod + def _format_pansou_list_title(cls, item: Dict[str, Any]) -> str: + channel = cls._normalize_pansou_channel_name(item.get("channel")) + emoji = "📺" if channel == "115" else "🗄" if channel == "quark" else "🔗" + title = cls._clean_text(item.get("note") or item.get("title") or "未命名资源") + title = re.sub(r"^\s*\[(?:115|quark|QUARK)\]\s*", "", title).strip() + title = re.sub(r"^\s*#(?:剧集|电影|动漫|资源)\s*", "", title).strip() + title = cls._truncate_text(title, 110) + if title.startswith(("📺", "🗄", "🗃", "📁", "🔗")): + return title + return f"{emoji} {title}".strip() + + def _format_pansou_text( + self, + keyword: str, + items: List[Dict[str, Any]], + total: int, + *, + page: int = 1, + page_size: int = 10, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(items), page=page, page_size=page_size) + page_items = items[start:end] + count_115 = len([x for x in page_items if x.get("channel") == "115"]) + count_quark = len([x for x in page_items if x.get("channel") == "quark"]) + lines = [ + f"盘搜搜索:{keyword}", + f"共找到 {total} 条结果,当前第 {safe_page}/{total_pages} 页(本次缓存 {len(items)} 条候选),本页 115 {count_115} 条、夸克 {count_quark} 条:", + ] + seen_115 = False + seen_quark = False + for cached in page_items: + idx = cached["index"] + channel = cached["channel"] + if channel == "115" and not seen_115: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟦 115 结果") + lines.append("") + seen_115 = True + elif channel == "quark" and not seen_quark: + if lines and lines[-1] != "": + lines.append("") + lines.append("🟨 夸克结果") + lines.append("") + seen_quark = True + display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) + date_suffix = f" — {display_datetime}" if display_datetime else "" + lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") + if cached.get("password"): + lines.append(f" 提取码:{cached['password']}") + brief = self._pansou_item_brief_summary(cached) + if brief: + lines.append(f" 摘要:{brief}") + first_visible_index = self._safe_int((page_items[0] or {}).get("index"), 1) if page_items else 1 + next_quark_hint = next((self._safe_int(item.get("index"), 0) for item in page_items if item.get("channel") == "quark"), 0) + suggestion_lines = self._format_pansou_selection_suggestion(page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + if next_quark_hint > 0 and next_quark_hint != first_visible_index: + lines.append(f"本页夸克结果从 {next_quark_hint} 开始编号;例如直接回复“{next_quark_hint}”即可处理本页第 1 条夸克结果。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _human_resource_signal_text(cls, title: str, reasons: List[str]) -> str: + blob = f"{title} {' '.join(reasons or [])}".lower() + signals: List[str] = [] + if any(token in blob for token in ["4k", "2160", "uhd", "臻彩"]): + signals.append("画质规格更高") + if any(token in blob for token in ["hdr", "dv", "杜比", "dolby", "高码率", "60fps", "60帧"]): + signals.append("视频规格更完整") + if any(token in blob for token in ["e01", "s01", "全集", "全季", "连续", "更新至", "更至", "覆盖到当前"]): + signals.append("集数覆盖更明确") + if any(token in blob for token in ["中字", "简中", "繁中", "字幕", "内封", "内嵌"]): + signals.append("字幕信息更清楚") + if any(token in blob for token in ["免费", "积分 0", "影巢免费"]): + signals.append("成本更低") + if not signals: + return "综合画质、完整度和可转存性更均衡" + return "、".join(signals[:3]) + + @classmethod + def _human_resource_provider_text(cls, provider: str) -> str: + normalized = cls._clean_text(provider).lower() + if normalized == "115" or "115" in normalized: + return "115 资源更适合做主收藏和后续入库" + if normalized == "quark" or "夸克" in normalized: + return "夸克资源更适合走夸克转存链路" + return "这个资源整体更适合优先处理" + + @classmethod + def _human_resource_suggestion_line(cls, best_index: int, best_title: str, provider: str, reasons: List[str]) -> str: + title_part = f":{best_title}" if best_title else "" + provider_text = cls._human_resource_provider_text(provider) + signal_text = cls._human_resource_signal_text(best_title, reasons) + return f"优先选 {best_index}{title_part}。{provider_text},主要优势是{signal_text}。" + + @classmethod + def _human_quark_fallback_line(cls, quark_index: int, quark_title: str, reasons: List[str]) -> str: + title_part = f":{quark_title}" if quark_title else "" + signal_text = cls._human_resource_signal_text(quark_title, reasons) + return f"如果这次明确只走夸克,备选 {quark_index}{title_part}。它在夸克结果里相对更稳,优势是{signal_text}。" + + def _format_pansou_selection_suggestion(self, items: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(items, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:4] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_items = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and self._normalize_pansou_channel_name(item.get("channel")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_hdhive_resource_body_lines( + self, + resources: List[Dict[str, Any]], + *, + candidate: Optional[Dict[str, Any]] = None, + start_index: int = 1, + ) -> List[str]: + lines: List[str] = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {len(resources)} 条") + current_provider = "" + for offset, item in enumerate(resources, start=start_index): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + lines.append(self._format_hdhive_resource_summary_line(item, offset)) + lines.append("") + return [line for line in lines if line is not None] + + @classmethod + def _format_hdhive_resource_summary_line(cls, item: Dict[str, Any], index: int) -> str: + provider = str(item.get("pan_type") or "?").lower() + emoji = "📺" if provider == "115" else "🗄" if provider == "quark" else "🔗" + title = cls._clean_text(item.get("title") or "未命名资源") + points_text = cls._resource_points_text(item) + share_size = cls._list_text(item.get("share_size") or item.get("size")) + episode = cls._resource_episode_text(item) + resolution = "/".join(item.get("video_resolution") or []) or "" + source = cls._list_text(item.get("source")) + subtitle = cls._resource_subtitle_text(item) + remark = cls._resource_remark_text(item) + detail_parts = [ + points_text, + share_size, + episode, + resolution, + source, + subtitle, + remark, + ] + details = " · ".join(part for part in detail_parts if part) + return f"{index}. {emoji} {title}" + (f" · {details}" if details else "") + + def _format_hdhive_selection_suggestion(self, resources: List[Dict[str, Any]]) -> List[str]: + summary = self._score_summary(resources, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + quark_items = [ + dict(item or {}) + for item in (resources or []) + if isinstance(item, dict) and str(item.get("pan_type") or "").lower() == "quark" + ] + best_quark = self._best_scored_source_item(quark_items) + quark_index = self._safe_int(best_quark.get("pick_index") or best_quark.get("index"), 0) + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _format_cloud_selection_suggestion( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> List[str]: + combined: List[Dict[str, Any]] = [] + for item in pansou_items or []: + if isinstance(item, dict): + combined.append(dict(item)) + for item in hdhive_resources or []: + if not isinstance(item, dict): + continue + current = dict(item) + cloud_index = self._safe_int( + current.get("cloud_index") or current.get("pick_index") or current.get("index"), + 0, + ) + current["index"] = cloud_index + current["pick_index"] = cloud_index + combined.append(current) + summary = self._score_summary(combined, limit=5) + best = summary.get("best") if isinstance(summary.get("best"), dict) else {} + best_index = self._safe_int(best.get("index"), 0) + if best_index <= 0: + return [] + best_title = self._clean_text(best.get("title")) + best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines = ["智能建议"] + lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) + quark_candidates = [ + dict(item or {}) + for item in combined + if isinstance(item, dict) + and self._normalize_pansou_channel_name(item.get("channel") or item.get("pan_type")) == "quark" + ] + best_quark = self._best_scored_source_item(quark_candidates) + quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index") or best_quark.get("cloud_index"), 0) + if quark_index > 0 and quark_index != best_index: + quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) + quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} + quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] + lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) + return lines + + def _assistant_cloud_entry_summary( + self, + pansou_items: List[Dict[str, Any]], + hdhive_resources: List[Dict[str, Any]], + ) -> Dict[str, Any]: + preferred_index = 0 + if pansou_items: + preferred_index = self._safe_int((pansou_items[0] or {}).get("index"), 0) + elif hdhive_resources: + preferred_index = self._safe_int((hdhive_resources[0] or {}).get("cloud_index") or (hdhive_resources[0] or {}).get("index"), 0) + if preferred_index <= 0: + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "cloud_search", + "label": "云盘搜索结果已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(preferred_index), + "fallback_command": f"选择 {preferred_index} 详情", + "compact_commands": [str(preferred_index), f"选择 {preferred_index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _assistant_finalize_cloud_result( + self, + *, + session: str, + cache_key: str, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + target_path: str, + lead_note: str = "", + ) -> Dict[str, Any]: + page_size = self._assistant_cloud_result_page_size + self._save_session( + cache_key, + { + "kind": "assistant_cloud", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "pansou_items": pansou_items, + "pansou_total": pansou_total, + "hdhive_resources": hdhive_resources, + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidates": hdhive_candidates, + "page": 1, + "page_size": page_size, + }, + ) + text_message = self._format_cloud_text( + keyword=keyword, + pansou_items=pansou_items, + pansou_total=pansou_total, + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=1, + page_size=page_size, + ) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search", + "ok": True, + "items": self._public_pansou_items(pansou_items), + "hdhive_resources": self._public_hdhive_items(hdhive_resources), + "hdhive_candidate": dict(hdhive_candidate or {}), + "hdhive_candidate_count": len(hdhive_candidates or []), + "page": 1, + "page_size": page_size, + "total_pages": max(1, ((len(pansou_items) + len(hdhive_resources)) + page_size - 1) // page_size) if (pansou_items or hdhive_resources) else 1, + "decision_summary": self._assistant_cloud_entry_summary(pansou_items, hdhive_resources), + "search_scope": "pansou_and_hdhive", + }), + } + + def _format_cloud_text( + self, + *, + keyword: str, + pansou_items: List[Dict[str, Any]], + pansou_total: int, + hdhive_resources: List[Dict[str, Any]], + hdhive_candidate: Optional[Dict[str, Any]], + hdhive_candidates: List[Dict[str, Any]], + page: int = 1, + page_size: int = 20, + ) -> str: + total_items = len(pansou_items) + len(hdhive_resources) + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(total_items, page=page, page_size=page_size) + start_index = start + 1 + end_index = min(total_items, end) + lines = [f"云盘搜索:{keyword}"] + if total_items > 0: + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {start_index}-{end_index} / 共 {total_items} 条已展开结果:") + pansou_page_items = [item for item in pansou_items if start < self._safe_int(item.get('index'), 0) <= end] + hdhive_page_items = [item for item in hdhive_resources if start < self._safe_int(item.get('cloud_index') or item.get('index'), 0) <= end] + lines.extend(["", "盘搜结果"]) + if pansou_page_items: + lines.extend(self._format_pansou_body_lines(pansou_page_items, pansou_total)) + elif pansou_items: + lines.append("本页无盘搜结果") + else: + lines.append("暂无结果") + lines.extend(["", "影巢结果"]) + if hdhive_page_items: + lines.extend(self._format_hdhive_resource_body_lines( + hdhive_page_items, + candidate=hdhive_candidate, + start_index=self._safe_int((hdhive_page_items[0] or {}).get("cloud_index") or (hdhive_page_items[0] or {}).get("index"), 1), + )) + elif hdhive_resources: + lines.append("本页无影巢结果") + elif hdhive_candidates: + lines.append(f"候选影片 {len(hdhive_candidates)} 个,当前未自动展开。") + lines.append(f"如需细看影巢,请发送:影巢搜索 {keyword}") + else: + lines.append("暂无结果") + suggestion_lines = self._format_cloud_selection_suggestion(pansou_page_items, hdhive_page_items) + if suggestion_lines: + lines.extend(["", *suggestion_lines]) + lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") + lines.append("如需只看单一来源,可继续发:盘搜搜索 片名 / 影巢搜索 片名。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + def _assistant_finalize_hdhive_candidates( + self, + *, + session: str, + cache_key: str, + keyword: str, + candidates: List[Dict[str, Any]], + media_type: str, + year: str, + target_path: str, + recommend_handoff: Optional[Dict[str, Any]] = None, + lead_note: str = "", + ) -> Dict[str, Any]: + self._save_session( + cache_key, + { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or self._hdhive_default_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), + }, + ) + text_message = self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size) + if lead_note: + text_message = self._prepend_search_note(text_message, lead_note) + return { + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": True, + "candidates": candidates, + "search_scope": "hdhive", + }), + } + + @staticmethod + def _format_score_label(item: Dict[str, Any]) -> str: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + if not score: + return "" + try: + value = int(float(score.get("score") or 0)) + except Exception: + value = 0 + action = AgentResourceOfficer._clean_text(score.get("recommended_action")) + if score.get("can_auto_execute"): + suffix = "可自动入库" + elif action == "ask_confirm": + suffix = "建议确认" + else: + suffix = "不建议自动" + return f"{value}分 {suffix}" + + @staticmethod + def _score_decision_label(value: Any) -> str: + action = AgentResourceOfficer._clean_text(value) + if action == "ask_confirm": + return "建议确认" + if action == "do_not_auto": + return "不建议自动" + if action == "auto_ingest_cloud": + return "可自动入库" + if action == "auto_download_pt": + return "可自动下载" + return action or "待判断" + + def _score_brief_item(self, item: Dict[str, Any], fallback_index: int = 0) -> Dict[str, Any]: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + if not score: + return {} + torrent = current.get("torrent_info") if isinstance(current.get("torrent_info"), dict) else {} + index = self._safe_int( + current.get("pick_index") or current.get("index") or fallback_index, + fallback_index, + ) + title = ( + self._clean_text(torrent.get("title")) + or self._clean_text(current.get("note")) + or self._clean_text(current.get("title") or current.get("matched_title")) + or "未命名资源" + ) + provider = ( + self._clean_text(current.get("pan_type") or current.get("channel")) + or self._clean_text(torrent.get("site_name")) + or self._clean_text(current.get("site")) + ) + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + brief = { + "index": index, + "title": title[:160], + "provider": provider, + "source_type": self._clean_text(score.get("source_type")), + "score": self._safe_int(score.get("score"), 0), + "quality_rank": self._score_quality_rank(current), + "score_level": self._clean_text(score.get("score_level")), + "recommended_action": self._clean_text(score.get("recommended_action")), + "can_auto_execute": bool(score.get("can_auto_execute")), + "score_reasons": reasons[:3], + "risk_reasons": risks[:2], + "hard_risk_reasons": hard_risks[:2], + } + points_text = self._resource_points_text(current) if current and brief.get("source_type") != "pt" else "" + if points_text and points_text != "积分未知": + brief["points_text"] = points_text + seeders = torrent.get("seeders") if torrent else score.get("seeders") + if seeders is not None: + brief["seeders"] = seeders + volume = self._clean_text(torrent.get("volume_factor") if torrent else score.get("volume_factor")) + if volume: + brief["volume_factor"] = volume + size = self._clean_text(current.get("share_size") or current.get("size") or torrent.get("size")) + if size: + brief["size"] = size + return brief + + def _score_summary(self, items: List[Dict[str, Any]], *, limit: int = 5) -> Dict[str, Any]: + scored: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + brief = self._score_brief_item(item, fallback_index=index) + if brief: + scored.append(brief) + scored.sort(key=lambda value: ( + 1 if value.get("can_auto_execute") else 0, + self._safe_int(value.get("score"), 0), + self._safe_int(value.get("quality_rank"), 0), + ), reverse=True) + auto_count = len([item for item in scored if item.get("can_auto_execute")]) + confirm_count = len([item for item in scored if item.get("recommended_action") == "ask_confirm"]) + blocked_count = len([item for item in scored if item.get("hard_risk_reasons")]) + warning_count = len([item for item in scored if item.get("risk_reasons")]) + summary = { + "total_scored": len(scored), + "auto_count": auto_count, + "confirm_count": confirm_count, + "blocked_count": blocked_count, + "warning_count": warning_count, + "best": scored[0] if scored else None, + "top_recommendations": scored[:max(1, min(10, self._safe_int(limit, 5)))], + } + summary["decision"] = self._score_summary_decision(summary) + return summary + + def _score_summary_decision(self, summary: Optional[Dict[str, Any]]) -> Dict[str, Any]: + data = dict(summary or {}) + best = data.get("best") if isinstance(data.get("best"), dict) else {} + if not best: + return { + "stage": "no_scored_items", + "label": "暂无评分结果", + "requires_confirmation": False, + "prefer_plan_first": True, + "decision_hint": "当前没有可评分条目,先完成搜索或选择后再判断。", + "command_policy": "none", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "", + "fallback_command": "", + "compact_commands": [], + "recommended_commands": [], + } + choice = self._safe_int(best.get("index"), 0) + source_type = self._clean_text(best.get("source_type")).lower() + recommended_action = self._clean_text(best.get("recommended_action")) + title = self._clean_text(best.get("title")) + hard_risks = [self._clean_text(value) for value in (best.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + is_pt = source_type == "pt" + detail_command = f"选择 {choice}" if is_pt else f"选择 {choice} 详情" + plan_command = f"下载{choice}" if is_pt else f"计划选择 {choice}" + commands = [command for command in [detail_command if choice else "", plan_command if choice else "", "执行计划"] if command] + if best.get("can_auto_execute"): + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},已达到自动化阈值,但默认仍建议先生成计划再执行。" + stage = "auto_candidate" + elif hard_risks: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但存在硬风险,不能自动执行。" + stage = "blocked" + elif recommended_action == "ask_confirm": + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},风险可控,建议先看详情再决定。" + stage = "confirm" + else: + hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但综合评分偏低,不建议直接处理。" + stage = "low_score" + return { + "stage": stage, + "label": self._score_decision_label("auto_download_pt" if best.get("can_auto_execute") and is_pt else "auto_ingest_cloud" if best.get("can_auto_execute") else recommended_action), + "source_type": source_type, + "choice": choice, + "title": title[:160], + "score": self._safe_int(best.get("score"), 0), + "requires_confirmation": not bool(best.get("can_auto_execute")), + "prefer_plan_first": True, + "command_policy": "read_then_confirm_write" if len(commands) > 1 else "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": bool(len(commands) > 1), + "can_auto_run_preferred": bool(commands), + "preferred_command": commands[0] if commands else "", + "fallback_command": commands[1] if len(commands) > 1 else "", + "compact_commands": commands[:2], + "recommended_commands": commands, + "decision_hint": hint, + "top_hard_risk": hard_risks[0] if hard_risks else "", + "top_warning": risks[0] if risks else "", + } + + @staticmethod + def _format_score_summary_decision_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + decision = data.get("decision") if isinstance(data.get("decision"), dict) else {} + if not decision: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(decision.get("label")) + hint = AgentResourceOfficer._clean_text(decision.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (decision.get("compact_commands") or decision.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"决策建议:{label}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("下一步:" + " / ".join(commands)) + return lines + + def _best_scored_source_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not candidates: + return {} + return max( + candidates, + key=lambda item: ( + 1 if (item.get("score") or {}).get("can_auto_execute") else 0, + self._safe_int((item.get("score") or {}).get("score"), 0), + self._score_quality_rank(item), + -self._safe_int(item.get("index") or item.get("pick_index"), 0), + ), + ) + + def _format_cloud_item_detail_text(self, item: Dict[str, Any], *, title: str = "云盘资源详情") -> str: + current = dict(item or {}) + score = current.get("score") if isinstance(current.get("score"), dict) else {} + score_summary = self._score_summary([current], limit=1) if score else {} + index = self._safe_int(current.get("index") or current.get("pick_index"), 0) + name = ( + self._clean_text(current.get("note")) + or self._clean_text(current.get("title")) + or self._clean_text(current.get("matched_title")) + or "未命名资源" + ) + provider = self._clean_text(current.get("channel") or current.get("pan_type") or current.get("provider")) + size = self._clean_text(current.get("share_size") or current.get("size")) + resolution = self._clean_text(current.get("resolution")) + source = self._clean_text(current.get("source") or current.get("source_name")) + datetime_text = self._format_pansou_display_datetime(current.get("datetime")) + password = self._clean_text(current.get("password")) + url = self._clean_text(current.get("url") or current.get("share_url") or current.get("link")) + brief = self._clean_text(current.get("brief_summary") or self._pansou_item_brief_summary(current)) + points = self._resource_points_text(current) + lines = [title] + if index: + lines.append(f"编号:{index}") + lines.append(f"资源:{name}") + if provider: + lines.append(f"网盘:{provider}") + if points: + lines.append(f"积分:{points}") + if resolution: + lines.append(f"分辨率:{resolution}") + if size: + lines.append(f"大小:{size}") + if source: + lines.append(f"来源:{source}") + if datetime_text: + lines.append(f"日期:{datetime_text}") + if brief: + lines.append(f"摘要:{brief}") + if password: + lines.append(f"提取码:{password}") + if url: + lines.append(f"链接:{url}") + if score: + lines.append("") + lines.append("智能建议") + lines.append(f"评分:{self._safe_int(score.get('score'), 0)} / {self._clean_text(score.get('score_level')) or '-'}") + reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [risk for risk in risks if risk not in hard_risks] + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + lines.extend(self._format_score_summary_decision_lines(score_summary)) + return "\n".join(lines) + + def _assistant_scoring_policy_public_data(self) -> Dict[str, Any]: + prefs = self._default_assistant_preferences() + return { + "schema_version": "scoring_policy.v1", + "owner": "plugin_rules", + "agent_role": "explain_and_confirm_only", + "summary": "评分由插件内置规则执行;外部智能体只读取 score_summary、解释原因、请求确认,不能绕过硬风险。", + "global_decision": { + "auto_execute_requires": [ + "auto_ingest_enabled=true", + "score >= auto_ingest_score_threshold", + "hard_risk_reasons 为空", + ], + "confirm_range": "confirm_score_threshold <= score < auto_ingest_score_threshold 且无硬风险", + "default_source": "plugin_config_then_session_preferences", + "default_confirm_score_threshold": prefs.get("confirm_score_threshold"), + "default_auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), + "auto_ingest_default": prefs.get("auto_ingest_enabled"), + }, + "cloud": { + "source_types": ["hdhive", "pansou", "115", "quark"], + "positive_signals": [ + "4K/UHD", + "杜比视界/HDR", + "中文字幕", + "全集/完整季/更新完整度", + "REMUX/原盘/Web-DL/高码率", + "匹配网盘偏好", + "匹配默认目录", + ], + "hard_gates": [ + "影巢积分超过 hdhive_max_unlock_points 时禁止自动解锁", + "影巢积分未知时禁止自动解锁", + ], + "default_hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), + "pansou_cost": "无积分成本,主要按质量、完整度、字幕和网盘类型评分", + }, + "pt": { + "source_types": ["moviepilot_native_search", "torrent_search", "subscribe_search"], + "positive_signals": [ + "做种数高", + "免费/促销/FreeLeech", + "4K/UHD", + "杜比视界/HDR", + "字幕信息", + "REMUX/原盘/Web-DL/高码率", + "季/全集标识", + ], + "hard_gates": [ + "做种数 0 禁止自动下载", + "做种数低于 pt_min_seeders 禁止自动下载", + "用户要求免费时,非免费资源禁止自动下载", + ], + "default_pt_min_seeders": prefs.get("pt_min_seeders"), + "volume_factor_note": "免费/促销明显加分;普通资源默认中性,不强行判废", + }, + "score_summary_contract": { + "read_fields": [ + "best", + "top_recommendations", + "decision", + "score", + "score_level", + "recommended_action", + "can_auto_execute", + "score_reasons", + "risk_reasons", + "hard_risk_reasons", + ], + "decision_fields": [ + "stage", + "label", + "choice", + "decision_hint", + "preferred_command", + "fallback_command", + "compact_commands", + "recommended_commands", + ], + "blocked_count": "只统计 hard_risk_reasons,不统计缺字幕等软提醒", + "warning_count": "统计 risk_reasons,用于解释需要用户确认的原因", + "do_not_parse": "不要解析自然语言 message 来判断自动化,优先读取 score_summary", + }, + } + + @staticmethod + def _format_bytes_size(value: Any) -> str: + try: + size = float(value or 0) + except Exception: + return "" + if size <= 0: + return "" + units = ["B", "KB", "MB", "GB", "TB"] + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f}{units[index]}" if index else f"{int(size)}B" + + def _mp_context_preview_item(self, context: Any, index: int, preferences: Dict[str, Any]) -> Dict[str, Any]: + torrent = getattr(context, "torrent_info", None) + meta = getattr(context, "meta_info", None) + media = getattr(context, "media_info", None) + item = { + "index": index, + "cache_index": index, + "torrent_info": { + "title": self._clean_text(getattr(torrent, "title", "")), + "size": self._format_bytes_size(getattr(torrent, "size", None)), + "seeders": getattr(torrent, "seeders", None), + "peers": getattr(torrent, "peers", None), + "site_name": self._clean_text(getattr(torrent, "site_name", "")), + "volume_factor": self._clean_text(getattr(torrent, "volume_factor", "")), + "page_url": self._clean_text(getattr(torrent, "page_url", "")), + }, + "meta_info": { + "season_episode": self._clean_text(getattr(meta, "season_episode", "")), + "resource_team": self._clean_text(getattr(meta, "resource_team", "")), + "video_encode": self._clean_text(getattr(meta, "video_encode", "")), + "edition": self._clean_text(getattr(meta, "edition", "")), + "resource_pix": self._clean_text(getattr(meta, "resource_pix", "")), + }, + "media_info": { + "title": self._clean_text(getattr(media, "title", "")), + "year": self._clean_text(getattr(media, "year", "")), + "tmdb_id": getattr(media, "tmdb_id", None), + "douban_id": getattr(media, "douban_id", None), + }, + } + item["score"] = self._score_pt_resource(item, preferences=preferences) + return item + + @classmethod + def _display_pt_title(cls, title: Any) -> str: + text = cls._clean_text(title or "未命名资源") + # Some chat frontends auto-link dotted release names such as + # Spider-Man.2002.1080p. Insert zero-width breaks for display only. + return re.sub(r"(?<=[A-Za-z0-9])\.(?=[A-Za-z0-9])", ".\u200b", text) + + @classmethod + def _pt_title_badges(cls, title: Any) -> str: + text = cls._clean_text(title).lower() + badges: List[str] = [] + if cls._score_has_any(text, ["2160", "4k", "uhd"]): + badges.append("✨4K") + elif cls._score_has_any(text, ["1080"]): + badges.append("🎞️1080p") + if cls._score_has_any(text, ["dolby vision", "dovi", " dv ", ".dv.", "杜比视界"]): + badges.append("⚡DV") + if cls._score_has_any(text, ["hdr10", "hdr", "hlg"]): + badges.append("🔶HDR") + if cls._score_has_any(text, ["remux", "原盘"]): + badges.append("💿REMUX") + return " ".join(badges) + + @staticmethod + def _pt_promotion_text(value: Any) -> str: + text = str(value or "").strip() or "普通" + lowered = text.lower() + if "免费" in text or "free" in lowered: + return f"🆓 {text}" + if "%" in text or "x" in lowered: + return f"🎁 {text}" + return f"🎫 {text}" + + def _sort_mp_preview_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + ranked = [ + dict(item or {}) + for item in (items or []) + if isinstance(item, dict) + ] + ranked.sort( + key=lambda item: ( + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + self._score_quality_rank(item), + self._safe_int((item.get("score") or {}).get("score"), 0), + -self._safe_int(item.get("index"), 0), + ), + reverse=True, + ) + for index, item in enumerate(ranked, start=1): + item["index"] = index + item["display_index"] = index + return ranked + + def _renumber_mp_display_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + renumbered: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], start=1): + if not isinstance(item, dict): + continue + current = dict(item) + current["source_index"] = self._safe_int( + current.get("source_index") or current.get("index") or current.get("display_index"), + index, + ) + current["index"] = index + current["display_index"] = index + renumbered.append(current) + return renumbered + + def _assistant_mp_selection_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + state = self._load_session(cache_key) or {} + state_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if state_items: + return [ + dict(item or {}) + for item in state_items + if isinstance(item, dict) + ] + return self._mp_search_all_preview_items(cache_key, preferences=preferences) + + def _assistant_mp_item_by_display_index( + self, + *, + choice: int, + cache_key: str, + preferences: Dict[str, Any], + ) -> Tuple[Dict[str, Any], List[int]]: + items = self._assistant_mp_selection_items(cache_key, preferences) + selected = next( + ( + dict(item or {}) + for item in items + if self._safe_int((item or {}).get("index"), 0) == choice + ), + {}, + ) + available = [ + self._safe_int((item or {}).get("index"), 0) + for item in items + if isinstance(item, dict) and self._safe_int(item.get("index"), 0) > 0 + ] + return selected, available + + @staticmethod + def _page_bounds(total_items: int, page: int = 1, page_size: int = 20) -> Tuple[int, int, int, int]: + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (max(0, total_items) + safe_page_size - 1) // safe_page_size) if total_items else 1 + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + end = start + safe_page_size + return safe_page, safe_page_size, total_pages, start, end + + def _mp_search_cache_preview( + self, + cache_key: str, + preferences: Dict[str, Any], + *, + page: int = 1, + page_size: int = 20, + limit: Optional[int] = None, + ) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + all_items: List[Dict[str, Any]] = [] + for index, context in enumerate(results, 1): + all_items.append(self._mp_context_preview_item(context, index, preferences)) + all_items = self._sort_mp_preview_items(all_items) + effective_page_size = max(1, self._safe_int(limit, page_size)) + return self._slice_mp_preview_items(all_items, page=page, page_size=effective_page_size) + + def _mp_search_all_preview_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + return self._sort_mp_preview_items([ + self._mp_context_preview_item(context, index, preferences) + for index, context in enumerate(results, 1) + ]) + + def _slice_mp_preview_items( + self, + items: List[Dict[str, Any]], + *, + page: int = 1, + page_size: int = 20, + ) -> List[Dict[str, Any]]: + _safe_page, _safe_page_size, _total_pages, start, end = self._page_bounds( + len(items), + page=page, + page_size=page_size, + ) + return [ + dict(item or {}) + for item in items[start:end] + if isinstance(item, dict) + ] + + def _format_mp_search_text( + self, + keyword: str, + message_text: str, + preview: List[Dict[str, Any]], + *, + total: int = 0, + page: int = 1, + page_size: int = 20, + result_filter: str = "", + latest_episode: int = 0, + episode_filter: int = 0, + ) -> str: + header = message_text.strip().splitlines()[0] if message_text else f"MP 原生搜索:{keyword}" + lines = [header] + if preview: + total_results = max(self._safe_int(total, 0), len(preview)) + safe_page_size = max(1, self._safe_int(page_size, self._assistant_result_page_size)) + total_pages = max(1, (total_results + safe_page_size - 1) // safe_page_size) + score_summary = self._score_summary(preview, limit=5) + lines.append("") + if result_filter == "latest_episode" and latest_episode > 0: + lines.append(f"最新集筛选:当前最高 E{latest_episode:02d},仅展示包含该集数的候选。") + elif result_filter.startswith("episode:") and episode_filter > 0: + lines.append(f"集数筛选:仅展示包含 E{episode_filter:02d} 的候选。") + lines.append(f"当前第 {max(1, page)}/{total_pages} 页,共 {total_results} 条结果(按做种数优先排序):") + lines.append("PT 资源:") + for item in preview: + torrent = item.get("torrent_info") or {} + score = item.get("score") or {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + badge_suffix = f" {badges}" if badges else "" + lines.append(f"{item.get('index')}. 🧲【{torrent.get('site_name') or '未知站点'}】 {title}{badge_suffix}") + details = [ + f"🌱 做种:{torrent.get('seeders') if torrent.get('seeders') is not None else '?'}", + self._pt_promotion_text(torrent.get("volume_factor")), + f"💾 {torrent.get('size') or '未知'}", + f"⭐ {self._format_score_label(item)}", + ] + hard_risks = score.get("hard_risk_reasons") or [] + risks = score.get("risk_reasons") or [] + risks = [risk for risk in risks if risk not in hard_risks] + if hard_risks: + details.append("硬风险:" + ";".join(str(item) for item in hard_risks[:2])) + elif risks: + details.append("提醒:" + ";".join(str(item) for item in risks[:2])) + lines.append(" " + " | ".join(details)) + decision_lines = self._format_score_summary_decision_lines(score_summary) + if result_filter == "latest_episode" or result_filter.startswith("episode:"): + normalized_decision_lines: List[str] = [] + for line in decision_lines: + if line.startswith("下一步:"): + continue + if line.startswith("建议:"): + line = line.replace( + "建议先看详情再决定", + "可直接生成下载计划,计划不会立即执行", + ) + normalized_decision_lines.append(line) + decision_lines = normalized_decision_lines + lines.extend(decision_lines) + if page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + best_index = self._safe_int(((score_summary.get("best") or {}) if isinstance(score_summary, dict) else {}).get("index"), 0) + if (result_filter == "latest_episode" or result_filter.startswith("episode:")) and best_index > 0: + lines.append(f"操作提示:建议回复“{best_index}”或“下载{best_index}”生成下载计划,不会立即下载。") + lines.append(f"如需先核对站点详情,可回复“{best_index}详情”。") + else: + lines.append("操作提示:回复编号或“下载N”生成下载计划;回复“N详情”看详情。") + lines.append("计划生成后,再回复“执行计划”或同一个编号确认执行。") + return "\n".join(line for line in lines if line) + + async def _assistant_mp_media_detail( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "", + year: str = "", + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_media_detail( + keyword=keyword, + media_type=media_type, + year=year, + ) + item = result.get("item") if isinstance(result.get("item"), dict) else {} + self._save_session(cache_key, { + "kind": "assistant_mp_media_detail", + "stage": "media_detail", + "keyword": keyword, + "items": [item] if item else [], + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "媒体识别完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": bool(result.get("success")), + "keyword": keyword, + "media_type": media_type, + "year": year, + "item": item, + }), + } + + async def _assistant_mp_media_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + preferences: Dict[str, Any], + page: int = 1, + page_size: int = 10, + result_filter: str = "", + ) -> Dict[str, Any]: + message_text = self._ensure_feishu_channel()._execute_media_search(keyword, cache_key) + failed_prefixes = ("MP 原生搜索失败", "未识别到媒体信息", "搜索资源失败") + route_ok = not any(message_text.startswith(prefix) for prefix in failed_prefixes) + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + total = len((cache or {}).get("results") or []) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + filtered_items = all_items + latest_episode = 0 + episode_filter = 0 + effective_filter = self._clean_text(result_filter) + if effective_filter == "latest_episode": + latest_items, latest_episode = self._latest_episode_mp_items(all_items) + if latest_items: + filtered_items = self._renumber_mp_display_items(latest_items) + total = len(filtered_items) + elif effective_filter.startswith("episode:"): + episode_filter = self._safe_int(effective_filter.split(":", 1)[1], 0) + episode_items = self._episode_filter_mp_items(all_items, episode_filter) + if episode_items: + filtered_items = self._renumber_mp_display_items(episode_items) + total = len(filtered_items) + preview = self._slice_mp_preview_items(filtered_items, page=page, page_size=page_size) if filtered_items else self._mp_search_cache_preview(cache_key, preferences=preferences, page=page, page_size=page_size) + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": keyword, + "items": preview, + "all_items": filtered_items, + "raw_all_items": all_items, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "target_path": "", + }) + return { + "success": route_ok, + "message": self._format_mp_search_text( + keyword, + message_text, + preview, + total=total, + page=page, + page_size=page_size, + result_filter=effective_filter, + latest_episode=latest_episode, + episode_filter=episode_filter, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search", + "ok": route_ok, + "keyword": keyword, + "source_type": "pt", + "items": preview, + "total": total, + "page": max(1, self._safe_int(page, 1)), + "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), + "total_pages": max(1, (max(0, total) + max(1, self._safe_int(page_size, self._assistant_result_page_size)) - 1) // max(1, self._safe_int(page_size, self._assistant_result_page_size))) if total else 1, + "result_filter": effective_filter, + "latest_episode": latest_episode, + "episode_filter": episode_filter, + "score_summary": self._score_summary(preview, limit=5), + "preferences": preferences, + }), + } + + async def _assistant_mp_candidate_search( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str = "auto", + year: str = "", + page: int = 1, + pending_action: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type=self._clean_text(media_type or "auto").lower() or "auto", + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "success": False, + "message": f"MP 候选解析失败:{search_message}", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": False, + "keyword": clean_keyword, + "error_code": "candidate_search_failed", + "result": result, + }), + } + candidates = result.get("candidates") or [] + page_size = self._hdhive_candidate_page_size + self._save_session(cache_key, { + "kind": "assistant_mp_candidate", + "stage": "candidate", + "keyword": clean_keyword, + "media_type": self._clean_text(media_type or "auto").lower() or "auto", + "year": self._clean_text(year), + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + "target_path": target_path or "", + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }) + message = self._format_mp_candidate_lines(candidates, page=page, page_size=page_size) + if isinstance(pending_action, dict) and pending_action: + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名生成待确认下载计划,不会直接下载。", + ) + elif pending_mode == "cloud_transfer_execute": + message = message.replace( + "选定后再搜索 PT 资源。", + "选定后将用正确片名继续搜索云盘资源并转存。", + ) + pending_label = self._clean_text(pending_action.get("label")) + if pending_label: + message = f"{message}\n选定影片后将继续:{pending_label}" + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_candidates", + "ok": True, + "keyword": clean_keyword, + "candidates": candidates, + "page": max(1, self._safe_int(page, 1)), + "page_size": page_size, + **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), + }), + } + + async def _assistant_mp_result_detail( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + try: + cache = self._ensure_feishu_channel()._get_search_cache(cache_key) + except Exception: + cache = None + results = (cache or {}).get("results") or [] + item, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not item: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice}。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + torrent = item.get("torrent_info") or {} + meta = item.get("meta_info") or {} + media = item.get("media_info") or {} + score = item.get("score") or {} + lines = [ + f"MP 搜索结果详情 #{choice}", + f"标题:🧲 {self._display_pt_title(torrent.get('title'))} {self._pt_title_badges(torrent.get('title'))}".strip(), + f"站点:【{torrent.get('site_name') or '未知站点'}】", + f"大小:💾 {torrent.get('size') or '未知'}", + f"热度:🌱 做种 {torrent.get('seeders') if torrent.get('seeders') is not None else '?'} | 下载 {torrent.get('peers') if torrent.get('peers') is not None else '?'} | {self._pt_promotion_text(torrent.get('volume_factor'))}", + ] + media_text = " / ".join(str(part) for part in [ + media.get("title"), + media.get("year"), + f"TMDB:{media.get('tmdb_id')}" if media.get("tmdb_id") else "", + f"豆瓣:{media.get('douban_id')}" if media.get("douban_id") else "", + ] if part) + if media_text: + lines.append(f"媒体:{media_text}") + meta_text = " / ".join(str(part) for part in [ + meta.get("season_episode"), + meta.get("resource_pix"), + meta.get("video_encode"), + meta.get("edition"), + meta.get("resource_team"), + ] if part) + if meta_text: + lines.append(f"识别标签:{meta_text}") + score_label = self._format_score_label(item) + if score_label: + lines.append(f"评分:{score_label}") + reasons = [str(value) for value in (score.get("score_reasons") or []) if value] + hard_risks = [str(value) for value in (score.get("hard_risk_reasons") or []) if value] + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + risks = [risk for risk in risks if risk not in hard_risks] + score_summary = self._score_summary([item], limit=1) + if reasons: + lines.append("加分理由:" + ";".join(reasons[:6])) + if hard_risks: + lines.append("硬风险:" + ";".join(hard_risks[:6])) + if risks: + lines.append("提醒:" + ";".join(risks[:6])) + if torrent.get("page_url"): + lines.append(f"详情页:{torrent.get('page_url')}") + lines.extend(self._format_score_summary_decision_lines(score_summary)) + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + "kind": "assistant_mp", + "stage": "search_result", + "keyword": (cache or {}).get("keyword") or current_state.get("keyword") or "", + "items": current_state.get("items") or self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=max(1, self._safe_int(current_state.get("page"), 1)), + page_size=max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + ), + "all_items": current_state.get("all_items") or [], + "raw_all_items": current_state.get("raw_all_items") or [], + "selected_index": choice, + "total": len(results), + "page": max(1, self._safe_int(current_state.get("page"), 1)), + "page_size": max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), + "result_filter": current_state.get("result_filter") or "", + "latest_episode": self._safe_int(current_state.get("latest_episode"), 0), + "episode_filter": self._safe_int(current_state.get("episode_filter"), 0), + "target_path": current_state.get("target_path") or "", + **({"recommend_handoff": dict(current_state.get("recommend_handoff") or {})} if current_state.get("recommend_handoff") else {}), + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_result_detail", + "ok": True, + "choice": choice, + "item": item, + "score_summary": score_summary, + }), + } + + async def _assistant_mp_best_result_detail( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_search_best_detail", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = await self._assistant_mp_result_detail( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if result.get("success"): + result["message"] = f"当前评分最高的 PT 候选是 #{choice}\n{result.get('message') or ''}".strip() + data = dict(result.get("data") or {}) + data["action"] = "mp_search_best_detail" + data["best_choice"] = choice + result["data"] = data + return result + + async def _assistant_mp_best_download_plan( + self, + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + if not scored: + return { + "success": False, + "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_best_download_plan", + "ok": False, + "error_code": "mp_best_result_not_found", + }), + } + best = max( + scored, + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + ) + choice = self._safe_int(best.get("index"), 0) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_best_download", + message="最佳片源下载计划已生成", + ) + if result.get("success"): + result["message"] = "\n".join(line for line in [ + f"已选择当前评分最高的 PT 候选:#{choice}", + result.get("message") or "", + ] if line) + data = dict(result.get("data") or {}) + data["choice"] = choice + data["item"] = best + result["data"] = data + return result + + def _assistant_format_download_plan_choice(self, *, rank: int, plan_id: str, item: Dict[str, Any]) -> str: + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + score = item.get("score") if isinstance(item.get("score"), dict) else {} + title = self._display_pt_title(torrent.get("title")) + badges = self._pt_title_badges(torrent.get("title")) + site = self._clean_text(torrent.get("site_name")) or "未知站点" + seeders = torrent.get("seeders") if torrent.get("seeders") is not None else "?" + size = self._clean_text(torrent.get("size")) or "未知" + score_label = self._format_score_label(item) + risk_text = self._assistant_score_warning_text(score, limit=1).replace("风险提示:", "") + suffix = f" | 提醒:{risk_text}" if risk_text else "" + badge_text = f" {badges}" if badges else "" + return ( + f"{rank}. {plan_id} | 资源 #{self._safe_int(item.get('index'), 0)} | " + f"🧲【{site}】 {title}{badge_text}\n" + f" 🌱 做种:{seeders} | {self._pt_promotion_text(torrent.get('volume_factor'))} | 💾 {size} | ⭐ {score_label}{suffix}" + ) + + async def _assistant_attach_download_plan_choices( + self, + result: Dict[str, Any], + *, + session: str, + cache_key: str, + preferences: Dict[str, Any], + limit: int = 3, + ) -> Dict[str, Any]: + if not isinstance(result, dict) or not result.get("success"): + return result + result_data = result.get("data") if isinstance(result.get("data"), dict) else {} + preview = [ + dict(item or {}) + for item in (result_data.get("items") or []) + if isinstance(item, dict) + ] + if not preview: + preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) + scored = [ + item for item in preview + if isinstance(item, dict) and isinstance(item.get("score"), dict) + ] + scored.sort( + key=lambda item: ( + self._safe_int((item.get("score") or {}).get("score"), 0), + self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), + ), + reverse=True, + ) + choices = scored[:max(1, min(3, self._safe_int(limit, 3)))] + if not choices: + return result + group_id = self._new_session_id("mpdl") + plan_rows: List[str] = [] + plan_items: List[Dict[str, Any]] = [] + for rank, item in enumerate(choices, start=1): + plan_result = self._assistant_mp_download_plan_response( + choice=self._safe_int(item.get("index"), 0), + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download_choice", + message=f"下载方案 {rank} 已生成", + ) + if not plan_result.get("success"): + continue + plan_data = plan_result.get("data") or {} + plan_id = self._clean_text(plan_data.get("plan_id")) + if plan_id and isinstance(self._workflow_plans.get(plan_id), dict): + execute_body = self._workflow_plans[plan_id].get("execute_body") + if not isinstance(execute_body, dict): + execute_body = {} + execute_body.update({ + "plan_rank": rank, + "multi_plan_group": group_id, + }) + self._workflow_plans[plan_id]["execute_body"] = execute_body + self._workflow_plans[plan_id]["plan_rank"] = rank + self._workflow_plans[plan_id]["plan_choice"] = self._safe_int(item.get("index"), 0) + self._workflow_plans[plan_id]["multi_plan_group"] = group_id + self._persist_workflow_plans() + plan_rows.append(self._assistant_format_download_plan_choice(rank=rank, plan_id=plan_id, item=item)) + plan_items.append({ + "rank": rank, + "plan_id": plan_id, + "choice": self._safe_int(item.get("index"), 0), + "item": item, + }) + if not plan_items: + return result + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, { + **current_state, + "kind": "assistant_mp_download_plans", + "stage": "download_plan_choices", + "pending_plan_group": group_id, + "plan_choices": plan_items, + "source_search": result.get("data") or {}, + }) + merged = { + "success": True, + "message": "\n".join([ + f"已按当前偏好生成 {len(plan_items)} 个待确认下载方案,均未实际下载:", + *plan_rows, + "回复方案编号 1/2/3 执行对应方案;回复“选择 资源编号”可先看详情。", + ]), + } + data = { + "action": "mp_download_plan_choices", + "ok": True, + "pending_plan_group": group_id, + "plan_choices": plan_items, + "ready_to_execute": True, + "write_effect": "state", + } + data["source_search"] = result.get("data") or {} + data["source_search_message"] = self._clean_text(result.get("message")) + merged["data"] = self._assistant_response_data(session=session, data=data) + return merged + + def _assistant_smart_search_stop_after_source( + self, + candidate: Optional[Dict[str, Any]], + preferences: Optional[Dict[str, Any]], + ) -> bool: + current = dict(candidate or {}) + if not current: + return False + score_value = self._safe_int(current.get("score"), 0) + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + hard_risks = current.get("hard_risk_reasons") or [] + recommended_action = self._clean_text(current.get("recommended_action")) + return score_value >= threshold and not hard_risks and recommended_action != "not_recommended" + + def _assistant_smart_search_candidate_from_cloud_item( + self, + item: Dict[str, Any], + *, + source_type: str, + ) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + provider = self._clean_text(item.get("pan_type") or item.get("channel") or item.get("provider")).lower() + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action == "ask_confirm": + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"计划选择 {index}" if index > 0 else "" + detail_command = f"选择 {index} 详情" if index > 0 else "" + return { + "source_type": source_type, + "title": self._clean_text(item.get("note") or item.get("title") or item.get("matched_title") or "未命名资源"), + "year": self._clean_text(item.get("year") or item.get("matched_year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "provider": provider or self._clean_text(item.get("source") or source_type), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "points_text": self._resource_points_text(item), + "provider": provider, + "size": self._clean_text(item.get("share_size") or item.get("size")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + def _assistant_smart_search_candidate_from_pt_item(self, item: Dict[str, Any]) -> Dict[str, Any]: + score = item.get("score") if isinstance(item.get("score"), dict) else {} + torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} + media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} + index = self._safe_int(item.get("index") or item.get("pick_index"), 0) + hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] + risks = [value for value in risks if value not in hard_risks] + raw_action = self._clean_text(score.get("recommended_action")) + if hard_risks: + recommended_action = "not_recommended" + elif raw_action in {"ask_confirm", "auto_download_pt"}: + recommended_action = "ask_confirm_plan" + else: + recommended_action = "show_only" + plan_command = f"下载{index}" if index > 0 else "" + detail_command = f"选择 {index}" if index > 0 else "" + return { + "source_type": "mp_pt", + "title": self._clean_text(torrent.get("title") or media.get("title") or "未命名资源"), + "year": self._clean_text(media.get("year")), + "media_type": self._clean_text(item.get("media_type") or media.get("type") or "tv"), + "provider": self._clean_text(torrent.get("site_name") or "pt"), + "choice": index, + "score": self._safe_int(score.get("score"), 0), + "score_level": self._clean_text(score.get("score_level")), + "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], + "risk_reasons": risks[:3], + "hard_risk_reasons": hard_risks[:2], + "cost_summary": { + "seeders": self._safe_int(score.get("seeders"), self._safe_int(torrent.get("seeders"), 0)), + "volume_factor": self._clean_text(score.get("volume_factor") or torrent.get("volume_factor")), + "site_name": self._clean_text(score.get("site_name") or torrent.get("site_name")), + }, + "recommended_action": recommended_action, + "next_command": plan_command or detail_command, + "detail_command": detail_command, + "plan_command": plan_command, + "raw_item": item, + } + + async def _assistant_smart_search_hdhive_resources( + self, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + target_path: str, + preferences: Dict[str, Any], + ) -> Dict[str, Any]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return { + "source_type": "hdhive", + "ok": False, + "skipped": True, + "reason": disabled.get("message") or "影巢资源入口已关闭", + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return { + "source_type": "hdhive", + "ok": False, + "reason": search_message, + "state": {}, + "candidates": [], + "items": [], + "score_summary": {}, + } + candidates = result.get("candidates") or [] + if not candidates: + return { + "source_type": "hdhive", + "ok": True, + "reason": "未找到影巢候选", + "state": { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + "candidates": [], + "items": [], + "score_summary": {}, + } + + selected_candidate: Dict[str, Any] = {} + selected_preview: List[Dict[str, Any]] = [] + selected_summary: Dict[str, Any] = {} + for candidate in candidates[:3]: + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or media_type or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + continue + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path, + ) + if not preview: + continue + summary = self._score_summary(preview, limit=5) + best = summary.get("best") or {} + current_best = selected_summary.get("best") if isinstance(selected_summary.get("best"), dict) else {} + if not selected_preview or self._safe_int(best.get("score"), 0) > self._safe_int(current_best.get("score"), 0): + selected_candidate = dict(candidate or {}) + selected_preview = preview + selected_summary = summary + if self._assistant_smart_search_stop_after_source( + self._assistant_smart_search_candidate_from_cloud_item(self._best_scored_source_item(preview), source_type="hdhive"), + preferences, + ): + break + + state: Dict[str, Any] + if selected_preview: + state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "selected_candidate": selected_candidate, + "resources": selected_preview, + "page": 1, + "page_size": self._assistant_result_page_size, + } + else: + state = { + "kind": "assistant_hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + } + return { + "source_type": "hdhive", + "ok": True, + "reason": search_message, + "state": state, + "candidates": candidates, + "items": selected_preview, + "score_summary": selected_summary, + } + + def _assistant_smart_search_decision_summary( + self, + *, + keyword: str, + preferences: Dict[str, Any], + checked: List[Dict[str, Any]], + best_candidate: Dict[str, Any], + available_sources: Optional[List[Dict[str, Any]]] = None, + blocked_sources: Optional[List[Dict[str, Any]]] = None, + decision_profile: str = "", + ) -> Dict[str, Any]: + prefs = self._normalize_assistant_preferences(preferences) + threshold = self._safe_int( + prefs.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + auto_threshold = self._safe_int( + prefs.get("auto_ingest_score_threshold"), + self._assistant_default_auto_ingest_score_threshold, + ) + source_names = { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + } + preferred_command = self._clean_text(best_candidate.get("next_command")) + fallback_command = self._clean_text(best_candidate.get("detail_command")) + detail_command = "先看详情" if best_candidate.get("choice") else "" + detail_short_command = "详情" if best_candidate.get("choice") else "" + title = self._clean_text(best_candidate.get("title")) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + write_intent_commands = {"计划最佳", "执行最佳", "确认执行", "先计划"} + decision_mode = "show_detail" + confirm_required = False + decision_reason = "" + confirmation_prompt = "" + if not best_candidate: + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "decision_hint": "已按当前偏好尝试可用源,但没有找到符合条件的资源。", + "decision_mode": "not_recommended", + "decision_reason": "所有可用源都没有给出符合当前偏好的候选。", + "preferred_command": "", + "fallback_command": "", + "detail_command": "", + "detail_short_command": "", + "confirmation_prompt": "", + "plan_short_command": "", + "confirm_short_command": "", + "compact_commands": [], + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": False, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": "show_only", + } + if hard_risks: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最高分是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')},但存在硬风险。" + decision_mode = "not_recommended" + decision_reason = "当前最高分候选存在硬风险,不能作为直接推荐执行项。" + confirmation_prompt = "先看详情,或换源后再试。" + elif score_value >= threshold: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前首选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分)。" + if score_value >= auto_threshold: + decision_mode = "execute_now" + decision_reason = "当前首选分数已达到高可信区间,可以作为立即执行首选,但写入仍需明确意图。" + preferred_command = "执行最佳" + fallback_command = "计划最佳" + confirm_required = True + confirmation_prompt = "确认执行;如果想保守一点,回复:先计划 或 先看详情。" + else: + decision_mode = "make_plan" + decision_reason = "当前首选已达到建议确认阈值,优先生成待确认计划。" + preferred_command = "计划最佳" + fallback_command = "执行最佳" + confirm_required = True + confirmation_prompt = "先计划;如果想直接落地,回复:确认执行;如果想先检查资源,回复:先看详情。" + else: + hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最佳候选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分),但还没达到优先阈值。" + decision_mode = "show_detail" + decision_reason = "当前最佳候选分数偏低,优先查看详情或尝试切换搜索源。" + preferred_command = fallback_command or preferred_command + fallback_command = "计划最佳" if best_candidate.get("choice") and not hard_risks else "" + confirmation_prompt = "先看详情;如果仍要继续,回复:先计划 或 换影巢 / 换盘搜 / 换PT。" + if title: + hint = f"{hint} {title}" + auto_run_command = "" + confirm_command = "" + display_command = preferred_command or detail_command + preferred_requires_confirmation = preferred_command in write_intent_commands + fallback_requires_confirmation = fallback_command in write_intent_commands + if preferred_requires_confirmation: + confirm_command = preferred_command + if detail_command and detail_command != preferred_command: + command_policy = "read_then_confirm_write" + auto_run_command = detail_command + display_command = detail_command + recommended_agent_behavior = "auto_continue_then_wait_confirmation" + else: + command_policy = "wait_user_confirmation" + recommended_agent_behavior = "wait_user_confirmation" + can_auto_run_preferred = False + else: + command_policy = "safe_read_only" + can_auto_run_preferred = bool(preferred_command) + recommended_agent_behavior = "show_only" if decision_mode == "not_recommended" else "auto_continue" + display_command = preferred_command or detail_command + return { + "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], + "threshold": threshold, + "auto_ingest_score_threshold": auto_threshold, + "preferred_source": source_type, + "preferred_score": score_value, + "preferred_title": title[:160], + "decision_hint": hint.strip(), + "decision_mode": decision_mode, + "decision_reason": decision_reason or hint.strip(), + "preferred_command": preferred_command, + "fallback_command": fallback_command, + "detail_command": detail_command if best_candidate.get("choice") and not hard_risks else "", + "detail_short_command": detail_short_command if best_candidate.get("choice") and not hard_risks else "", + "confirmation_prompt": confirmation_prompt, + "plan_command": "计划最佳" if best_candidate.get("choice") and not hard_risks else "", + "plan_short_command": "计划" if best_candidate.get("choice") and not hard_risks else "", + "execute_command": "执行最佳" if best_candidate.get("choice") and not hard_risks else "", + "confirm_short_command": "确认" if best_candidate.get("choice") and not hard_risks else "", + "compact_commands": [ + command + for command in [preferred_command, fallback_command, detail_short_command or detail_command] + if command + ][:2], + "command_policy": command_policy, + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": fallback_requires_confirmation, + "can_auto_run_preferred": can_auto_run_preferred, + "auto_run_command": auto_run_command, + "confirm_command": confirm_command, + "display_command": display_command, + "available_sources": available_sources or [], + "blocked_sources": blocked_sources or [], + "confirm_required": confirm_required, + "decision_profile": self._clean_text(decision_profile), + "recommended_agent_behavior": recommended_agent_behavior, + } + + def _assistant_smart_decision_entry_message( + self, + *, + keyword: str, + decision_summary: Dict[str, Any], + available_sources: List[Dict[str, Any]], + blocked_sources: List[Dict[str, Any]], + ) -> str: + checked = decision_summary.get("checked_sources") or [] + checked_text = " -> ".join( + { + "pansou": "盘搜", + "hdhive": "影巢", + "mp_pt": "MP/PT", + }.get(self._clean_text(item).lower(), self._clean_text(item)) + for item in checked + if self._clean_text(item) + ) + lines = [f"资源决策:{keyword}"] + if checked_text: + lines.append(f"已检查:{checked_text}") + if available_sources: + lines.append("可用源:" + " / ".join(self._clean_text(item.get("label")) for item in available_sources if self._clean_text(item.get("label")))) + if blocked_sources: + blocked_text = ";".join( + f"{self._clean_text(item.get('label'))}:{self._clean_text(item.get('reason'))}" + for item in blocked_sources[:3] + if self._clean_text(item.get("label")) and self._clean_text(item.get("reason")) + ) + if blocked_text: + lines.append("已跳过:" + blocked_text) + if self._clean_text(decision_summary.get("decision_hint")): + lines.append(self._clean_text(decision_summary.get("decision_hint"))) + if self._clean_text(decision_summary.get("decision_reason")): + lines.append("结论:" + self._clean_text(decision_summary.get("decision_reason"))) + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + confirmation_prompt = self._clean_text(decision_summary.get("confirmation_prompt")) + if preferred_command: + lines.append(f"首选:{preferred_command}") + if fallback_command and fallback_command != preferred_command: + lines.append(f"备选:{fallback_command}") + if confirmation_prompt: + lines.append("确认链:" + confirmation_prompt) + return "\n".join(line for line in lines if line).strip() + + def _assistant_smart_source_item_by_choice( + self, + source_states: Dict[str, Any], + *, + source_type: str, + choice: int, + ) -> Dict[str, Any]: + current_state = source_states.get(source_type) if isinstance(source_states, dict) else {} + current_state = current_state if isinstance(current_state, dict) else {} + if source_type == "pansou": + items = current_state.get("items") or [] + elif source_type == "hdhive": + items = current_state.get("resources") or current_state.get("items") or [] + else: + items = current_state.get("items") or [] + for item in items: + if self._safe_int((item or {}).get("index") or (item or {}).get("pick_index"), 0) == choice: + return dict(item or {}) + return {} + + def _assistant_smart_best_plan_response( + self, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的智能搜索结果,请先发送:智能搜索 片名 或 智能计划 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_search_session_not_found", + }), + } + best_candidate = current_state.get("best_candidate") if isinstance(current_state.get("best_candidate"), dict) else {} + decision_summary = current_state.get("decision_summary") if isinstance(current_state.get("decision_summary"), dict) else {} + checked = current_state.get("sources_checked") if isinstance(current_state.get("sources_checked"), list) else [] + source_states = current_state.get("source_states") if isinstance(current_state.get("source_states"), dict) else {} + if not best_candidate: + return { + "success": False, + "message": "智能搜索当前没有可用于生成计划的候选,请先换片名,或调整偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_not_found", + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + risks = [self._clean_text(value) for value in (best_candidate.get("risk_reasons") or []) if self._clean_text(value)] + origin = self._clean_text(current_state.get("origin")).lower() + final_path = ( + target_path + or self._clean_text(current_state.get("target_path")) + or self._clean_text(((source_states.get(source_type) or {}) if isinstance(source_states, dict) else {}).get("target_path")) + ) + + if hard_risks: + head = self._clean_text(decision_summary.get("decision_hint")) or "当前首选存在硬风险。" + tail = ";".join(hard_risks[:2]) + return { + "success": False, + "message": f"{head}\n未生成计划:{tail}", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_best_candidate_blocked", + "best_candidate": best_candidate, + "decision_summary": decision_summary, + "sources_checked": checked, + }), + } + + result: Dict[str, Any] + if source_type == "pansou": + selected = self._assistant_smart_source_item_by_choice(source_states, source_type="pansou", choice=choice) or dict(best_candidate.get("raw_item") or {}) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return { + "success": False, + "message": "当前盘搜首选缺少可转存链接,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_share_url", + "best_candidate": best_candidate, + }), + } + provider_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": self._clean_text(selected.get("password")), + "path": final_path or provider_path, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "pansou", + "path": final_path or provider_path, + }, + message="智能搜索待确认计划已生成", + score_items=[selected], + extra_data={ + "choice": choice, + "source_type": "pansou", + "target_path": final_path or provider_path, + "selected_item": selected, + }, + ) + elif source_type == "hdhive": + resource = self._assistant_smart_source_item_by_choice(source_states, source_type="hdhive", choice=choice) or dict(best_candidate.get("raw_item") or {}) + slug = self._clean_text(resource.get("slug")) + if not slug: + return { + "success": False, + "message": "当前影巢首选缺少 slug,无法生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_missing_hdhive_slug", + "best_candidate": best_candidate, + }), + } + result = self._save_assistant_pick_plan_response( + workflow="smart_resource_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path or self._hdhive_default_path, + "resource": resource, + }], + execute_body={ + "workflow": "smart_resource_plan", + "session": session, + "session_id": cache_key, + "choice": choice, + "mode": "hdhive", + "path": final_path or self._hdhive_default_path, + }, + message="智能搜索待确认计划已生成", + score_items=[resource], + extra_data={ + "choice": choice, + "source_type": "hdhive", + "target_path": final_path or self._hdhive_default_path, + "selected_resource": resource, + }, + ) + elif source_type == "mp_pt": + preferences = self._assistant_preferences_for_session(session=session) + result = self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="smart_resource_plan", + message="智能搜索待确认计划已生成", + ) + else: + return { + "success": False, + "message": "当前首选来源暂不支持统一计划生成,请先查看详情后手动选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "smart_plan_unsupported_source", + "best_candidate": best_candidate, + }), + } + + data = dict(result.get("data") or {}) + adjusted_decision_summary = dict(decision_summary) + if origin == "mp_recommend" and choice and not hard_risks: + adjusted_decision_summary.update({ + "decision_mode": "make_plan", + "decision_reason": "推荐会话已为当前首项生成待确认计划;建议先看详情,再决定是否确认执行。", + "preferred_command": "确认", + "fallback_command": "详情", + "detail_command": "详情", + "detail_short_command": "详情", + "confirmation_prompt": "先看详情;如果确认无误,回复:确认。", + "plan_command": "计划", + "plan_short_command": "计划", + "execute_command": "确认", + "confirm_short_command": "确认", + "compact_commands": ["确认", "详情"], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + }) + data.update({ + "source_type": source_type, + "best_candidate": best_candidate, + "decision_summary": adjusted_decision_summary, + "sources_checked": checked, + "smart_plan_auto_selected": True, + }) + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in adjusted_decision_summary: + data[key] = adjusted_decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + if choice and not data.get("choice"): + data["choice"] = choice + result["data"] = data + message_lines = [ + "已根据智能搜索结果自动选择当前首选并生成待确认计划", + f"首选:{source_type} #{choice} | {score_value}分" + (f" | {title}" if title else ""), + result.get("message") or "", + ] + if risks: + message_lines.append("风险提示:" + ";".join(risks[:2])) + result["message"] = "\n".join(line for line in message_lines if line) + return result + + async def _assistant_execute_prepared_plan_result( + self, + request: Request, + *, + session: str, + cache_key: str, + plan_result: Dict[str, Any], + message_prefix: str = "", + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + if not bool(plan_result.get("success")): + return plan_result + plan_data = dict(plan_result.get("data") or {}) + plan_id = self._clean_text(plan_data.get("plan_id")) + if not plan_id: + return { + "success": False, + "message": "当前没有可执行的待确认计划,请先生成计划后再执行。", + "data": self._assistant_response_data(session=session, data={ + "action": "execute_plan", + "ok": False, + "error_code": "plan_not_found", + }), + } + execute_result = await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": plan_id, + "prefer_unexecuted": True, + "stop_on_error": True, + "include_raw_results": False, + "compact": False, + "apikey": self._extract_apikey(request, {}), + }) + ) + execute_data = dict(execute_result.get("data") or {}) + for key in [ + "source_type", + "best_candidate", + "decision_summary", + "sources_checked", + "score_summary", + "choice", + "smart_plan_auto_selected", + ]: + if key in plan_data: + execute_data[key] = plan_data.get(key) + if extra_data: + execute_data.update(dict(extra_data)) + execute_result["data"] = execute_data + if message_prefix: + execute_result["message"] = "\n".join( + line for line in [message_prefix, self._clean_text(execute_result.get("message"))] + if line + ).strip() + return execute_result + + async def _assistant_smart_best_execute_response( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]] = None, + target_path: str = "", + ) -> Dict[str, Any]: + plan_result = self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + ) + plan_data = dict(plan_result.get("data") or {}) + best_candidate = plan_data.get("best_candidate") if isinstance(plan_data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + choice = self._safe_int(best_candidate.get("choice"), 0) + score_value = self._safe_int(best_candidate.get("score"), 0) + title = self._clean_text(best_candidate.get("title")) + source_label = {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get(source_type, source_type or "当前源") + transfer_source_text = { + "pansou": "盘搜搜索转存", + "hdhive": "影巢搜索转存", + "mp_pt": "MP/PT 下载", + }.get(source_type, source_label) + prefix = "已根据智能搜索结果自动生成并执行当前首选计划" + prefix += f"\n执行方式:{transfer_source_text}" + if choice: + prefix += f"\n首选:{source_label} #{choice} | {score_value}分" + (f" | {title}" if title else "") + return await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=prefix, + extra_data={ + "smart_execute_auto_selected": True, + "source_type": source_type, + }, + ) + + async def _assistant_cloud_transfer_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + effective_target_path = self._clean_text(target_path) + prefer_provider = self._clean_text((session_preference_overrides or {}).get("prefer_cloud_provider")).lower() + if not effective_target_path: + if prefer_provider == "quark": + effective_target_path = self._quark_default_path + elif prefer_provider == "115": + effective_target_path = self._p115_default_path + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=effective_target_path, + search_result=search_result, + origin=origin, + ) + best_candidate = dict((smart_state.get("best_candidate") or {})) + if not search_result.get("success") and not best_candidate.get("source_type"): + return search_result + + preferences = self._assistant_smart_merge_session_preferences( + self._assistant_preferences_for_session(session=session), + session_overrides=session_preference_overrides, + ) + threshold = self._safe_int( + preferences.get("confirm_score_threshold"), + self._assistant_default_confirm_score_threshold, + ) + score_value = self._safe_int(best_candidate.get("score"), 0) + hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] + if best_candidate.get("source_type") and score_value >= threshold and not hard_risks: + execute_result = await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=effective_target_path, + ) + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if source_type == "hdhive": + raw_item = dict(best_candidate.get("raw_item") or {}) + points_text = self._resource_points_text(raw_item) + if points_text and points_text not in {"免费", "未标积分"}: + execute_result["message"] = "\n".join( + line + for line in [ + f"影巢积分:{points_text},已直接解锁并转存。", + self._clean_text(execute_result.get("message")), + ] + if line + ).strip() + return execute_result + + source_states = smart_state.get("source_states") if isinstance(smart_state.get("source_states"), dict) else {} + pansou_state = dict(source_states.get("pansou") or {}) + hdhive_state = dict(source_states.get("hdhive") or {}) + cloud_result = self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=pansou_state.get("items") or [], + pansou_total=len(pansou_state.get("items") or []), + hdhive_resources=hdhive_state.get("resources") or [], + hdhive_candidate=hdhive_state.get("selected_candidate") or {}, + hdhive_candidates=hdhive_state.get("candidates") or [], + target_path=effective_target_path or self._hdhive_default_path, + lead_note="当前云盘资源质量或风险未达到直接转存阈值,已列出盘搜 + 影巢结果供你手动选择。", + ) + top_choices: List[str] = [] + if best_candidate.get("choice") and best_candidate.get("source_type"): + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get( + self._clean_text(best_candidate.get("source_type")).lower(), + self._clean_text(best_candidate.get("source_type")), + ) + top_choices.append(f"{source_label} #{self._safe_int(best_candidate.get('choice'), 0)}") + for item in (smart_state.get("alternatives") or []): + if not isinstance(item, dict): + continue + source_type = self._clean_text(item.get("source_type")).lower() + if source_type not in {"pansou", "hdhive"}: + continue + source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get(source_type, source_type) + label = f"{source_label} #{self._safe_int(item.get('choice'), 0)}" + if label not in top_choices and self._safe_int(item.get("choice"), 0) > 0: + top_choices.append(label) + if len(top_choices) >= 3: + break + if top_choices: + cloud_result["message"] = "\n".join( + [ + self._clean_text(cloud_result.get("message")), + "推荐优先看:" + " / ".join(top_choices[:3]), + ] + ).strip() + if isinstance(cloud_result.get("data"), dict): + cloud_result["data"]["action"] = "cloud_transfer_execute" + cloud_result["data"]["best_candidate"] = best_candidate + return cloud_result + + async def _assistant_smart_resource_search( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + action_name: str = "smart_resource_search", + origin: str = "", + ) -> Dict[str, Any]: + base_preferences = self._assistant_preferences_for_session(session=session) + preferences = self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + decision_profile=decision_profile, + ) + search_order = self._assistant_smart_search_source_order(preferences, source_order=source_order) + available_sources, blocked_sources = self._assistant_smart_source_availability( + preferences, + source_order=source_order, + ) + if not search_order: + return { + "success": False, + "message": "当前偏好把盘搜、影巢、MP/PT 都关闭了,请先保存偏好后再试。", + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": False, + "error_code": "no_enabled_sources", + "preference_status": self._assistant_preferences_status_brief(session=session), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + }), + } + + checked: List[Dict[str, Any]] = [] + alternatives: List[Dict[str, Any]] = [] + active_state: Dict[str, Any] = {} + best_candidate: Dict[str, Any] = {} + best_source_score = -1 + source_states: Dict[str, Any] = {} + threshold = self._safe_int(preferences.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold) + + for current_source in search_order: + if current_source == "pansou": + search_ok, payload, search_message = self._call_pansou_search(keyword) + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + for idx, item in enumerate(items, start=1): + item["index"] = idx + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=6) + source_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": target_path or self._hdhive_default_path, + "items": items, + } + source_states[current_source] = copy.deepcopy(source_state) + summary = self._score_summary(items, limit=5) + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="pansou") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(search_ok), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": search_message or ("盘搜无结果" if not items else ""), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="pansou") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + if current_source == "hdhive": + hdhive_result = await self._assistant_smart_search_hdhive_resources( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + preferences=preferences, + ) + source_state = dict(hdhive_result.get("state") or {}) + items = hdhive_result.get("items") or [] + items = self._assistant_filter_cloud_items_by_preferences(items, preferences) + source_state["items"] = items + source_state["resources"] = items + source_states[current_source] = copy.deepcopy(source_state) + summary = hdhive_result.get("score_summary") if isinstance(hdhive_result.get("score_summary"), dict) else {} + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="hdhive") if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(hdhive_result.get("ok")), + "result_count": len(items), + "candidate_count": len(hdhive_result.get("candidates") or []), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._clean_text(hdhive_result.get("reason")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_cloud_item(item, source_type="hdhive") + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + continue + + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + items = ((mp_result.get("data") or {}).get("items") or []) if isinstance(mp_result.get("data"), dict) else [] + summary = ((mp_result.get("data") or {}).get("score_summary") or {}) if isinstance(mp_result.get("data"), dict) else {} + source_state = copy.deepcopy(self._load_session(cache_key) or {}) + source_states[current_source] = source_state + best_item = self._best_scored_source_item(items) + source_best = self._assistant_smart_search_candidate_from_pt_item(best_item) if best_item else {} + checked.append({ + "source_type": current_source, + "ok": bool(mp_result.get("success")), + "result_count": len(items), + "best_score": self._safe_int((source_best or {}).get("score"), 0), + "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), + "reason": self._assistant_result_message_head(mp_result.get("message")), + }) + alternatives.extend( + self._assistant_smart_search_candidate_from_pt_item(item) + for item in items[:4] + ) + if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: + best_source_score = self._safe_int(source_best.get("score"), 0) + best_candidate = source_best + active_state = source_state + if self._assistant_smart_search_stop_after_source(source_best, preferences): + break + + alternatives = [item for item in alternatives if isinstance(item, dict) and item.get("choice")] + alternatives.sort( + key=lambda item: ( + self._safe_int(item.get("score"), 0), + 0 if item.get("hard_risk_reasons") else 1, + ), + reverse=True, + ) + unique_alternatives: List[Dict[str, Any]] = [] + seen_keys: set = set() + for item in alternatives: + dedupe_key = ( + self._clean_text(item.get("source_type")).lower(), + self._safe_int(item.get("choice"), 0), + ) + if dedupe_key in seen_keys: + continue + seen_keys.add(dedupe_key) + unique_alternatives.append(item) + + decision_summary = self._assistant_smart_search_decision_summary( + keyword=keyword, + preferences=preferences, + checked=checked, + best_candidate=best_candidate, + available_sources=available_sources, + blocked_sources=blocked_sources, + decision_profile=decision_profile, + ) + if active_state: + smart_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path or active_state.get("target_path") or "", + "active_source": self._clean_text(best_candidate.get("source_type")), + "active_state": copy.deepcopy(active_state), + "source_states": source_states, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "source_order": search_order, + "decision_profile": self._clean_text(decision_profile), + "decision_entry": action_name, + "origin": self._clean_text(origin), + "session_preference_overrides": dict(session_preference_overrides or {}), + } + self._save_session(cache_key, smart_state) + + if action_name == "smart_resource_decision": + message_lines = [ + self._assistant_smart_decision_entry_message( + keyword=keyword, + decision_summary=decision_summary, + available_sources=available_sources, + blocked_sources=blocked_sources, + ) + ] + else: + message_lines = [ + f"智能搜索:{keyword}", + "已检查:" + " -> ".join( + {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get( + self._clean_text(item.get("source_type")).lower(), + self._clean_text(item.get("source_type")), + ) + for item in checked + if self._clean_text(item.get("source_type")) + ), + ] + if best_candidate: + message_lines.append(decision_summary.get("decision_hint") or "") + if best_candidate.get("hard_risk_reasons"): + message_lines.append("降级原因:" + ";".join(best_candidate.get("hard_risk_reasons")[:2])) + elif best_candidate.get("risk_reasons") and self._safe_int(best_candidate.get("score"), 0) < threshold: + message_lines.append("继续原因:" + ";".join(best_candidate.get("risk_reasons")[:2])) + else: + message_lines.append("当前按偏好筛过可用源后,没有找到可推荐结果。") + preferred_command = self._clean_text(decision_summary.get("preferred_command")) + fallback_command = self._clean_text(decision_summary.get("fallback_command")) + plan_command = self._clean_text(decision_summary.get("plan_command")) + execute_command = self._clean_text(decision_summary.get("execute_command")) + detail_short_command = self._clean_text(decision_summary.get("detail_short_command")) + plan_short_command = self._clean_text(decision_summary.get("plan_short_command")) + confirm_short_command = self._clean_text(decision_summary.get("confirm_short_command")) + if action_name != "smart_resource_decision" and preferred_command: + message_lines.append(f"建议先发:{preferred_command}") + if action_name != "smart_resource_decision" and fallback_command and fallback_command != preferred_command: + message_lines.append(f"备选:{fallback_command}") + if action_name != "smart_resource_decision" and plan_command and plan_command not in {preferred_command, fallback_command}: + message_lines.append(f"如需直接生成待确认计划:{plan_command}") + if action_name != "smart_resource_decision" and execute_command and execute_command not in {preferred_command, fallback_command, plan_command}: + message_lines.append(f"如需直接执行:{execute_command}") + override_summary = self._assistant_smart_session_overrides_summary(session_preference_overrides) + if override_summary: + message_lines.append("会话偏好:" + override_summary) + + score_summary = {} + active_source_type = self._clean_text(best_candidate.get("source_type")).lower() + if active_source_type == "pansou": + active_items = (source_states.get("pansou") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "hdhive": + active_items = (source_states.get("hdhive") or {}).get("resources") or [] + score_summary = self._score_summary(active_items, limit=5) + elif active_source_type == "mp_pt": + active_items = (source_states.get("mp_pt") or {}).get("items") or [] + score_summary = self._score_summary(active_items, limit=5) + + return { + "success": True, + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": action_name, + "ok": True, + "sources_checked": checked, + "best_candidate": best_candidate, + "alternatives": unique_alternatives[:6], + "decision_summary": decision_summary, + "score_summary": score_summary, + "preference_status": self._assistant_preferences_status_brief(session=session), + "effective_preferences": preferences, + "session_preference_overrides": dict(session_preference_overrides or {}), + "available_sources": available_sources, + "blocked_sources": blocked_sources, + "decision_mode": self._clean_text(decision_summary.get("decision_mode")), + "decision_reason": self._clean_text(decision_summary.get("decision_reason")), + "next_actions": [ + command + for command in [ + preferred_command, + fallback_command, + detail_short_command, + plan_command, + plan_short_command, + execute_command, + confirm_short_command, + "继续决策", + "换影巢", + "换盘搜", + "换PT", + "保守一点", + "激进一点", + "只用夸克", + "只用115", + "只走PT", + "不用影巢", + "按保存偏好", + "跟进", + ] + if self._clean_text(command) + ], + }), + } + + async def _assistant_smart_resource_decision( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + decision_profile: str = "", + session_preference_overrides: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + return await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + action_name="smart_resource_decision", + origin=origin, + ) + + async def _assistant_smart_resource_decision_adjust( + self, + request: Request, + *, + session: str, + cache_key: str, + state: Optional[Dict[str, Any]], + adjust_action: str, + ) -> Dict[str, Any]: + current_state = dict(state or self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) != "assistant_smart_search": + return { + "success": False, + "message": "当前没有可继续的资源决策会话,请先发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_session_not_found", + }), + } + keyword = self._clean_text(current_state.get("keyword")) + if not keyword: + return { + "success": False, + "message": "当前资源决策会话缺少片名,请重新发送:资源决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "smart_decision_missing_keyword", + }), + } + media_type = self._clean_text(current_state.get("media_type") or "auto") or "auto" + year = self._clean_text(current_state.get("year")) + target_path = self._clean_text(current_state.get("target_path")) + source_order = list(current_state.get("source_order") or []) + session_preference_overrides = dict(current_state.get("session_preference_overrides") or {}) + if not source_order: + base_preferences = self._assistant_preferences_for_session(session=session) + source_order = self._assistant_smart_search_source_order( + self._assistant_smart_merge_session_preferences( + base_preferences, + session_overrides=session_preference_overrides, + ) + ) + decision_profile = self._clean_text(current_state.get("decision_profile")) + if adjust_action == "decision_hdhive": + source_order = ["hdhive", "pansou", "mp_pt"] + elif adjust_action == "decision_pansou": + source_order = ["pansou", "hdhive", "mp_pt"] + elif adjust_action == "decision_mp_pt": + source_order = ["mp_pt", "pansou", "hdhive"] + elif adjust_action in { + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + session_preference_overrides, source_order = self._assistant_smart_apply_session_override( + session_preference_overrides, + adjust_action=adjust_action, + source_order=source_order, + ) + elif adjust_action == "decision_conservative": + decision_profile = "conservative" + elif adjust_action == "decision_aggressive": + decision_profile = "aggressive" + elif adjust_action == "decision_continue": + pass + return await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=decision_profile, + session_preference_overrides=session_preference_overrides, + ) + + async def _assistant_smart_resource_plan( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + async def _assistant_smart_resource_execute( + self, + request: Request, + *, + keyword: str, + session: str, + cache_key: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + origin: str = "", + ) -> Dict[str, Any]: + if keyword: + search_result = await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + ) + smart_state = self._assistant_smart_state_after_search( + cache_key=cache_key, + keyword=keyword, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + search_result=search_result, + origin=origin, + ) + if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): + return search_result + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=smart_state, + target_path=target_path, + ) + return await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + target_path=target_path, + ) + + def _assistant_smart_state_after_search( + self, + *, + cache_key: str, + keyword: str, + media_type: str, + year: str, + source_order: Optional[List[str]] = None, + target_path: str = "", + search_result: Optional[Dict[str, Any]] = None, + origin: str = "", + ) -> Dict[str, Any]: + current_state = dict(self._load_session(cache_key) or {}) + if self._clean_text(current_state.get("kind")) == "assistant_smart_search": + return current_state + data = (search_result or {}).get("data") if isinstance((search_result or {}).get("data"), dict) else {} + best_candidate = data.get("best_candidate") if isinstance(data.get("best_candidate"), dict) else {} + source_type = self._clean_text(best_candidate.get("source_type")).lower() + if not source_type: + return current_state + source_states = copy.deepcopy(current_state.get("source_states") or {}) if isinstance(current_state.get("source_states"), dict) else {} + raw_item = dict(best_candidate.get("raw_item") or {}) if isinstance(best_candidate.get("raw_item"), dict) else {} + choice = self._safe_int(best_candidate.get("choice") or raw_item.get("index"), 0) + final_path = self._clean_text(target_path or current_state.get("target_path") or data.get("target_path")) + + if source_type == "pansou" and raw_item: + item = dict(raw_item) + if choice and not item.get("index"): + item["index"] = choice + if not final_path: + share_url = self._clean_text(item.get("url")) + final_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + source_states[source_type] = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": keyword, + "target_path": final_path, + "items": [item], + } + elif source_type == "hdhive" and raw_item: + resource = dict(raw_item) + source_states[source_type] = { + "kind": "assistant_hdhive", + "stage": "resource_list", + "keyword": keyword, + "target_path": final_path or self._hdhive_default_path, + "resources": [resource], + "items": [resource], + } + + active_state = copy.deepcopy(source_states.get(source_type) or current_state.get("active_state") or {}) + fallback_state = { + "kind": "assistant_smart_search", + "stage": "result", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": final_path, + "active_source": source_type, + "active_state": active_state, + "source_states": source_states, + "sources_checked": data.get("sources_checked") if isinstance(data.get("sources_checked"), list) else [], + "best_candidate": best_candidate, + "alternatives": data.get("alternatives") if isinstance(data.get("alternatives"), list) else [], + "decision_summary": data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}, + "available_sources": data.get("available_sources") if isinstance(data.get("available_sources"), list) else [], + "blocked_sources": data.get("blocked_sources") if isinstance(data.get("blocked_sources"), list) else [], + "source_order": source_order or current_state.get("source_order") or [], + "decision_profile": self._clean_text(current_state.get("decision_profile") or ((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_profile")), + "decision_entry": self._clean_text(data.get("action") or current_state.get("decision_entry") or "smart_resource_search"), + "origin": self._clean_text(origin or current_state.get("origin")), + "session_preference_overrides": data.get("session_preference_overrides") if isinstance(data.get("session_preference_overrides"), dict) else dict(current_state.get("session_preference_overrides") or {}), + } + self._save_session(cache_key, fallback_state) + return fallback_state + + @staticmethod + def _assistant_score_warning_text(score: Dict[str, Any], *, limit: int = 3) -> str: + risks = [str(value) for value in (score.get("risk_reasons") or []) if value] + if not risks: + return "" + return "风险提示:" + ";".join(risks[:max(1, limit)]) + + def _assistant_mp_download_plan_response( + self, + *, + choice: int, + session: str, + cache_key: str, + preferences: Dict[str, Any], + workflow: str = "mp_download", + message: str = "PT 下载计划已生成", + ) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会生成下载计划。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”后再选择编号。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + }), + } + result = self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": "pick_mp_download", + "session": session, + "session_id": cache_key, + "choice": choice, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "choice": choice, + "dry_run": False, + }, + message=message, + score_items=[selected], + extra_data={ + "choice": choice, + "selected": selected, + }, + ) + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + warning = self._assistant_score_warning_text(score) + if warning: + result["message"] = f"{result.get('message')}\n{warning}" + return result + + def _assistant_mp_subscribe_plan_response( + self, + *, + keyword: str, + session: str, + cache_key: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + action_name = "start_mp_subscribe_search" if immediate_search else "start_mp_subscribe" + workflow = "mp_subscribe_and_search" if immediate_search else "mp_subscribe" + label = "订阅并搜索计划已生成" if immediate_search else "订阅计划已生成" + return self._save_assistant_pick_plan_response( + workflow=workflow, + session=session, + session_id=cache_key, + actions=[{ + "name": action_name, + "session": session, + "session_id": cache_key, + "keyword": keyword, + }], + execute_body={ + "workflow": workflow, + "session": session, + "session_id": cache_key, + "keyword": keyword, + "dry_run": False, + }, + message=label, + extra_data={ + "keyword": keyword, + "immediate_search": bool(immediate_search), + }, + ) + + def _assistant_mp_download_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "下载任务操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + } + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return { + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_target_not_found", + "target": target, + }), + } + downloader = self._clean_text(downloader) + return self._save_assistant_pick_plan_response( + workflow="mp_download_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + }], + execute_body={ + "workflow": "mp_download_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "downloader": downloader, + "delete_files": delete_files, + "dry_run": False, + }, + message="下载任务操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + def _assistant_mp_subscribe_control_plan_response( + self, + *, + control: str, + target: str, + session: str, + cache_key: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + control = self._clean_text(control) + target = self._clean_text(target) + if not control or not target: + return { + "success": False, + "message": "订阅操作缺少 control 或 target。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + } + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id): + return { + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + } + return self._save_assistant_pick_plan_response( + workflow="mp_subscribe_control", + session=session, + session_id=cache_key, + actions=[{ + "name": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + }], + execute_body={ + "workflow": "mp_subscribe_control", + "session": session, + "session_id": cache_key, + "control": control, + "target": target, + "dry_run": False, + }, + message="订阅操作计划已生成", + extra_data={ + "control": control, + "target": target, + }, + ) + + async def _assistant_mp_download(self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any]) -> Dict[str, Any]: + selected, available_indexes = self._assistant_mp_item_by_display_index( + choice=choice, + cache_key=cache_key, + preferences=preferences, + ) + if not selected: + available_text = "、".join(str(index) for index in available_indexes[:20]) + return { + "success": False, + "message": ( + f"当前列表没有 #{choice},不会执行下载。可选编号:{available_text}。" + if available_text + else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": False, + "error_code": "mp_result_not_found", + "choice": choice, + "available_indexes": available_indexes, + "write_effect": "state", + }), + } + score = selected.get("score") if isinstance(selected.get("score"), dict) else {} + cache_choice = self._safe_int(selected.get("cache_index"), choice) + message_text = self._ensure_feishu_channel()._execute_media_download(cache_choice, cache_key) + ok = not message_text.startswith(( + "下载资源失败", + "下载提交失败", + "没有可用", + "序号超出范围", + )) and "无法连接" not in message_text and "添加下载任务失败" not in message_text + warning = self._assistant_score_warning_text(score) + if warning: + message_text = f"{message_text}\n{warning}".strip() + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download", + "ok": ok, + "choice": choice, + "cache_choice": cache_choice, + "selected": selected, + "score": score, + "write_effect": "write", + }), + } + + async def _assistant_mp_subscribe( + self, + *, + keyword: str, + session: str, + immediate_search: bool = False, + ) -> Dict[str, Any]: + keyword = self._clean_text(keyword) + if not keyword: + return { + "success": False, + "message": "订阅失败:缺少片名或关键词", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": False, + "error_code": "missing_keyword", + }), + } + message_text = self._ensure_feishu_channel()._execute_media_subscribe(keyword, immediate_search) + ok = not message_text.startswith("订阅失败") + return { + "success": ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", + "ok": ok, + "keyword": keyword, + "immediate_search": bool(immediate_search), + "write_effect": "write", + }), + } + + def _resolve_mp_download_task_target(self, *, target: str, cache_key: str) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index: + return dict(item) + hash_match = re.search(r"\b[0-9a-fA-F]{40}\b", target_text) + if hash_match: + task_hash = hash_match.group(0) + return {"hash": task_hash, "hash_short": task_hash[:8]} + short_hash_match = re.search(r"\b[0-9a-fA-F]{6,12}\b", target_text) + if short_hash_match: + short_hash = short_hash_match.group(0).lower() + for item in items: + task_hash = self._clean_text(item.get("hash")).lower() + if task_hash.startswith(short_hash): + return dict(item) + return {} + + async def _assistant_mp_download_tasks( + self, + *, + session: str, + cache_key: str, + status: str = "downloading", + title: str = "", + hash_value: str = "", + downloader: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_tasks( + downloader=downloader, + status=status or "downloading", + title=title, + hash_value=hash_value, + limit=max(1, min(30, self._safe_int(limit, 10))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_tasks", + "stage": "download_tasks", + "keyword": title or hash_value or status or "downloading", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_tasks", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": result.get("total", len(items)), + }), + } + + async def _assistant_mp_download_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_download_task_target(target=target, cache_key=cache_key) + task_hash = self._clean_text(selected.get("hash")) + if not task_hash: + return { + "success": False, + "message": "操作下载任务失败:请先发送“下载任务”获取列表,再按编号操作,例如“暂停下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "missing_download_task_hash", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_download_task( + action=control, + hash_value=task_hash, + downloader=downloader or self._clean_text(selected.get("downloader")), + delete_files=delete_files, + ) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载任务操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_download_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_download_history( + title=title, + hash_value=hash_value, + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_download_history", + "stage": "download_history", + "keyword": title or hash_value or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_download_history", + "title": title, + "hash": hash_value, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + async def _assistant_mp_downloaders(self, *, session: str, cache_key: str) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_downloaders() + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_downloaders", + "stage": "downloaders", + "keyword": "downloaders", + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "下载器查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_downloaders", + "ok": bool(result.get("success")), + "items": items, + "enabled_count": self._safe_int(result.get("enabled_count"), 0), + }), + } + + async def _assistant_mp_sites( + self, + *, + session: str, + cache_key: str, + status: str = "active", + name: str = "", + limit: int = 30, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_sites( + status=status or "active", + name=name, + limit=max(1, min(100, self._safe_int(limit, 30))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_sites", + "stage": "sites", + "keyword": name or status or "active", + "items": items, + "status": result.get("status") or status, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "站点查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_sites", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), 0), + }), + } + + def _resolve_mp_subscribe_target(self, *, target: str, cache_key: str, allow_raw_id: bool = False) -> Dict[str, Any]: + target_text = self._clean_text(target) + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + index = self._safe_int(target_text, 0) + if index > 0: + for item in items: + if self._safe_int(item.get("index"), 0) == index or self._safe_int(item.get("id"), 0) == index: + return dict(item) + if allow_raw_id: + return {"id": index} + return {} + + async def _assistant_mp_subscribes( + self, + *, + session: str, + cache_key: str, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_subscribes( + status=status or "all", + media_type=media_type or "all", + name=name, + limit=max(1, min(100, self._safe_int(limit, 20))), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_subscribes", + "stage": "subscribe_list", + "keyword": name or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribes", + "ok": bool(result.get("success")), + "status": result.get("status") or status, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + }), + } + + async def _assistant_mp_subscribe_control( + self, + *, + session: str, + cache_key: str, + control: str, + target: str, + allow_raw_id: bool = False, + ) -> Dict[str, Any]: + selected = self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id) + subscribe_id = self._safe_int(selected.get("id") or target, 0) + if subscribe_id <= 0: + return { + "success": False, + "message": "操作订阅失败:请先发送“订阅列表”获取列表,再按编号操作,例如“搜索订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "missing_subscribe_id", + "target": target, + }), + } + result = self._ensure_feishu_channel()._control_subscribe(action=control, subscribe_id=subscribe_id) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "订阅操作完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": bool(result.get("success")), + "control": control, + "target": target, + "selected": selected, + "result": result, + "write_effect": "write", + }), + } + + async def _assistant_mp_transfer_history( + self, + *, + session: str, + cache_key: str, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status=status or "all", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + self._save_session(cache_key, { + "kind": "assistant_mp_transfer_history", + "stage": "transfer_history", + "keyword": title or status or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": self._clean_text(result.get("message")) or "整理历史查询完成", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_transfer_history", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_history", + "status": result.get("status") or status, + "title": title, + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + }), + } + + def _assistant_mp_detect_stage( + self, + *, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> str: + def has_term(value: Any, terms: List[str]) -> bool: + text = self._clean_text(value).lower() + return bool(text) and any(term in text for term in terms) + + failed_terms = ["失败", "错误", "error", "fail", "未识别", "识别失败", "入库失败", "整理失败"] + success_terms = ["成功", "完成", "已入库", "已整理", "已转移", "imported", "success", "completed"] + running_terms = ["处理中", "进行中", "转移中", "整理中", "入库中", "等待", "队列", "pending", "running"] + + for item in transfer_items: + if has_term(item.get("status_text") or item.get("status"), failed_terms): + return "failed" + for item in download_items: + if has_term(item.get("transfer_status_text") or item.get("transfer_status"), failed_terms): + return "failed" + + if task_items: + return "downloading" + + for item in transfer_items: + status_value = item.get("status_text") or item.get("status") + if has_term(status_value, success_terms): + return "imported" + if has_term(status_value, running_terms): + return "transferring" + + for item in download_items: + transfer_value = item.get("transfer_status_text") or item.get("transfer_status") + if has_term(transfer_value, success_terms): + return "imported" + if has_term(transfer_value, running_terms): + return "transferring" + + if transfer_items: + return "transferring" + if download_items: + return "downloaded" + return "not_found" + + def _assistant_mp_diagnosis_summary( + self, + *, + keyword: str, + hash_value: str, + task_items: List[Dict[str, Any]], + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + force_failed: bool = False, + ) -> Dict[str, Any]: + stage = "failed" if force_failed else self._assistant_mp_detect_stage( + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + matched_count = len(task_items) + len(download_items) + len(transfer_items) + matched = matched_count > 0 + evidence: List[str] = [] + risk_reasons: List[str] = [] + recommended_action = "" + follow_up_hint = "" + confidence = 0.0 + + if task_items: + first_task = task_items[0] + evidence.append( + f"下载任务 {self._clean_text(first_task.get('title')) or '-'} | " + f"{self._clean_text(first_task.get('progress')) or '-'} | " + f"{self._clean_text(first_task.get('state')) or '-'}" + ) + if download_items: + first_download = download_items[0] + evidence.append( + f"下载历史 {self._clean_text(first_download.get('title')) or '-'} | " + f"{self._clean_text(first_download.get('date')) or '-'} | " + f"{self._clean_text(first_download.get('transfer_status_text')) or '-'}" + ) + transfer_status_text = self._clean_text(first_download.get("transfer_status_text")) + if transfer_status_text and any(word in transfer_status_text for word in ["失败", "错误"]): + risk_reasons.append(f"下载历史显示整理状态异常:{transfer_status_text}") + if transfer_items: + first_transfer = transfer_items[0] + evidence.append( + f"整理历史 {self._clean_text(first_transfer.get('title')) or '-'} | " + f"{self._clean_text(first_transfer.get('status_text')) or '-'} | " + f"{self._clean_text(first_transfer.get('date')) or '-'}" + ) + status_text = self._clean_text(first_transfer.get("status_text")) + if status_text and any(word in status_text for word in ["失败", "错误"]): + risk_reasons.append(f"整理历史存在失败记录:{status_text}") + + if stage == "downloading": + confidence = 0.95 + recommended_action = "query_mp_download_tasks" + follow_up_hint = "资源仍在下载阶段,优先查看下载任务进度。" + elif stage == "downloaded": + confidence = 0.85 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已找到下载历史,但还没有明确的入库记录,下一步查看整理/入库历史。" + elif stage == "transferring": + confidence = 0.9 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "资源已经进入整理/入库链路,优先查看最近整理历史。" + elif stage == "imported": + confidence = 0.98 + recommended_action = "query_mp_transfer_history" + follow_up_hint = "已经找到成功入库线索,如需确认最终落地可查看最近整理历史。" + elif stage == "failed": + confidence = 0.92 if matched else 0.65 + recommended_action = "query_mp_local_diagnose" + follow_up_hint = "已发现失败线索,建议进入本地诊断汇总失败原因和下一步处理建议。" + elif stage == "not_found": + confidence = 0.95 + recommended_action = "query_mp_download_history" + follow_up_hint = "当前没有找到相关下载任务、下载历史或整理记录。先检查是否真的提交过下载/订阅。" + else: + confidence = 0.5 + recommended_action = "query_mp_lifecycle_status" + follow_up_hint = "状态不足以判断,建议继续查看生命周期聚合结果。" + + if hash_value: + evidence.append(f"Hash 检索:{hash_value}") + elif keyword: + evidence.append(f"关键词检索:{keyword}") + + return { + "status": "ok" if matched else "not_found", + "matched": matched, + "stage": stage, + "confidence": confidence, + "evidence": evidence[:6], + "risk_reasons": risk_reasons[:6], + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + } + + def _assistant_mp_diagnosis_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + hash_value: str, + preferred: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + } + keyword_body = dict(base_body) + if keyword: + keyword_body["keyword"] = keyword + if hash_value: + keyword_body["hash"] = hash_value + action_order = [preferred] + for name in ["query_mp_lifecycle_status", "query_mp_download_history", "query_mp_transfer_history", "query_mp_local_diagnose"]: + if name not in action_order: + action_order.append(name) + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_mp_lifecycle_status": "聚合查看下载任务、下载历史和整理/入库历史", + "query_mp_download_history": "查看下载历史,并确认是否已经提交过下载", + "query_mp_transfer_history": "查看整理/入库历史,确认是否已经落库或失败", + "query_mp_local_diagnose": "汇总本地/PT 链路的失败线索与处理建议", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**keyword_body, "name": name, "compact": True}, + ) + for name in action_order[:4] + ] + return action_order[:4], templates + + def _assistant_followup_command(self, action_name: str, *, keyword: str = "", hash_value: str = "") -> str: + target = self._clean_text(keyword or hash_value) + mapping = { + "query_execution_followup": "后续", + "query_mp_download_tasks": "下载任务", + "query_mp_subscribes": "订阅列表", + } + if action_name in mapping: + return mapping[action_name] + if action_name == "query_mp_ingest_status": + return f"入库 {target}".strip() + if action_name == "query_mp_lifecycle_status": + return f"状态 {target}".strip() + if action_name == "query_mp_download_history": + return f"记录 {target}".strip() + if action_name == "query_mp_transfer_history": + return f"入库记录 {target}".strip() + if action_name == "query_mp_local_diagnose": + return f"诊断 {target}".strip() + if action_name == "query_ai_failed_samples": + return f"失败样本 {target}".strip() if target else "失败样本" + if action_name == "query_ai_sample_worklist": + return f"工作清单 {target}".strip() if target else "工作清单" + if action_name == "query_ai_sample_insights": + return f"样本洞察 {target}".strip() if target else "样本洞察" + if action_name == "start_mp_media_search": + return f"MP搜索 {target}".strip() + return "" + + def _assistant_template_command( + self, + template: Dict[str, Any], + *, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> str: + if not isinstance(template, dict): + return "" + body = dict(template.get("action_body") or template.get("body") or {}) + action_name = self._clean_text(body.get("name")) or self._clean_text(template.get("name")) + target_text = self._clean_text(target or body.get("keyword") or body.get("hash") or body.get("target")) + command = self._assistant_followup_command( + action_name, + keyword=self._clean_text(keyword or body.get("keyword") or target_text), + hash_value=self._clean_text(hash_value or body.get("hash")), + ) + if command: + return command + if action_name == "query_mp_recent_activity": + return "最近" + if action_name == "execute_session_latest_plan": + return "执行计划" + if action_name == "mp_download_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停下载", + "resume": "恢复下载", + "delete": "删除下载", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "下载任务" + if action_name == "mp_subscribe_control": + control = self._clean_text(body.get("control")) + mapping = { + "pause": "暂停订阅", + "resume": "恢复订阅", + "delete": "删除订阅", + "search": "搜索订阅", + } + prefix = mapping.get(control) + if prefix and target_text: + return f"{prefix} {target_text}" + return prefix or "订阅列表" + return "" + + def _assistant_error_summary( + self, + *, + error_code: str, + recommended_action: str = "", + message_head: str = "", + next_actions: Optional[List[Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + target: str = "", + ) -> Dict[str, Any]: + code = self._clean_text(error_code) + if not code: + return {} + label_map = { + "latest_plan_not_executed": "当前会话还有待执行计划", + "plan_not_executed": "指定计划还没有执行", + "executed_plan_not_found": "当前没有可追踪的已执行计划", + "followup_not_available": "当前计划没有自动追踪动作", + "invalid_download_control_args": "下载任务参数不完整", + "download_target_not_found": "没有匹配到可操作的下载任务", + "invalid_subscribe_control_args": "订阅操作参数不完整", + "subscribe_target_not_found": "没有匹配到可操作的订阅", + } + hint_map = { + "latest_plan_not_executed": "先执行计划,再继续查看后续状态。", + "plan_not_executed": "先执行这条计划,再继续查看执行后追踪。", + "executed_plan_not_found": "先执行下载、订阅或转存计划,再查看后续。", + "followup_not_available": "当前计划只能改查状态、记录或最近活动。", + "invalid_download_control_args": "先发“下载任务”获取列表,再按编号暂停、恢复或删除。", + "download_target_not_found": "先发“下载任务”刷新列表,再按编号操作。", + "invalid_subscribe_control_args": "先发“订阅列表”获取列表,再按编号暂停、恢复或删除。", + "subscribe_target_not_found": "先发“订阅列表”刷新列表,再按编号操作。", + } + command_candidates: List[str] = [] + fallback_map = { + "latest_plan_not_executed": ["执行计划", "后续"], + "plan_not_executed": ["执行计划", "后续"], + "executed_plan_not_found": ["最近"], + "followup_not_available": [f"状态 {target}".strip(), f"记录 {target}".strip(), "最近"], + "invalid_download_control_args": ["下载任务"], + "download_target_not_found": ["下载任务"], + "invalid_subscribe_control_args": ["订阅列表"], + "subscribe_target_not_found": ["订阅列表"], + } + for command in fallback_map.get(code, []): + command_text = self._clean_text(command) + if command_text and command_text not in command_candidates: + command_candidates.append(command_text) + synthetic_templates: List[Dict[str, Any]] = [] + action_name = self._clean_text(recommended_action) + if action_name: + synthetic_templates.append({ + "name": action_name, + "action_body": { + "name": action_name, + "keyword": keyword, + "hash": hash_value, + "target": target, + }, + }) + synthetic_templates.extend( + item for item in (action_templates or []) if isinstance(item, dict) + ) + for item in synthetic_templates: + command = self._assistant_template_command( + item, + keyword=keyword, + hash_value=hash_value, + target=target, + ) + if command and command not in command_candidates: + command_candidates.append(command) + for name in [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)]: + command = self._assistant_followup_command(name, keyword=keyword or target, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + preferred_requires_confirmation = code in {"latest_plan_not_executed", "plan_not_executed"} + return { + "error_code": code, + "label": label_map.get(code, message_head or code), + "decision_hint": hint_map.get(code, message_head or "当前请求未完成,请先按建议补充上下文。"), + "command_policy": "confirm_then_resume" if preferred_requires_confirmation else "safe_read_recovery", + "preferred_requires_confirmation": preferred_requires_confirmation, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": not preferred_requires_confirmation and bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + } + + def _format_quark_transfer_failure(self, detail: str = "", target_path: str = "") -> str: + text = self._clean_text(detail) + target = self._clean_text(target_path) or self._quark_default_path or "/飞书" + if "41031" in text or "分享者用户封禁链接查看受限" in text: + return ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + "建议改选同一列表里的其他夸克资源。" + ) + if "未获取到 stoken" in text: + try: + cookie_ok, _cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok = False + if cookie_ok: + return ( + "夸克转存失败:当前夸克登录态正常,但这条分享无法获取 stoken," + "更可能是分享链接失效、提取码错误或分享已受限。建议改选同一列表里的其他夸克资源。" + ) + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if "分享链接为空" in text or "无权查看内容" in text: + return ( + "夸克转存失败:当前分享内容为空或账号无权查看," + "更可能是分享链接失效、内容被取消分享或分享受限。建议改选其他资源。" + ) + if "require login [guest]" in text or "未配置夸克 Cookie" in text: + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text or text == f"无法转存到 {target}": + try: + cookie_ok, cookie_message = self._ensure_quark_service().check_cookie() + except Exception: + cookie_ok, cookie_message = True, "" + cookie_text = self._clean_text(cookie_message) + if (not cookie_ok) and ( + "require login [guest]" in cookie_text + or "未配置夸克 Cookie" in cookie_text + or "cookie" in cookie_text.lower() + or "login" in cookie_text.lower() + ): + return ( + f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" + "建议先刷新夸克登录后再试。" + ) + if not text: + return ( + f"夸克转存失败:无法转存到 {target}。" + "当前原因未明,请先不要自行推断为路径问题,可稍后重试或先检查夸克登录态。" + ) + return f"夸克转存失败:{text}" + + @staticmethod + def _is_quark_share_restricted_message(detail: str) -> bool: + text = str(detail or "").strip() + if not text: + return False + return "41031" in text or "分享者用户封禁链接查看受限" in text + + async def _assistant_retry_pansou_quark_candidates( + self, + request: Request, + *, + items: List[Dict[str, Any]], + selected_index: int, + selected_url: str, + session: str, + target_path: str, + apikey: str, + ) -> Optional[Dict[str, Any]]: + candidates: List[Tuple[int, Dict[str, Any]]] = [] + for offset, raw_item in enumerate(items[selected_index:], start=selected_index + 1): + item = dict(raw_item or {}) + share_url = self._clean_text(item.get("url")) + if not share_url or not self._is_quark_url(share_url): + continue + if share_url == selected_url: + continue + candidates.append((offset, item)) + if len(candidates) >= 3: + break + + if not candidates: + return None + + for alt_index, alt_item in candidates: + alt_url = self._clean_text(alt_item.get("url")) + alt_code = self._clean_text(alt_item.get("password")) + alt_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": alt_url, + "access_code": alt_code, + "path": target_path, + "trigger": "Agent影视助手 盘搜夸克候选自动切换", + "apikey": apikey, + }) + ) + if alt_result.get("success"): + message = ( + f"原夸克链接已受限,已自动切换到同列表夸克候选 #{alt_index}。\n" + f"{str(alt_result.get('message') or '夸克转存成功')}" + ) + data = dict(alt_result.get("data") or {}) + data.update( + { + "provider": "quark", + "selected_choice": alt_index, + "auto_switched_from_choice": selected_index, + "auto_switched": True, + "selected_item": alt_item, + } + ) + return { + "success": True, + "message": message, + "data": self._assistant_response_data(session=session, data=data), + } + + hint_choices = "、".join(f"#{idx}" for idx, _ in candidates) + return { + "success": False, + "message": ( + "夸克转存失败:当前分享链接已受限或分享者账号受限," + f"可改试同列表夸克候选 {hint_choices}。" + ), + "data": self._assistant_response_data( + session=session, + data={ + "provider": "quark", + "auto_switched": False, + "fallback_choices": [idx for idx, _ in candidates], + }, + ), + } + + def _assistant_followup_summary( + self, + *, + category: str, + stage: str, + recommended_action: str, + follow_up_hint: str, + next_actions: Optional[List[str]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + keyword: str = "", + hash_value: str = "", + ) -> Dict[str, Any]: + action_names = [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)] + command_candidates: List[str] = [] + for name in action_names: + command = self._assistant_followup_command(name, keyword=keyword, hash_value=hash_value) + if command and command not in command_candidates: + command_candidates.append(command) + label_map = { + "mp_download": "下载后追踪", + "mp_subscribe": "订阅后追踪", + "cloud_write": "云盘落库追踪", + "mp_diagnosis": "本地/PT 状态追踪", + "ai_reingest": "AI 二次识别重放追踪", + } + summary = { + "category": category, + "stage": self._clean_text(stage), + "label": label_map.get(category, category or "后续追踪"), + "preferred_action": self._clean_text(recommended_action), + "decision_hint": self._clean_text(follow_up_hint), + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": bool(command_candidates), + "preferred_command": command_candidates[0] if command_candidates else "", + "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", + "compact_commands": command_candidates[:2], + "recommended_commands": command_candidates[:3], + "next_actions": action_names[:4], + "action_templates_count": len([item for item in (action_templates or []) if isinstance(item, dict)]), + } + if category == "ai_reingest" and keyword: + summary.update({ + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "recommended_commands": ["诊断", "入库状态", "工作清单"], + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + }) + return summary + + @staticmethod + def _format_followup_summary_lines(summary: Optional[Dict[str, Any]]) -> List[str]: + data = dict(summary or {}) + if not data: + return [] + lines: List[str] = [] + label = AgentResourceOfficer._clean_text(data.get("label")) + stage = AgentResourceOfficer._clean_text(data.get("stage")) + hint = AgentResourceOfficer._clean_text(data.get("decision_hint")) + commands = [AgentResourceOfficer._clean_text(item) for item in (data.get("compact_commands") or data.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] + if label: + lines.append(f"后续追踪:{label}{f' | {stage}' if stage else ''}") + if hint: + lines.append(f"建议:{hint}") + if commands: + lines.append("可直接继续:" + " / ".join(commands)) + return lines + + @staticmethod + def _assistant_local_api_base_urls() -> List[str]: + return [ + "http://127.0.0.1:3001", + "http://127.0.0.1:3000", + "http://host.docker.internal:3000", + ] + + def _assistant_plugin_api_request( + self, + *, + plugin_name: str, + path: str, + query: Optional[Dict[str, Any]] = None, + body: Optional[Dict[str, Any]] = None, + method: str = "GET", + timeout: int = 15, + ) -> Dict[str, Any]: + token = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not token: + return {"success": False, "message": "服务端未配置 API Token"} + safe_path = "/" + self._clean_text(path).lstrip("/") + query_payload = dict(query or {}) + query_payload["apikey"] = token + last_error = "" + payload_bytes: Optional[bytes] = None + headers = {"Accept": "application/json"} + if body is not None: + headers["Content-Type"] = "application/json" + payload_bytes = json.dumps(dict(body or {}), ensure_ascii=False).encode("utf-8") + for base_url in self._assistant_local_api_base_urls(): + try: + url = f"{base_url}/api/v1/plugin/{plugin_name}{safe_path}?{urlencode(query_payload)}" + request = UrlRequest( + url=url, + data=payload_bytes, + headers=headers, + method=(method or "GET").upper(), + ) + with urlopen(request, timeout=max(5, self._safe_int(timeout, 15))) as response: + raw = response.read().decode("utf-8", errors="ignore") + payload = json.loads(raw or "{}") + if isinstance(payload, dict): + return payload + last_error = f"插件 {plugin_name} 返回了非字典 JSON" + except Exception as exc: + last_error = str(exc) + return { + "success": False, + "message": f"调用 {plugin_name}{safe_path} 失败:{last_error or '未知错误'}", + } + + @staticmethod + def _assistant_ai_recognizer_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + return running_plugins.get("AIRecognizerEnhancer") + + def _assistant_ai_failed_samples_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + return { + "success": True, + "data": { + "count": len(samples), + "samples": samples, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/failed_samples", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_worklist_payload(self, *, limit: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_summarize_sample"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) + worklist = [plugin._summarize_sample(sample) for sample in samples] + return { + "success": True, + "data": { + "count": len(worklist), + "samples": worklist, + }, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 工作清单失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_worklist", + query={"limit": max(1, min(limit, 100))}, + method="GET", + ) + + def _assistant_ai_sample_insights_payload(self, *, limit: int, top: int) -> Dict[str, Any]: + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_build_sample_insights"): + try: + samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 200)))) + insights = plugin._build_sample_insights(samples, top=max(1, min(top, 20))) + return { + "success": True, + "data": insights, + } + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 读取 AI 样本洞察失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/sample_insights", + query={"limit": max(1, min(limit, 200)), "top": max(1, min(top, 20))}, + method="GET", + ) + + def _assistant_ai_replay_failed_sample_payload( + self, + *, + sample_index: int, + remove_if_resolved: bool, + ) -> Dict[str, Any]: + body = { + "sample_index": max(0, self._safe_int(sample_index, 0)), + "remove_if_resolved": bool(remove_if_resolved), + } + plugin = self._assistant_ai_recognizer_plugin() + if plugin is not None and hasattr(plugin, "_replay_failed_sample"): + try: + result = plugin._replay_failed_sample(body) + if isinstance(result, dict): + return result + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 重放 AI 失败样本失败,改走 HTTP 兜底:{exc}") + return self._assistant_plugin_api_request( + plugin_name="AIRecognizerEnhancer", + path="/replay_failed_sample", + method="POST", + body=body, + ) + + def _assistant_ai_sample_by_index(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + state = self._load_session(cache_key) or {} + items = state.get("items") if isinstance(state.get("items"), list) else [] + for item in items: + current = item if isinstance(item, dict) else {} + if self._safe_int(current.get("sample_index"), 0) == desired: + return dict(current) + if self._safe_int(current.get("index"), 0) == desired: + return dict(current) + return {} + + @staticmethod + def _assistant_quarkdisk_plugin() -> Any: + if PluginManager is None: + return None + try: + running_plugins = PluginManager().running_plugins or {} + except Exception: + return None + plugin = running_plugins.get("QuarkDisk") or running_plugins.get("quarkdisk") + if plugin is not None: + return plugin + for candidate in running_plugins.values(): + if candidate is None: + continue + if candidate.__class__.__name__ == "QuarkDisk": + return candidate + if getattr(candidate, "plugin_name", "") == "夸克网盘存储": + return candidate + return None + + def _assistant_quarkdisk_clear_directory(self, path: str) -> Optional[Tuple[bool, Dict[str, Any], str]]: + plugin = self._assistant_quarkdisk_plugin() + if plugin is None or app_schemas is None: + return None + try: + storage_name = self._clean_text(getattr(plugin, "_disk_name", "")) or "夸克网盘" + clear_cache = getattr(plugin, "clear_cache", None) + + def _safe_clear_cache() -> None: + if callable(clear_cache): + try: + clear_cache() + except Exception: + pass + + def _resolve_folder() -> Any: + resolved = None + if hasattr(plugin, "get_folder"): + resolved = plugin.get_folder(storage=storage_name, path=Path(path)) + if resolved is None and hasattr(plugin, "get_file_item"): + resolved = plugin.get_file_item(storage=storage_name, path=Path(path)) + return resolved + + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + _safe_clear_cache() + folder = _resolve_folder() + if folder is None: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 未定位到目录,改走直连夸克接口确认" + + items = [] + if hasattr(plugin, "list_files"): + items = plugin.list_files(folder, recursion=False) or [] + if not items: + _safe_clear_cache() + folder = _resolve_folder() or folder + items = plugin.list_files(folder, recursion=False) or [] + files = [item for item in items if getattr(item, "type", "") != "dir"] + folders = [item for item in items if getattr(item, "type", "") == "dir"] + if not items: + return False, { + "target_path": path, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + "fallback_to_direct": True, + }, "QuarkDisk 返回空列表,改走直连夸克接口确认" + + removed_count = 0 + failed_items: List[str] = [] + for item in items: + ok = plugin.delete_file(item) if hasattr(plugin, "delete_file") else None + if ok: + removed_count += 1 + continue + item_path = self._clean_text(getattr(item, "path", "")) or self._clean_text(getattr(item, "name", "")) + if item_path: + failed_items.append(item_path) + result = { + "target_path": path, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self._clean_text(getattr(item, "name", "")) for item in items[:20] if self._clean_text(getattr(item, "name", ""))], + "failed_items": failed_items[:20], + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), + "bridge": "quarkdisk", + } + if failed_items: + return False, result, f"夸克网盘存储删除失败:{len(failed_items)} 项未删除" + return True, result, "success" + except Exception as exc: + logger.warning(f"[AgentResourceOfficer] 调用 quarkdisk 清空目录失败,改走直连夸克接口:{exc}") + return None + + def _assistant_ai_sample_reference(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return {} + cached = self._assistant_ai_sample_by_index(cache_key=cache_key, sample_index=desired) + if cached: + return cached + for payload in ( + self._assistant_ai_failed_samples_payload(limit=1000), + self._assistant_ai_sample_worklist_payload(limit=1000), + ): + rows = [] + if isinstance((payload or {}).get("data"), dict): + rows = (payload.get("data") or {}).get("samples") or [] + if not isinstance(rows, list): + continue + for item in rows: + current = dict(item or {}) + if self._safe_int(current.get("sample_index") or current.get("index"), 0) == desired: + return current + return {} + + def _assistant_ai_replay_target_title(self, *, sample: Dict[str, Any], target: Dict[str, Any]) -> str: + current_sample = dict(sample or {}) + current_target = dict(target or {}) + return ( + self._clean_text(current_target.get("name")) + or self._clean_text(current_sample.get("verified_title")) + or self._clean_text(current_sample.get("guess_name")) + or self._clean_text(current_sample.get("title")) + or self._clean_text(current_sample.get("path")) + ) + + def _assistant_ai_sample_matches(self, sample: Dict[str, Any], keyword: str) -> bool: + needle = self._clean_text(keyword).lower() + if not needle: + return True + target = sample.get("inferred_target") if isinstance(sample.get("inferred_target"), dict) else {} + fields = [ + sample.get("title"), + sample.get("path"), + sample.get("reason"), + sample.get("guess_name"), + sample.get("verified_title"), + target.get("name"), + ] + for value in fields: + text = self._clean_text(value).lower() + if text and needle in text: + return True + return False + + def _assistant_ai_filtered_rows(self, rows: List[Dict[str, Any]], keyword: str) -> List[Dict[str, Any]]: + if not keyword: + return [dict(item or {}) for item in rows] + return [ + dict(item or {}) + for item in rows + if self._assistant_ai_sample_matches(dict(item or {}), keyword) + ] + + def _assistant_ai_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + base_body = { + "session": session, + "session_id": session_id, + "keyword": keyword, + "compact": True, + } + action_order = [ + "query_ai_sample_worklist", + "query_ai_sample_insights", + "query_ai_failed_samples", + "query_mp_local_diagnose", + ] + templates = [ + self._assistant_action_template( + name=name, + description={ + "query_ai_sample_worklist": "查看 AI 识别失败样本工作清单,适合先挑目标", + "query_ai_sample_insights": "查看 AI 失败样本洞察,适合看重复样本和优先处理项", + "query_ai_failed_samples": "查看 AI 原始失败样本列表,适合核对标题、路径和失败原因", + "query_mp_local_diagnose": "回到本地/PT 诊断链,继续看入库失败阶段和证据", + }.get(name, name), + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": name}, + ) + for name in action_order + ] + return action_order, templates + + def _assistant_ai_sample_brief_lines( + self, + rows: List[Dict[str, Any]], + *, + limit: int = 5, + ) -> List[str]: + lines: List[str] = [] + for item in rows[: max(1, min(limit, 10))]: + label = ( + self._clean_text( + ((item.get("inferred_target") or {}).get("name")) + if isinstance(item.get("inferred_target"), dict) + else "" + ) + or self._clean_text(item.get("verified_title")) + or self._clean_text(item.get("guess_name")) + or self._clean_text(item.get("title")) + or "未命名样本" + ) + confidence = round(self._safe_float(item.get("guess_confidence"), 0.0), 2) + reason = self._clean_text(item.get("reason")) or "-" + lines.append( + f"{self._safe_int(item.get('sample_index'), 0)}. {label} | 置信度 {confidence} | {reason}" + ) + return lines + + def _assistant_mp_recent_activity_summary( + self, + *, + download_items: List[Dict[str, Any]], + transfer_items: List[Dict[str, Any]], + ) -> Dict[str, Any]: + evidence: List[str] = [] + for item in download_items[:3]: + evidence.append( + f"下载 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('transfer_status_text')) or '-'}" + ) + for item in transfer_items[:3]: + evidence.append( + f"入库 {self._clean_text(item.get('title')) or '-'} | " + f"{self._clean_text(item.get('date')) or '-'} | " + f"{self._clean_text(item.get('status_text')) or '-'}" + ) + return { + "status": "ok" if (download_items or transfer_items) else "not_found", + "matched": bool(download_items or transfer_items), + "stage": "unknown", + "confidence": 0.8 if (download_items or transfer_items) else 0.95, + "evidence": evidence[:6], + "risk_reasons": [], + "recommended_action": "query_mp_lifecycle_status", + "follow_up_hint": "先从最近活动里挑目标,再继续看单个资源的生命周期或本地诊断。", + } + + async def _assistant_mp_lifecycle_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(10, self._safe_int(limit, 5))) + channel = self._ensure_feishu_channel() + task_result = channel._query_download_tasks( + status="all", + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + download_result = channel._query_download_history( + title=title, + hash_value=hash_value, + limit=safe_limit, + ) + transfer_result = channel._query_transfer_history( + title=title, + status="all", + limit=safe_limit, + ) + task_items = task_result.get("items") if isinstance(task_result.get("items"), list) else [] + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + keyword = title or hash_value or "全部" + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value=hash_value, + task_items=task_items, + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value=hash_value, + preferred=self._clean_text(diagnosis_summary.get("recommended_action")) or "query_mp_download_history", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword=title, + hash_value=hash_value, + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = [f"MP 生命周期追踪:{keyword}"] + lines.append( + f"结论:{diagnosis_summary.get('stage') or 'unknown'} | " + f"置信度 {int(float(diagnosis_summary.get('confidence') or 0) * 100)}%" + ) + lines.append(f"活动下载任务:{len(task_items)} 条;下载历史:{download_result.get('total', len(download_items))} 条;整理历史:{transfer_result.get('total', len(transfer_items))} 条") + if task_items: + lines.append("下载任务:") + for item in task_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} | {item.get('progress') or '-'} | {item.get('state') or '-'} | Hash:{item.get('hash_short') or '-'}") + if download_items: + lines.append("下载历史:") + for item in download_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('date') or '-'} | {item.get('transfer_status_text') or '-'} | Hash:{item.get('download_hash_short') or '-'}") + if transfer_items: + lines.append("整理/入库历史:") + for item in transfer_items[:safe_limit]: + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('status_text') or '-'} | {item.get('date') or '-'}") + if not task_items and not download_items and not transfer_items: + lines.append("未找到相关任务、下载历史或整理历史。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + lines.append("说明:这是只读聚合查询,用于判断资源处于搜索后、下载中、已下载还是已落库阶段。") + self._save_session(cache_key, { + "kind": "assistant_mp_lifecycle_status", + "stage": "lifecycle_status", + "keyword": keyword, + "items": { + "download_tasks": task_items, + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + ok = bool(task_result.get("success")) and bool(download_result.get("success")) and bool(transfer_result.get("success")) + return { + "success": ok, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_lifecycle_status", + "ok": ok, + "source_type": "moviepilot_lifecycle_status", + "title": title, + "hash": hash_value, + "download_tasks": { + "ok": bool(task_result.get("success")), + "total": self._safe_int(task_result.get("total"), len(task_items)), + "items": task_items, + }, + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_ingest_status( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + lines = [ + f"本地/PT 入库状态:{self._clean_text(title or hash_value) or '全部'}", + f"当前阶段:{stage}", + ] + if diagnosis_summary.get("evidence"): + lines.append("证据:") + for item in (diagnosis_summary.get("evidence") or [])[:5]: + lines.append(f"- {item}") + if diagnosis_summary.get("risk_reasons"): + lines.append("风险:") + for item in (diagnosis_summary.get("risk_reasons") or [])[:5]: + lines.append(f"- {item}") + lines.extend(self._format_followup_summary_lines(diagnosis_summary.get("followup_summary"))) + lifecycle_data["action"] = "mp_ingest_status" + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(lines), + "data": lifecycle_data, + } + + async def _assistant_mp_ingest_failures( + self, + *, + session: str, + cache_key: str, + title: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + result = self._ensure_feishu_channel()._query_transfer_history( + title=title, + status="failed", + limit=max(1, min(50, self._safe_int(limit, 10))), + page=max(1, self._safe_int(page, 1)), + ) + items = result.get("items") if isinstance(result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_diagnosis_summary( + keyword=title, + hash_value="", + task_items=[], + download_items=[], + transfer_items=items, + force_failed=bool(items), + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword=title, + hash_value="", + preferred="query_mp_local_diagnose", + ) + lines = [f"本地/PT 失败线索:{self._clean_text(title) or '最近失败'}"] + if items: + for item in items[: min(len(items), 6)]: + lines.append( + f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | " + f"{item.get('status_text') or '-'} | {item.get('date') or '-'}" + ) + else: + lines.append("未找到匹配的整理/入库失败记录。") + self._save_session(cache_key, { + "kind": "assistant_mp_ingest_failures", + "stage": "ingest_failures", + "keyword": title or "failed", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_ingest_failures", + "ok": bool(result.get("success")), + "source_type": "moviepilot_transfer_failures", + "title": title, + "status": "failed", + "items": items, + "total": self._safe_int(result.get("total"), len(items)), + "page": self._safe_int(result.get("page"), page), + "limit": self._safe_int(result.get("limit"), limit), + "diagnosis_summary": diagnosis_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_ai_failed_samples( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_failed_samples_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="工作清单", + ) + lines = [f"AI 失败样本:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 失败样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_failed_samples", + "stage": "ai_failed_samples", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_failed_samples", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先看工作清单或样本洞察,再决定是否进入二次识别重放。", + }), + } + + async def _assistant_ai_sample_worklist( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + safe_limit = max(1, min(50, self._safe_int(limit, 10))) + result = self._assistant_ai_sample_worklist_payload(limit=safe_limit) + rows = [] + if isinstance((result or {}).get("data"), dict): + rows = (result.get("data") or {}).get("samples") or [] + items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + decision_summary = self._assistant_ai_reingest_decision_summary( + items=items, + fallback_command="样本洞察", + ) + lines = [f"AI 工作清单:{self._clean_text(keyword) or '全部'}"] + if items: + lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") + lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) + else: + lines.append("当前没有命中的 AI 工作清单样本。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_worklist", + "stage": "ai_sample_worklist", + "keyword": keyword or "all", + "items": items, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_worklist", + "ok": bool(result.get("success")), + "keyword": keyword, + "items": items, + "count": len(items), + "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_insights", + "follow_up_hint": "先看样本洞察确认哪些失败样本最值得重放或生成识别词。", + }), + } + + async def _assistant_ai_sample_insights( + self, + *, + session: str, + cache_key: str, + keyword: str = "", + limit: int = 20, + top: int = 5, + ) -> Dict[str, Any]: + safe_limit = max(1, min(100, self._safe_int(limit, 20))) + safe_top = max(1, min(10, self._safe_int(top, 5))) + result = self._assistant_ai_sample_insights_payload(limit=safe_limit, top=safe_top) + insights = dict((result.get("data") or {})) if isinstance(result.get("data"), dict) else {} + next_actions, action_templates = self._assistant_ai_followups( + session=session, + session_id=cache_key, + keyword=keyword, + ) + priority_samples = insights.get("priority_samples") if isinstance(insights.get("priority_samples"), list) else [] + decision_summary = self._assistant_ai_reingest_decision_summary( + items=priority_samples, + fallback_command="工作清单", + ) + lines = [f"AI 样本洞察:{self._clean_text(keyword) or '全局'}"] + total_count = self._safe_int(insights.get("total_count"), 0) + if total_count > 0: + lines.append(f"样本总数:{total_count}") + reason_counts = insights.get("reason_counts") if isinstance(insights.get("reason_counts"), list) else [] + if reason_counts: + lines.append("主要失败原因:") + for item in reason_counts[:safe_top]: + lines.append(f"- {self._clean_text(item.get('reason')) or '-'}:{self._safe_int(item.get('count'), 0)}") + repeated_groups = insights.get("repeated_groups") if isinstance(insights.get("repeated_groups"), list) else [] + if repeated_groups: + lines.append("重复出现的样本组:") + for item in repeated_groups[:safe_top]: + lines.append(f"- {self._clean_text(item.get('title')) or '-'}:{self._safe_int(item.get('count'), 0)}") + if priority_samples: + lines.append("优先处理样本:") + lines.extend(self._assistant_ai_sample_brief_lines(priority_samples, limit=safe_top)) + else: + lines.append("当前没有可分析的 AI 失败样本洞察。") + self._save_session(cache_key, { + "kind": "assistant_ai_sample_insights", + "stage": "ai_sample_insights", + "keyword": keyword or "all", + "items": insights, + "target_path": "", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_sample_insights", + "ok": bool(result.get("success")), + "keyword": keyword, + "insights": insights, + "next_actions": next_actions, + "action_templates": action_templates, + "decision_summary": decision_summary, + "recommended_action": "query_ai_sample_worklist", + "follow_up_hint": "先挑优先样本,再确认是否进入 AI 二次识别重放。", + }), + } + + def _assistant_ai_replay_followups( + self, + *, + session: str, + session_id: str, + keyword: str, + ) -> Tuple[List[str], List[Dict[str, Any]]]: + next_actions: List[str] = [ + "query_ai_sample_worklist", + "query_ai_failed_samples", + "query_ai_sample_insights", + ] + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + base_body = { + "session": session, + "session_id": session_id, + "compact": True, + } + templates: List[Dict[str, Any]] = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="继续查看这次二次识别相关的本地/PT 入库诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_local_diagnose", "keyword": keyword, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_mp_ingest_status", "keyword": keyword, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续挑需要重放的失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,核对标题、路径和失败原因", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,判断失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_body, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + return next_actions, templates + + def _assistant_ai_reingest_decision_summary( + self, + *, + items: List[Dict[str, Any]], + fallback_command: str, + ) -> Dict[str, Any]: + fallback = self._clean_text(fallback_command) + first_index = 0 + for item in items or []: + first_index = self._safe_int((item or {}).get("index"), 0) + if first_index > 0: + break + if first_index <= 0: + return { + "decision_mode": "show_detail", + "decision_reason": "当前没有可直接重放的 AI 失败样本。", + "preferred_command": fallback, + "fallback_command": "", + "compact_commands": [fallback] if fallback else [], + "recommended_agent_behavior": "show_only" if fallback else "stop", + "auto_run_command": "", + "confirm_command": "", + "display_command": fallback, + } + replay_command = f"重放 {first_index}" + return { + "decision_mode": "make_plan", + "decision_reason": f"先为样本 #{first_index} 生成二次识别重放计划,再确认是否实际重放。", + "preferred_command": replay_command, + "fallback_command": fallback, + "compact_commands": [item for item in [replay_command, fallback] if item], + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": replay_command, + "confirm_command": "确认", + "display_command": replay_command, + } + + def _assistant_ai_replay_sample_plan_response( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + reference = self._assistant_ai_sample_reference(cache_key=cache_key, sample_index=desired) + if not reference: + return { + "success": False, + "message": "未找到对应的 AI 失败样本。请先发送“工作清单”或“失败样本”查看当前样本编号。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "sample_not_found", + "sample_index": desired, + }), + } + title = self._assistant_ai_replay_target_title( + sample=reference, + target=(reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}), + ) + return self._save_assistant_pick_plan_response( + workflow="ai_replay_failed_sample", + session=session, + session_id=cache_key, + actions=[{ + "name": "replay_ai_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + }], + execute_body={ + "workflow": "ai_replay_failed_sample", + "session": session, + "session_id": cache_key, + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "dry_run": False, + }, + message="AI 二次识别重放计划已生成", + extra_data={ + "sample_index": desired, + "remove_if_resolved": bool(remove_if_resolved), + "source_sample": reference, + "target": reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}, + "keyword": title, + "decision_summary": { + "decision_mode": "execute_now", + "decision_reason": ( + "AI 二次识别重放计划已生成,确认后才会实际重放并尝试重新识别。" + if bool(remove_if_resolved) + else "AI 二次识别重放计划已生成,确认后会实际重放,但保留原失败样本。" + ), + "preferred_command": "确认", + "fallback_command": "工作清单", + "compact_commands": ["确认", "工作清单"], + "command_policy": "confirm_then_resume", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "wait_user_confirmation", + "auto_run_command": "", + "confirm_command": "确认", + "display_command": "确认", + }, + }, + ) + + def _assistant_ai_replay_execution_decision_summary( + self, + *, + ok: bool, + resolved: bool, + has_title: bool, + ) -> Dict[str, Any]: + if ok and resolved and has_title: + return { + "decision_mode": "show_detail", + "decision_reason": "这次二次识别已命中目标,下一步先看本地诊断确认是否已消除失败,再决定是否继续整理。", + "preferred_command": "诊断", + "fallback_command": "入库状态", + "compact_commands": ["诊断", "入库状态"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "诊断", + "confirm_command": "", + "display_command": "诊断", + } + if ok: + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放已完成,但暂未命中目标。先回看工作清单和样本洞察,再决定是否继续重放或补识别词。", + "preferred_command": "工作清单", + "fallback_command": "样本洞察", + "compact_commands": ["工作清单", "样本洞察"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "auto_continue", + "auto_run_command": "工作清单", + "confirm_command": "", + "display_command": "工作清单", + } + return { + "decision_mode": "show_detail", + "decision_reason": "这次重放没有成功执行。先回看工作清单或失败样本,确认当前还能处理哪些样本。", + "preferred_command": "工作清单", + "fallback_command": "失败样本", + "compact_commands": ["工作清单", "失败样本"], + "command_policy": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + "auto_run_command": "", + "confirm_command": "", + "display_command": "工作清单", + } + + async def _assistant_ai_replay_failed_sample( + self, + *, + sample_index: int, + session: str, + cache_key: str, + remove_if_resolved: bool = True, + ) -> Dict[str, Any]: + desired = self._safe_int(sample_index, 0) + if desired <= 0: + return { + "success": False, + "message": "重放样本需要有效编号,例如:重放样本 3。", + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": False, + "error_code": "missing_sample_index", + }), + } + result = self._assistant_ai_replay_failed_sample_payload( + sample_index=desired, + remove_if_resolved=bool(remove_if_resolved), + ) + ok = bool(result.get("success")) + payload = dict(result.get("data") or {}) if isinstance(result.get("data"), dict) else {} + source_sample = dict(payload.get("source_sample") or {}) if isinstance(payload.get("source_sample"), dict) else {} + target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} + title = self._assistant_ai_replay_target_title(sample=source_sample, target=target) + next_actions, action_templates = self._assistant_ai_replay_followups( + session=session, + session_id=cache_key, + keyword=title, + ) + if ok: + self._save_session(cache_key, { + "kind": "assistant_ai_replay", + "stage": "ai_replay_failed_sample", + "keyword": title or f"sample-{desired}", + "items": [source_sample] if source_sample else [], + "target_path": "", + "sample_index": desired, + "replay_result": { + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + }, + }) + lines = [f"AI 二次识别重放:样本 #{desired}"] + if title: + lines.append(f"目标:{title}") + if ok: + lines.append("结果:" + ("已命中目标并完成重放" if bool(payload.get("resolved")) else "已完成重放,但暂未命中目标")) + if payload.get("resolved_by_identifiers"): + lines.append("来源:当前识别词已直接命中目标。") + elif payload.get("resolved_by_recognizer"): + lines.append("来源:识别器重跑后命中了目标。") + if "sample_removed" in payload: + lines.append("样本移除:" + ("已移除" if payload.get("sample_removed") else "未移除")) + else: + lines.append(self._clean_text(result.get("message")) or "重放失败") + resolved = bool(payload.get("resolved")) + recommended_action = "query_mp_local_diagnose" if ok and resolved and title else "query_ai_sample_worklist" + follow_up_hint = ( + "先回到本地诊断或入库状态,确认这次重放是否已经消除失败。" + if ok and resolved and title + else "先看工作清单或失败样本,确认是否还有可重放样本。" + ) + decision_summary = self._assistant_ai_replay_execution_decision_summary( + ok=ok, + resolved=resolved, + has_title=bool(title), + ) + return { + "success": ok, + "message": "\n".join(line for line in lines if line).strip(), + "data": self._assistant_response_data(session=session, data={ + "action": "ai_replay_failed_sample", + "ok": ok, + "sample_index": desired, + "source_sample": source_sample, + "target": target, + "identifier_preview": payload.get("identifier_preview") if isinstance(payload.get("identifier_preview"), dict) else {}, + "recognize_result": payload.get("recognize_result") if isinstance(payload.get("recognize_result"), dict) else {}, + "resolved": bool(payload.get("resolved")), + "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), + "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), + "sample_removed": bool(payload.get("sample_removed")), + "sample_removal_result": payload.get("sample_removal_result") if isinstance(payload.get("sample_removal_result"), dict) else {}, + "recommended_action": recommended_action, + "follow_up_hint": follow_up_hint, + "decision_summary": decision_summary, + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_recent_activity( + self, + *, + session: str, + cache_key: str, + limit: int = 10, + download_only: bool = False, + transfer_only: bool = False, + ) -> Dict[str, Any]: + safe_limit = max(1, min(20, self._safe_int(limit, 10))) + channel = self._ensure_feishu_channel() + download_result = {"success": True, "items": [], "total": 0} + transfer_result = {"success": True, "items": [], "total": 0} + if not transfer_only: + download_result = channel._query_download_history(title="", hash_value="", limit=safe_limit, page=1) + if not download_only: + transfer_result = channel._query_transfer_history(title="", status="all", limit=safe_limit, page=1) + download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] + transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] + diagnosis_summary = self._assistant_mp_recent_activity_summary( + download_items=download_items, + transfer_items=transfer_items, + ) + next_actions, action_templates = self._assistant_mp_diagnosis_followups( + session=session, + session_id=cache_key, + keyword="", + hash_value="", + preferred="query_mp_lifecycle_status", + ) + followup_summary = self._assistant_followup_summary( + category="mp_diagnosis", + stage=self._clean_text(diagnosis_summary.get("stage")), + recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), + follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), + next_actions=next_actions, + action_templates=action_templates, + keyword="", + hash_value="", + ) + diagnosis_summary["followup_summary"] = followup_summary + lines = ["最近本地/PT 活动:"] + if download_items: + lines.append(f"最近下载:{len(download_items)} 条") + if transfer_items: + lines.append(f"最近入库:{len(transfer_items)} 条") + if not download_items and not transfer_items: + lines.append("当前没有可展示的最近下载或入库活动。") + lines.extend(self._format_followup_summary_lines(followup_summary)) + self._save_session(cache_key, { + "kind": "assistant_mp_recent_activity", + "stage": "recent_activity", + "keyword": "recent_activity", + "items": { + "download_history": download_items, + "transfer_history": transfer_items, + }, + "target_path": "", + }) + return { + "success": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recent_activity", + "ok": bool(download_result.get("success")) and bool(transfer_result.get("success")), + "download_history": { + "ok": bool(download_result.get("success")), + "total": self._safe_int(download_result.get("total"), len(download_items)), + "items": download_items, + }, + "transfer_history": { + "ok": bool(transfer_result.get("success")), + "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), + "items": transfer_items, + }, + "diagnosis_summary": diagnosis_summary, + "followup_summary": followup_summary, + "recommended_action": diagnosis_summary.get("recommended_action"), + "follow_up_hint": diagnosis_summary.get("follow_up_hint"), + "next_actions": next_actions, + "action_templates": action_templates, + }), + } + + async def _assistant_mp_local_diagnose( + self, + *, + session: str, + cache_key: str, + title: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + lifecycle = await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=title, + hash_value=hash_value, + limit=limit, + ) + lifecycle_data = dict((lifecycle or {}).get("data") or {}) + diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) + stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" + risk_reasons = diagnosis_summary.get("risk_reasons") or [] + evidence = diagnosis_summary.get("evidence") or [] + ai_worklist = await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=title, + limit=max(5, limit), + ) + ai_data = dict((ai_worklist or {}).get("data") or {}) + ai_items = ai_data.get("items") if isinstance(ai_data.get("items"), list) else [] + if ai_items: + evidence.append(f"AI 失败样本命中 {len(ai_items)} 条") + risk_reasons.append("存在可用于二次识别重放的 AI 失败样本") + message_lines = [ + f"本地诊断:{self._clean_text(title or hash_value) or '全部'}", + f"判断阶段:{stage}", + f"是否命中记录:{'是' if diagnosis_summary.get('matched') else '否'}", + ] + if evidence: + message_lines.append("诊断证据:") + for item in evidence[:6]: + message_lines.append(f"- {item}") + if risk_reasons: + message_lines.append("风险与失败线索:") + for item in risk_reasons[:6]: + message_lines.append(f"- {item}") + if ai_items: + message_lines.append("AI 失败样本:") + for item in self._assistant_ai_sample_brief_lines(ai_items, limit=3): + message_lines.append(f"- {item}") + if diagnosis_summary.get("follow_up_hint"): + message_lines.append(f"建议动作:{diagnosis_summary.get('follow_up_hint')}") + next_actions = [] + for name in lifecycle_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + for name in ai_data.get("next_actions") or []: + text = self._clean_text(name) + if text and text not in next_actions: + next_actions.append(text) + action_templates = [] + for item in (lifecycle_data.get("action_templates") or []) + (ai_data.get("action_templates") or []): + if isinstance(item, dict): + action_templates.append(item) + diagnosis_summary["risk_reasons"] = risk_reasons[:6] + diagnosis_summary["evidence"] = evidence[:6] + lifecycle_data["diagnosis_summary"] = diagnosis_summary + lifecycle_data["action"] = "mp_local_diagnose" + lifecycle_data["ai_sample_worklist"] = { + "ok": bool(ai_data.get("ok")), + "count": self._safe_int(ai_data.get("count"), len(ai_items)), + "items": ai_items[:10], + } + lifecycle_data["next_actions"] = next_actions[:6] + lifecycle_data["action_templates"] = action_templates[:6] + return { + "success": bool(lifecycle.get("success")), + "message": "\n".join(message_lines), + "data": lifecycle_data, + } + + async def _assistant_mp_recommendations( + self, + *, + source: str = "tmdb_trending", + media_type: str = "all", + limit: int = 20, + session: str = "default", + cache_key: str = "", + ) -> Dict[str, Any]: + try: + from app.chain.recommend import RecommendChain + from app.schemas.types import MediaType, media_type_to_agent + except Exception as exc: + return { + "success": False, + "message": f"MP 推荐失败:当前环境缺少推荐依赖 {exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + max_limit = max(1, min(50, self._safe_int(limit, 20))) + source_name = self._clean_text(source) or "tmdb_trending" + media_type_name = self._clean_text(media_type) or "all" + chain = RecommendChain() + try: + def collect_items(raw_results: List[Dict[str, Any]], media_type_filter: str = "") -> List[Dict[str, Any]]: + current_media_type = media_type_filter or media_type_name + collected = [] + for raw_item in (raw_results or [])[:max_limit]: + if not isinstance(raw_item, dict): + continue + item_type = raw_item.get("type") + if current_media_type != "all": + enum_type = MediaType.from_agent(current_media_type) + agent_type = media_type_to_agent(item_type) + expected_types = {current_media_type} + if current_media_type == "movie": + expected_types.add("电影") + elif current_media_type == "tv": + expected_types.update({"电视剧", "剧集"}) + if enum_type and item_type != enum_type and agent_type not in expected_types: + continue + collected.append({ + "index": len(collected) + 1, + "title": raw_item.get("title"), + "year": raw_item.get("year"), + "type": media_type_to_agent(item_type), + "tmdb_id": raw_item.get("tmdb_id"), + "douban_id": raw_item.get("douban_id"), + "vote_average": raw_item.get("vote_average"), + "poster_path": raw_item.get("poster_path"), + "detail_link": raw_item.get("detail_link"), + }) + return collected + + results: List[Dict[str, Any]] = [] + if source_name == "tmdb_trending": + results = await chain.async_tmdb_trending(page=1) + elif source_name == "tmdb_movies": + results = await chain.async_tmdb_movies(page=1) + elif source_name == "tmdb_tvs": + results = await chain.async_tmdb_tvs(page=1) + elif source_name in {"douban_hot", "douban_movie_hot"}: + results = await chain.async_douban_movie_hot(page=1, count=max_limit) + if source_name == "douban_hot" and media_type_name in {"all", "tv"}: + results.extend(await chain.async_douban_tv_hot(page=1, count=max_limit)) + elif source_name == "douban_tv_hot": + results = await chain.async_douban_tv_hot(page=1, count=max_limit) + elif source_name == "douban_movie_showing": + results = await chain.async_douban_movie_showing(page=1, count=max_limit) + elif source_name == "douban_movie_top250": + results = await chain.async_douban_movie_top250(page=1, count=max_limit) + elif source_name == "douban_tv_animation": + results = await chain.async_douban_tv_animation(page=1, count=max_limit) + elif source_name == "bangumi_calendar": + results = await chain.async_bangumi_calendar(page=1, count=max_limit) + else: + return { + "success": False, + "message": f"不支持的推荐来源:{source_name}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + items = collect_items(results) + fallback_source = "" + if not items and source_name != "tmdb_trending": + fallback_source = "tmdb_trending" + fallback_media_type = media_type_name if media_type_name in {"movie", "tv"} else "all" + items = collect_items(await chain.async_tmdb_trending(page=1), fallback_media_type) + display_source = fallback_source or source_name + lines = [f"MP 热门推荐:{display_source},共 {len(items)} 条"] + if fallback_source: + lines.append(f"注:{source_name} 当前暂无结果,已自动回退 {fallback_source}。") + for item in items[:10]: + lines.append(f"{item.get('index')}. {item.get('title') or '-'} ({item.get('year') or '-'}) | {item.get('type') or '-'} | 评分 {item.get('vote_average') or '-'}") + lines.append("下一步:回复“选择 1 决策”进入统一资源决策。") + lines.append("如果已经明确意图,也可以直接发“选择 1 计划”或“选择 1 确认”;也支持直接回复“详情”“计划”“确认”,默认作用于当前榜单首项。") + lines.append("如果想走单源,也可以回复“选择 1”进入 MP 原生搜索,或“选择 1 影巢”“选择 1 盘搜”。") + decision_summary = self._assistant_mp_recommendation_decision_summary() + if cache_key: + self._save_session(cache_key, { + "kind": "assistant_mp_recommend", + "stage": "result", + "source": fallback_source or source_name, + "requested_source": source_name, + "media_type": media_type_name, + "keyword": "", + "items": items, + "target_path": "", + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": True, + "source_type": "moviepilot_recommendation", + "source": fallback_source or source_name, + "requested_source": source_name, + "fallback_source": fallback_source, + "media_type": media_type_name, + "items": items, + "decision_summary": decision_summary, + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "decision_short_command": "决策", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + }), + } + except Exception as exc: + logger.error(f"MP 推荐失败:{source_name} {exc}", exc_info=True) + return { + "success": False, + "message": f"MP 推荐失败:{exc}", + "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), + } + + def _format_mp_recommend_item_detail_text(self, item: Dict[str, Any]) -> str: + title = self._clean_text(item.get("title")) or "-" + year = self._clean_text(item.get("year")) or "-" + media_type = self._clean_text(item.get("type")) or "-" + tmdb_id = self._clean_text(item.get("tmdb_id")) or "-" + douban_id = self._clean_text(item.get("douban_id")) or "-" + vote = self._clean_text(item.get("vote_average")) or "-" + lines = [ + "MP 推荐条目详情", + f"标题:{title}", + f"年份:{year}", + f"类型:{media_type}", + f"评分:{vote}", + f"TMDB:{tmdb_id}", + f"豆瓣:{douban_id}", + "下一步:回复“决策”“计划”“确认”,或“盘搜”“影巢”“原生”。", + ] + return "\n".join(lines) + + def _assistant_mp_recommendation_decision_summary(self) -> Dict[str, Any]: + return { + "decision_mode": "show_detail", + "decision_reason": "推荐列表默认先看当前榜单首项详情,再决定是否生成计划或直接确认执行。", + "decision_hint": "当前推荐会话支持直接对首项继续:详情 / 决策 / 计划 / 确认,也支持切到盘搜 / 影巢 / 原生。", + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "command_policy": "safe_read_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "recommended_agent_behavior": "show_only", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": "盘搜", + "hdhive_short_command": "影巢", + "mp_short_command": "原生", + } + + def _assistant_recommend_handoff_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + current_state = dict(state or {}) + handoff = current_state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return {} + selected_index = self._safe_int(handoff.get("selected_index"), 0) + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + return { + "source": self._clean_text(handoff.get("source")), + "requested_source": self._clean_text(handoff.get("requested_source")), + "media_type": self._clean_text(handoff.get("media_type")), + "selected_index": selected_index if selected_index > 0 else None, + "selected_title": self._clean_text(selected_item.get("title")), + "return_short_command": "回推荐", + "source_short_commands": { + "pansou": "盘搜", + "hdhive": "影巢", + "mp": "原生", + }, + } + + def _assistant_recommend_handoff_state( + self, + *, + source: str, + requested_source: str, + media_type: str, + selected_index: int, + selected_item: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "source": self._clean_text(source), + "requested_source": self._clean_text(requested_source or source), + "media_type": self._clean_text(media_type or "all"), + "selected_index": max(0, self._safe_int(selected_index, 0)), + "return_short_command": "回推荐", + } + if isinstance(selected_item, dict) and selected_item: + payload["selected_item"] = dict(selected_item) + return payload + + async def _assistant_restore_mp_recommendation_handoff( + self, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + limit: int = 20, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendations", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + result = await self._assistant_mp_recommendations( + source=self._clean_text(handoff.get("requested_source") or handoff.get("source") or "tmdb_trending"), + media_type=self._clean_text(handoff.get("media_type") or "all"), + limit=limit, + session=session, + cache_key=cache_key, + ) + if not result.get("success"): + return result + selected_index = max(0, self._safe_int(handoff.get("selected_index"), 0)) + if selected_index > 0: + refreshed_state = self._load_session(cache_key) or {} + items = refreshed_state.get("items") if isinstance(refreshed_state.get("items"), list) else [] + if selected_index <= len(items): + self._save_session(cache_key, { + **refreshed_state, + "selected_index": selected_index, + "selected_item": dict(items[selected_index - 1] or {}), + }) + message = str(result.get("message") or "").strip() + if selected_index > 0: + message = f"{message}\n已返回推荐列表,默认仍指向第 {selected_index} 项。".strip() + result["message"] = message + payload = dict(result.get("data") or {}) + if selected_index > 0: + payload["selected_index"] = selected_index + payload["return_short_command"] = "" + result["data"] = self._assistant_response_data(session=session, data=payload) + return result + + async def _assistant_switch_recommend_handoff_source( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可切换的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法切换源。请先发送:回推荐。", + "data": self._assistant_response_data(session=session, data={ + "action": "switch_recommend_handoff_source", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": self._clean_text(mode), + "keyword": keyword, + "media_type": media_type, + "recommend_handoff": dict(handoff), + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_run_recommend_source_compound( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + mode: str, + followup_action: str, + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + mode = self._clean_text(mode).lower() + followup_action = self._clean_text(followup_action) + kind = self._clean_text(state.get("kind")) + if mode not in {"pansou", "mp"}: + return { + "success": False, + "message": "当前只支持 盘搜/原生 的单条详情、计划、确认命令。", + "data": self._assistant_response_data(session=session, data={ + "action": "recommend_source_compound", + "ok": False, + "error_code": "unsupported_recommend_source_compound_mode", + }), + } + + if kind == "assistant_mp_recommend": + selected_index = max(1, self._safe_int(state.get("selected_index"), 0) or 1) + source_result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": selected_index, + "mode": mode, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if not source_result.get("success"): + return source_result + else: + kind_mode = { + "assistant_pansou": "pansou", + "assistant_mp": "mp", + "assistant_hdhive": "hdhive", + }.get(kind, "") + if kind_mode != mode: + source_result = await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=mode, + ) + if not source_result.get("success"): + return source_result + + return await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": 0, + "action": followup_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_route_recommend_handoff_to_smart_decision( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + ) -> Dict[str, Any]: + handoff = state.get("recommend_handoff") + if not isinstance(handoff, dict) or not handoff: + return { + "success": False, + "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_missing", + }), + } + selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} + keyword = self._clean_text(selected_item.get("title")) + if not keyword: + return { + "success": False, + "message": "当前推荐上下文缺少标题,无法回到统一资源决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "recommend_handoff_title_missing", + }), + } + media_type = self._clean_text(handoff.get("media_type") or "auto") + return await self.api_assistant_route(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "mode": "smart_decision", + "keyword": keyword, + "media_type": media_type, + "origin": "mp_recommend", + "apikey": self._extract_apikey(request, {}), + })) + + async def _assistant_confirm_recommend_handoff( + self, + request, + *, + session: str, + cache_key: str, + state: Dict[str, Any], + compact: bool, + target_path: str, + ) -> Dict[str, Any]: + pending_plan = self._find_workflow_plan( + session=session, + session_id=cache_key, + executed=False, + ) + handoff_state = dict(state or {}) + if pending_plan and not isinstance(handoff_state.get("recommend_handoff"), dict): + pending_handoff = pending_plan.get("recommend_handoff") if isinstance(pending_plan.get("recommend_handoff"), dict) else {} + if pending_handoff: + handoff_state["recommend_handoff"] = dict(pending_handoff) + if pending_plan: + result = await self.api_assistant_plan_execute(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "prefer_unexecuted": True, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + kind = self._clean_text(state.get("kind")) + if kind in {"assistant_pansou", "assistant_mp"}: + result = await self.api_assistant_pick(_JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best_execute", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, {}), + })) + if handoff_state.get("recommend_handoff") and not bool(result.get("success")): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) + result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) + command_summary = self._assistant_compact_command_summary(result_data) + if command_summary: + result_data.update(command_summary) + result["data"] = result_data + return result + return { + "success": False, + "message": "当前会话没有待确认计划。影巢候选阶段请先选择编号;如果想回统一搜索决策,请回复:决策。", + "data": self._assistant_response_data(session=session, data={ + "action": "confirm_recommend_handoff", + "ok": False, + "error_code": "recommend_handoff_pending_plan_missing", + "preferred_command": "决策", + "fallback_command": "回推荐", + }), + } + + def _assistant_recommend_handoff_short_metadata( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + handoff = (state or {}).get("recommend_handoff") if isinstance((state or {}).get("recommend_handoff"), dict) else {} + source_short_commands = handoff.get("source_short_commands") if isinstance(handoff.get("source_short_commands"), dict) else {} + return { + "recommend_handoff": dict(handoff) if handoff else {}, + "return_short_command": self._clean_text(handoff.get("return_short_command") or "回推荐") if handoff else "", + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + "pansou_short_command": self._clean_text(source_short_commands.get("pansou") or "盘搜") if handoff else "", + "hdhive_short_command": self._clean_text(source_short_commands.get("hdhive") or "影巢") if handoff else "", + "mp_short_command": self._clean_text(source_short_commands.get("mp") or "原生") if handoff else "", + } + + def _assistant_recommend_handoff_plan_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "confirm", + "label": "待确认计划", + "decision_hint": "当前推荐条目已生成计划;先看详情,再决定是否确认执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "确认", + "fallback_command": "详情", + "compact_commands": ["确认", "详情"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_entry_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "source_entry", + "label": "已切入单源", + "decision_hint": "当前推荐条目已切入单源结果;先看详情,再决定是否生成计划。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": True, + "preferred_command": "详情", + "fallback_command": "计划", + "compact_commands": ["详情", "计划"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "详情", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_detail_summary( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "stage": "detail", + "label": "已查看详情", + "decision_hint": "当前推荐条目详情已展开;可以先生成计划,确认无误后再执行。", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": True, + "preferred_command": "计划", + "fallback_command": "确认", + "compact_commands": ["计划", "确认"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "计划", + "confirm_command": "确认", + "display_command": "详情", + **meta, + } + + def _assistant_recommend_handoff_execute_failure_followup( + self, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + meta = self._assistant_recommend_handoff_short_metadata(state) + return { + "category": "recommend_handoff", + "stage": "write_failed", + "label": "执行失败,建议换路", + "preferred_action": "return_to_smart_decision", + "decision_hint": "当前推荐条目的执行失败;优先回到统一资源决策,或回推荐后切换其它源。", + "command_policy": "safe_read_recovery", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "preferred_command": "决策", + "fallback_command": meta.get("return_short_command") or "回推荐", + "compact_commands": ["决策", meta.get("return_short_command") or "回推荐"], + "recommended_commands": [ + "决策", + meta.get("return_short_command") or "回推荐", + meta.get("hdhive_short_command") or "影巢", + meta.get("mp_short_command") or "原生", + ], + "recommended_agent_behavior": "show_only", + **meta, + } + + def _persist_workflow_plans(self) -> None: + try: + items = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + )[:self._workflow_plan_limit] + self._workflow_plans = { + self._clean_text(item.get("plan_id")): item + for item in items + if self._clean_text(item.get("plan_id")) + } + self.save_data(key=self._workflow_plan_store_key, value=self._workflow_plans) + except Exception: + pass + + def _restore_workflow_plans(self) -> None: + try: + restored = self.get_data(self._workflow_plan_store_key) or {} + if isinstance(restored, dict): + self._workflow_plans = { + self._clean_text(plan_id): dict(payload) + for plan_id, payload in restored.items() + if self._clean_text(plan_id) and isinstance(payload, dict) + } + except Exception: + self._workflow_plans = {} + + def _save_workflow_plan( + self, + *, + workflow: str, + session: str, + session_id: str = "", + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + ) -> Dict[str, Any]: + plan_id = self._new_session_id("plan") + created_at = int(time.time()) + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plan = { + "plan_id": plan_id, + "workflow": self._clean_text(workflow), + "session": session_name, + "session_id": normalized_session_id, + "actions": [dict(item or {}) for item in (actions or [])], + "execute_body": dict(execute_body or {}), + "created_at": created_at, + "created_at_text": self._format_unix_time(created_at), + "executed_at": 0, + "executed_at_text": "", + "executed": False, + } + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + return dict(plan) + + def _save_assistant_pick_plan_response( + self, + *, + workflow: str, + session: str, + session_id: str, + actions: List[Dict[str, Any]], + execute_body: Dict[str, Any], + message: str, + score_items: Optional[List[Dict[str, Any]]] = None, + extra_data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + plan = self._save_workflow_plan( + workflow=workflow, + session=session, + session_id=session_id, + actions=actions, + execute_body=execute_body, + ) + plan_id = self._clean_text(plan.get("plan_id")) + recommend_handoff = extra_data.get("recommend_handoff") if isinstance((extra_data or {}).get("recommend_handoff"), dict) else {} + if plan_id and recommend_handoff and isinstance(self._workflow_plans.get(plan_id), dict): + self._workflow_plans[plan_id]["recommend_handoff"] = dict(recommend_handoff) + self._persist_workflow_plans() + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": plan_id, + "session": session, + "session_id": session_id, + "prefer_unexecuted": True, + }, + ) + data = { + "action": "workflow_plan", + "ok": True, + "plan_id": plan_id, + "workflow": workflow, + "dry_run": True, + "workflow_actions": [dict(item or {}) for item in actions], + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan_id}, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "next_actions": ["execute_plan"], + "action_templates": [template], + "write_effect": "state", + } + if score_items: + data["score_summary"] = self._score_summary(score_items, limit=1) + if extra_data: + data.update(extra_data) + decision_summary = data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {} + for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: + if key in decision_summary and key not in data: + data[key] = decision_summary.get(key) + command_summary = self._assistant_compact_command_summary(data) + if command_summary: + data.update(command_summary) + choice = self._safe_int(data.get("choice"), 0) + confirm_hint = f"回复“执行计划”或“{choice}”确认执行。" if choice > 0 else "回复“执行计划”确认执行。" + return { + "success": True, + "message": f"{message}:{plan_id}\n未实际执行。{confirm_hint}", + "data": self._assistant_response_data(session=session, data=data), + } + + def _load_workflow_plan(self, plan_id: str) -> Optional[Dict[str, Any]]: + plan = (self._workflow_plans or {}).get(self._clean_text(plan_id)) + return dict(plan) if isinstance(plan, dict) else None + + def _find_workflow_plan( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + ) -> Optional[Dict[str, Any]]: + clean_plan_id = self._clean_text(plan_id) + if clean_plan_id: + return self._load_workflow_plan(clean_plan_id) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + for plan in plans: + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + return dict(plan) + return None + + def _find_pending_multi_plan( + self, + *, + session: str, + session_id: str, + rank: int = 0, + choice: int = 0, + group_id: str = "", + ) -> Optional[Dict[str, Any]]: + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not session_id_filter: + return None + candidates: List[Dict[str, Any]] = [] + for plan in (self._workflow_plans or {}).values(): + if not isinstance(plan, dict) or bool(plan.get("executed")): + continue + if self._clean_text(plan.get("session_id")) != session_id_filter: + continue + execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} + plan_group = self._clean_text(plan.get("multi_plan_group") or execute_body.get("multi_plan_group")) + if group_id and plan_group != group_id: + continue + plan_rank = self._safe_int(plan.get("plan_rank") or execute_body.get("plan_rank"), 0) + plan_choice = self._safe_int(plan.get("plan_choice") or execute_body.get("choice") or execute_body.get("index"), 0) + if rank > 0 and plan_rank != rank: + continue + if choice > 0 and plan_choice != choice and plan_rank != choice: + continue + candidates.append(dict(plan)) + candidates.sort( + key=lambda item: ( + -self._safe_int(item.get("created_at"), 0), + self._safe_int(item.get("plan_rank") or (item.get("execute_body") or {}).get("plan_rank"), 999), + ) + ) + return candidates[0] if candidates else None + + def _workflow_plan_public_item(self, plan: Dict[str, Any], *, include_actions: bool = False) -> Dict[str, Any]: + current = dict(plan or {}) + actions = current.get("actions") or [] + item = { + "plan_id": self._clean_text(current.get("plan_id")), + "workflow": self._clean_text(current.get("workflow")), + "session": self._clean_text(current.get("session")), + "session_id": self._clean_text(current.get("session_id")), + "created_at": self._safe_int(current.get("created_at"), 0), + "created_at_text": self._clean_text(current.get("created_at_text")), + "executed": bool(current.get("executed")), + "executed_at": self._safe_int(current.get("executed_at"), 0), + "executed_at_text": self._clean_text(current.get("executed_at_text")), + "last_success": current.get("last_success"), + "last_message": self._clean_text(current.get("last_message")), + "action_count": len(actions) if isinstance(actions, list) else 0, + } + if include_actions: + item["actions"] = [dict(action or {}) for action in actions] if isinstance(actions, list) else [] + item["execute_body"] = dict(current.get("execute_body") or {}) + return item + + def _session_workflow_plan_public_data(self, *, session: str = "", session_id: str = "") -> Dict[str, Any]: + pending = self._find_workflow_plan(session=session, session_id=session_id, executed=False) + latest = pending or self._find_workflow_plan(session=session, session_id=session_id, executed=None) + if not latest: + return { + "has_plan": False, + "has_pending": False, + "latest": None, + } + return { + "has_plan": True, + "has_pending": bool(pending), + "latest": self._workflow_plan_public_item(latest, include_actions=False), + } + + def _assistant_plans_public_data( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + plans = sorted( + (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), + key=lambda item: self._safe_int(item.get("created_at"), 0), + reverse=True, + ) + items: List[Dict[str, Any]] = [] + matching_total = 0 + for plan in plans: + if session_id_filter and self._clean_text(plan.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(plan.get("executed")) != bool(executed): + continue + matching_total += 1 + if len(items) < max_limit: + items.append(self._workflow_plan_public_item(plan, include_actions=include_actions)) + return { + "total": matching_total, + "total_matching": matching_total, + "total_all": len(self._workflow_plans or {}), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "include_actions": bool(include_actions), + "items": items, + } + + def _format_assistant_plans_text( + self, + *, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + limit: int = 20, + ) -> str: + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 保存计划。" + lines = [f"已保存计划:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "已执行" if item.get("executed") else "待执行" + line = ( + f"{index}. {item.get('plan_id') or '-'} | {status} | " + f"{item.get('workflow') or '-'} | {item.get('session') or '-'} | " + f"{item.get('action_count') or 0}步 | {item.get('created_at_text') or '-'}" + ) + if item.get("last_message"): + line = f"{line} | {item.get('last_message')}" + lines.append(line) + lines.append("下一步:可用 agent_resource_officer_execute_plan 执行 plan_id,或用 agent_resource_officer_plans_clear 清理。") + return "\n".join(lines) + + def _clear_workflow_plans( + self, + *, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + clean_plan_id = self._clean_text(plan_id) + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + if not any([clean_plan_id, session_id_filter, executed is not None, all_plans]): + return { + "ok": False, + "message": "请指定 plan_id、session/session_id、executed 过滤条件,或显式 all_plans=true", + "removed": 0, + "removed_ids": [], + } + removed_ids: List[str] = [] + for current_id, plan in list((self._workflow_plans or {}).items()): + if len(removed_ids) >= max_limit: + break + current = dict(plan or {}) + if clean_plan_id and self._clean_text(current_id) != clean_plan_id: + continue + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + if executed is not None and bool(current.get("executed")) != bool(executed): + continue + removed_ids.append(self._clean_text(current_id)) + for current_id in removed_ids: + self._workflow_plans.pop(current_id, None) + if removed_ids: + self._persist_workflow_plans() + return { + "ok": True, + "message": f"已清理 {len(removed_ids)} 条计划", + "removed": len(removed_ids), + "removed_ids": removed_ids, + "session": session_filter, + "session_id": session_id_filter, + "executed": executed, + "all_plans": bool(all_plans), + } + + def _record_assistant_execution( + self, + *, + action: str, + session: str = "default", + session_id: str = "", + success: bool = False, + message: str = "", + summary: Optional[Dict[str, Any]] = None, + ) -> None: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + entry = { + "id": self._new_session_id("exec"), + "time": int(time.time()), + "time_text": self._format_unix_time(int(time.time())), + "action": self._clean_text(action), + "session": session_name, + "session_id": normalized_session_id, + "success": bool(success), + "message_head": self._assistant_result_message_head(message), + "summary": dict(summary or {}), + } + self._execution_history.append(entry) + self._execution_history = self._execution_history[-self._execution_history_limit:] + self._persist_execution_history() + + def _assistant_history_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + session_filter = "" + session_id_filter = "" + if self._clean_text(session) or self._clean_text(session_id): + session_filter, session_id_filter = self._normalize_assistant_session_ref( + session=session, + session_id=session_id, + ) + items: List[Dict[str, Any]] = [] + for entry in reversed(self._execution_history or []): + current = dict(entry or {}) + if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: + continue + items.append(current) + if len(items) >= max_limit: + break + return { + "total": len(self._execution_history or []), + "limit": max_limit, + "session": session_filter, + "session_id": session_id_filter, + "items": items, + } + + def _format_assistant_history_text( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> str: + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + items = data.get("items") or [] + if not items: + return "当前没有 Agent影视助手 执行历史。" + lines = [f"最近执行历史:{len(items)} 条"] + for index, item in enumerate(items, 1): + status = "成功" if item.get("success") else "失败" + line = f"{index}. {item.get('time_text') or '-'} | {status} | {item.get('action') or '-'} | {item.get('session') or '-'}" + if item.get("message_head"): + line = f"{line} | {item.get('message_head')}" + lines.append(line) + return "\n".join(lines) + + def _is_session_expired(self, payload: Optional[Dict[str, Any]]) -> bool: + session = dict(payload or {}) + updated_at = self._safe_int(session.get("updated_at"), 0) + if updated_at <= 0: + return False + return (int(time.time()) - updated_at) > self._session_retention_seconds + + @staticmethod + def _format_unix_time(value: Any) -> str: + try: + timestamp = int(value) + except Exception: + return "" + if timestamp <= 0: + return "" + try: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) + except Exception: + return "" + + @staticmethod + def _group_resource_preview(items: List[Dict[str, Any]], per_group: Optional[int] = 6) -> List[Dict[str, Any]]: + groups: Dict[str, List[Dict[str, Any]]] = {"115": [], "quark": [], "other": []} + for item in items: + pan = str(item.get("pan_type") or "").lower() + if pan == "115": + key = "115" + elif pan == "quark": + key = "quark" + else: + key = "other" + if per_group is None or len(groups[key]) < per_group: + groups[key].append(item) + ordered = groups["115"] + groups["quark"] + if not ordered: + ordered = groups["other"] + preview: List[Dict[str, Any]] = [] + for index, item in enumerate(ordered, 1): + row = dict(item) + row["pick_index"] = index + preview.append(row) + return preview + + def _assistant_session_id(self, session: str) -> str: + session = self._clean_text(session) or "default" + return f"assistant::{session}" + + def _assistant_session_name_from_id(self, session_id: str) -> str: + clean_session_id = self._clean_text(session_id) + if clean_session_id.startswith("assistant::"): + return clean_session_id.split("assistant::", 1)[1] or "default" + return clean_session_id or "default" + + def _normalize_assistant_session_ref( + self, + *, + session: Any = None, + session_id: Any = None, + fallback: str = "default", + ) -> Tuple[str, str]: + clean_session_id = self._clean_text(session_id) + if clean_session_id: + session_name = self._assistant_session_name_from_id(clean_session_id) + return session_name, self._assistant_session_id(session_name) + session_name = self._clean_text(session) or fallback + return session_name, self._assistant_session_id(session_name) + + def _p115_status_snapshot(self) -> Dict[str, Any]: + health_ok, result, health_message = self._ensure_p115_service().health() + return { + "ready": health_ok, + "message": health_message or result.get("message") or "", + "direct_source": self._clean_text(result.get("direct_source")), + "helper_ready": bool(result.get("helper_ready")), + "client_type": self._p115_client_type, + "default_target_path": self._p115_default_path, + "cookie_mode": self._clean_text((result.get("cookie_state") or {}).get("mode")), + } + + def _format_p115_next_actions(self, status: Optional[Dict[str, Any]] = None) -> str: + current = dict(status or self._p115_status_snapshot()) + final_path = current.get("default_target_path") or self._p115_default_path + if current.get("ready"): + lines = [ + "下一步建议:", + f"1. 直接发:链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 也可以直接贴 115 链接,不写前缀也能识别", + "3. 搜资源可发:影巢搜索 片名", + "4. 外部搜资源可发:盘搜搜索 片名", + "5. 想复查登录状态可发:115状态", + ] + else: + lines = [ + "下一步建议:", + "1. 回复:115登录", + "2. 扫码确认后回复:检查115登录", + "3. 登录完成后可回复:115状态", + f"4. 然后可直接发 115 链接转存到 {final_path}", + "5. 也可以继续发:影巢搜索 片名", + ] + return "\n".join(lines) + + def _format_p115_transfer_failure( + self, + *, + detail: str = "", + target_path: str = "", + title: str = "115 转存失败", + ) -> str: + status = self._p115_status_snapshot() + final_path = target_path or status.get("default_target_path") or self._p115_default_path + clean_detail = self._clean_text(detail) + status_message = self._clean_text(status.get("message")) + lines = [title] + if clean_detail: + lines.append(f"原因:{clean_detail}") + if final_path: + lines.append(f"目标目录:{final_path}") + lines.append(f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}") + if status_message and status_message.lower() not in {"success", "ok"} and status_message != clean_detail: + lines.append(f"状态详情:{status_message}") + if status.get("ready"): + lines.append("建议:先回复 115状态 检查当前会话;如果还是失败,再回复 115登录 重新扫码。") + else: + lines.append("建议:先回复 115登录,扫码成功后再重试当前操作。") + return "\n".join(lines) + + @staticmethod + def _format_p115_resume_hint(title: str = "") -> str: + clean_title = str(title or "").strip() + prefix = f"已记住这次 115 任务({clean_title})。" if clean_title else "已记住这次 115 任务。" + return f"{prefix}\n登录成功后回复:检查115登录,我会自动继续处理。" + + def _save_pending_p115_share( + self, + session_id: str, + *, + share_url: str, + access_code: str = "", + target_path: str = "", + source: str = "", + title: str = "", + last_error: str = "", + ) -> None: + clean_url = self._clean_text(share_url) + if not clean_url: + return + state = self._load_session(session_id) or {} + previous = dict(state.get("pending_p115") or {}) + now = int(time.time()) + state["pending_p115"] = { + "kind": "share_route", + "share_url": clean_url, + "access_code": self._clean_text(access_code), + "target_path": target_path or self._p115_default_path, + "source": self._clean_text(source), + "title": self._clean_text(title), + "created_at": self._safe_int(previous.get("created_at"), now) or now, + "last_attempt_at": now, + "retry_count": max(0, self._safe_int(previous.get("retry_count"), 0)), + "last_error": self._clean_text(last_error) or self._clean_text(previous.get("last_error")), + } + if not state.get("kind"): + state["kind"] = "assistant_p115_pending" + state["stage"] = "pending_login" + self._save_session(session_id, state) + + def _clear_pending_p115_share(self, session_id: str) -> None: + state = self._load_session(session_id) + if not state or "pending_p115" not in state: + return + state.pop("pending_p115", None) + self._save_session(session_id, state) + + def _pending_p115_summary(self, state: Optional[Dict[str, Any]]) -> str: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return "" + title = self._clean_text(pending.get("title")) or "未命名任务" + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + source = self._clean_text(pending.get("source")) or "unknown" + created_at = self._format_unix_time(pending.get("created_at")) + last_attempt_at = self._format_unix_time(pending.get("last_attempt_at")) + retry_count = max(0, self._safe_int(pending.get("retry_count"), 0)) + last_error = self._clean_text(pending.get("last_error")) + lines = [ + "待继续的 115 任务:", + f"资源:{title}", + f"目录:{target_path}", + f"来源:{source}", + ] + if created_at: + lines.append(f"首次记录:{created_at}") + if last_attempt_at: + lines.append(f"最近尝试:{last_attempt_at}") + if retry_count: + lines.append(f"重试次数:{retry_count}") + if last_error: + lines.append(f"最近错误:{last_error}") + lines.append("可用命令:继续115任务 / 取消115任务") + return "\n".join(lines) + + def _pending_p115_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + pending = dict((state or {}).get("pending_p115") or {}) + if not self._clean_text(pending.get("share_url")): + return {"has_pending": False} + return { + "has_pending": True, + "title": self._clean_text(pending.get("title")) or "未命名任务", + "target_path": self._clean_text(pending.get("target_path")) or self._p115_default_path, + "source": self._clean_text(pending.get("source")) or "unknown", + "created_at": self._safe_int(pending.get("created_at"), 0), + "created_at_text": self._format_unix_time(pending.get("created_at")), + "last_attempt_at": self._safe_int(pending.get("last_attempt_at"), 0), + "last_attempt_at_text": self._format_unix_time(pending.get("last_attempt_at")), + "retry_count": max(0, self._safe_int(pending.get("retry_count"), 0)), + "last_error": self._clean_text(pending.get("last_error")), + } + + @staticmethod + def _assistant_find_action_template( + templates: Optional[List[Dict[str, Any]]], + names: List[str], + ) -> Optional[Dict[str, Any]]: + rows = [dict(item or {}) for item in (templates or []) if isinstance(item, dict)] + for current_name in names: + for item in rows: + if str(item.get("name") or "").strip() == current_name: + return item + return None + + def _assistant_recovery_public_data( + self, + *, + session_state: Optional[Dict[str, Any]] = None, + action_templates: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: + state = dict(session_state or {}) + templates = [dict(item or {}) for item in (action_templates or state.get("action_templates") or []) if isinstance(item, dict)] + saved_plan = dict(state.get("saved_plan") or {}) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id") or saved_plan.get("plan_id")) + latest_plan_executed = bool(latest_plan.get("executed")) + pending_p115 = dict(state.get("pending_p115") or {}) + has_session = bool(state.get("has_session")) + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + session_name = self._clean_text(state.get("session")) or self._assistant_session_name_from_id(self._clean_text(state.get("session_id"))) + session_id = self._clean_text(state.get("session_id")) + mode = "" + reason = "" + template: Optional[Dict[str, Any]] = None + + if saved_plan.get("has_pending"): + mode = "resume_saved_plan" + reason = "当前会话存在待执行计划" + template = self._assistant_find_action_template(templates, [ + "execute_latest_plan", + "execute_plan", + "execute_session_latest_plan", + ]) + elif latest_plan_executed and latest_plan_id: + mode = "followup_executed_plan" + reason = "当前会话最近一条计划已执行,建议先做统一后续追踪" + template = self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动追踪下载、订阅或入库后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **({"session": session_name} if session_name else {}), + **({"session_id": session_id} if session_id else {}), + "plan_id": latest_plan_id, + }, + ) + elif pending_p115.get("has_pending"): + mode = "resume_pending_115" + reason = "当前会话存在待继续的 115 任务" + template = self._assistant_find_action_template(templates, [ + "resume_pending_115", + "check_115_login", + ]) + elif has_session and kind == "assistant_pansou": + mode = "continue_pansou" + reason = "当前会话停留在盘搜结果列表" + template = self._assistant_find_action_template(templates, ["pick_pansou_result"]) + elif has_session and kind == "assistant_mp_candidate": + mode = "continue_mp_candidate" + reason = "当前会话停留在 MP 媒体候选列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp": + mode = "continue_mp_search" + reason = "当前会话停留在 MP 原生搜索结果列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_best_result_detail", + "query_mp_search_result_detail", + "pick_mp_download", + ]) + elif has_session and kind == "assistant_mp_recommend": + mode = "continue_mp_recommend" + reason = "当前会话停留在 MP 热门推荐列表" + template = self._assistant_find_action_template(templates, [ + "pick_recommend_smart_decision", + "pick_recommend_smart_plan", + "pick_recommend_mp_search", + ]) + elif has_session and kind == "assistant_mp_download_tasks": + mode = "continue_mp_download_tasks" + reason = "当前会话停留在 MP 下载任务列表" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "pause_mp_download", + "resume_mp_download", + "delete_mp_download", + ]) + elif has_session and kind == "assistant_mp_download_history": + mode = "continue_mp_download_history" + reason = "当前会话停留在 MP 下载历史" + template = self._assistant_find_action_template(templates, [ + "query_mp_lifecycle_status", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_downloaders": + mode = "continue_mp_downloaders" + reason = "当前会话停留在 MP 下载器状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_sites", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_sites": + mode = "continue_mp_sites" + reason = "当前会话停留在 MP 站点状态" + template = self._assistant_find_action_template(templates, [ + "query_mp_downloaders", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_subscribes": + mode = "continue_mp_subscribes" + reason = "当前会话停留在 MP 订阅列表" + template = self._assistant_find_action_template(templates, [ + "start_mp_subscribe", + "search_mp_subscribe", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_mp_lifecycle_status": + mode = "continue_mp_lifecycle_status" + reason = "当前会话停留在 MP 生命周期追踪" + template = self._assistant_find_action_template(templates, [ + "query_mp_download_history", + "start_mp_media_search", + ]) + elif has_session and kind == "assistant_hdhive" and stage == "candidate": + mode = "continue_hdhive_candidate" + reason = "当前会话停留在影巢候选列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_candidate"]) + elif has_session and kind == "assistant_hdhive" and stage == "resource": + mode = "continue_hdhive_resource" + reason = "当前会话停留在影巢资源列表" + template = self._assistant_find_action_template(templates, ["pick_hdhive_resource"]) + elif has_session and kind == "assistant_p115_login": + mode = "continue_115_login" + reason = "当前会话停留在 115 登录检查阶段" + template = self._assistant_find_action_template(templates, ["check_115_login", "show_115_status"]) + elif self._assistant_find_action_template(templates, ["preferences_save"]): + mode = "onboard_preferences" + reason = "智能体片源偏好未初始化,建议先询问并保存用户偏好" + template = self._assistant_find_action_template(templates, ["preferences_save"]) + else: + mode = "start_new" + reason = "当前没有待恢复的执行状态,可直接开始新任务" + template = self._assistant_find_action_template(templates, [ + "start_pansou_search", + "start_hdhive_search", + "start_115_login", + ]) + + can_resume = mode != "start_new" and bool(template) + return { + "mode": mode, + "reason": reason, + "can_resume": can_resume, + "recommended_action": self._clean_text((template or {}).get("name")), + "recommended_tool": self._clean_text((template or {}).get("tool")), + "action_template": template or None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in templates[:6] + if self._clean_text(item.get("name")) + ], + } + + def _assistant_session_public_data(self, session: str = "default") -> Dict[str, Any]: + session_name = self._clean_text(session) or "default" + session_id = self._assistant_session_id(session_name) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + state = self._load_session(session_id) or {} + if not state: + payload = { + "has_session": False, + "session": session_name, + "session_id": session_id, + "saved_plan": saved_plan, + "suggested_actions": ["execute_plan.session", "smart_entry"] if saved_plan.get("has_pending") else ["smart_entry"], + } + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + kind = self._clean_text(state.get("kind")) + stage = self._clean_text(state.get("stage")) + target_path = self._clean_text(state.get("target_path")) + payload: Dict[str, Any] = { + "has_session": True, + "session": session_name, + "session_id": session_id, + "kind": kind, + "stage": stage, + "updated_at": self._safe_int(state.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(state.get("updated_at")), + "target_path": target_path, + "keyword": self._clean_text(state.get("keyword")), + "media_type": self._clean_text(state.get("media_type")), + "year": self._clean_text(state.get("year")), + "pending_p115": self._pending_p115_public_data(state), + "saved_plan": saved_plan, + "suggested_actions": [], + } + + if kind == "assistant_pansou": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "channel": self._clean_text(item.get("channel")), + "title": self._clean_text(item.get("note")), + "source": self._clean_text(item.get("source")), + } + for idx, item in enumerate(items[:6]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["smart_pick.choice", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=影巢", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "recommend_handoff": self._assistant_recommend_handoff_public_data(state), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(((item.get("torrent_info") or {}).get("title")) or item.get("title")), + "site": self._clean_text((item.get("torrent_info") or {}).get("site_name")), + "seeders": (item.get("torrent_info") or {}).get("seeders"), + "volume_factor": self._clean_text((item.get("torrent_info") or {}).get("volume_factor")), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(items, limit=5), + "suggested_actions": ["mp_download.choice", "mp_subscribe.keyword", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=详情", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=盘搜", + "smart_entry.text=影巢", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + payload.update({ + "stage": "candidate", + "candidate_count": len(candidates), + "page": self._safe_int(state.get("page"), 1), + "page_size": self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size), + "candidates_preview": [ + { + "index": idx + 1, + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type") or item.get("type")), + "tmdb_id": item.get("tmdb_id"), + } + for idx, item in enumerate(candidates[:10]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + elif kind == "assistant_mp_download_tasks": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "hash_short": self._clean_text(item.get("hash_short")), + "downloader": self._clean_text(item.get("downloader")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_download_control.pause", "mp_download_control.resume", "mp_download_control.delete", "session_clear"] + if items else + ["mp_media_search", "mp_download_history", "session_clear"] + ), + }) + elif kind == "assistant_mp_download_history": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + "download_hash_short": self._clean_text(item.get("download_hash_short")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_lifecycle_status", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_downloaders": + items = state.get("items") or [] + payload.update({ + "enabled_count": self._safe_int(state.get("enabled_count"), 0), + "result_count": len(items), + "items_preview": [ + { + "name": self._clean_text(item.get("name")), + "type": self._clean_text(item.get("type")), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + } + for item in items[:8] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_sites", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_sites": + items = state.get("items") or [] + payload.update({ + "status": self._clean_text(state.get("status")), + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "name": self._clean_text(item.get("name")), + "domain": self._clean_text(item.get("domain")), + "enabled": bool(item.get("enabled")), + "has_cookie": bool(item.get("has_cookie")), + "priority": item.get("priority"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["mp_downloaders", "mp_media_search", "session_clear"], + }) + elif kind == "assistant_mp_subscribes": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "id": self._safe_int(item.get("id"), 0), + "title": self._clean_text(item.get("name")), + "year": self._clean_text(item.get("year")), + "state": self._clean_text(item.get("state")), + "lack_episode": item.get("lack_episode"), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ( + ["mp_subscribe_control.search", "mp_subscribe_control.pause", "mp_subscribe_control.resume", "mp_subscribe_control.delete", "session_clear"] + if items else + ["mp_subscribe.keyword", "mp_media_search", "session_clear"] + ), + }) + elif kind == "assistant_mp_lifecycle_status": + result_groups = state.get("items") if isinstance(state.get("items"), dict) else {} + task_items = result_groups.get("download_tasks") if isinstance(result_groups.get("download_tasks"), list) else [] + download_items = result_groups.get("download_history") if isinstance(result_groups.get("download_history"), list) else [] + transfer_items = result_groups.get("transfer_history") if isinstance(result_groups.get("transfer_history"), list) else [] + payload.update({ + "download_task_count": len(task_items), + "download_history_count": len(download_items), + "transfer_history_count": len(transfer_items), + "items_preview": [ + { + "kind": "download_task", + "title": self._clean_text(item.get("title")), + "progress": self._clean_text(item.get("progress")), + "state": self._clean_text(item.get("state")), + } + for item in task_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "download_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "transfer_status_text": self._clean_text(item.get("transfer_status_text")), + } + for item in download_items[:3] + if isinstance(item, dict) + ] + [ + { + "kind": "transfer_history", + "title": self._clean_text(item.get("title")), + "date": self._clean_text(item.get("date")), + "status_text": self._clean_text(item.get("status_text")), + } + for item in transfer_items[:3] + if isinstance(item, dict) + ], + "suggested_actions": ["mp_media_search", "mp_download_history", "session_clear"], + }) + elif kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + payload.update({ + "source": self._clean_text(state.get("source")), + "result_count": len(items), + "selected_index": selected_index if selected_index > 0 else None, + "items_preview": [ + { + "index": self._safe_int(item.get("index"), idx + 1), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "type": self._clean_text(item.get("type")), + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "douban_id": self._clean_text(item.get("douban_id")), + "vote_average": item.get("vote_average"), + } + for idx, item in enumerate(items[:10]) + if isinstance(item, dict) + ], + "suggested_actions": [ + "smart_pick.choice", + "smart_entry.text=电影", + "smart_entry.text=电视剧", + "smart_entry.text=豆瓣", + "smart_entry.text=热映", + "smart_entry.text=番剧", + "smart_pick.choice mode=hdhive", + "smart_pick.choice mode=pansou", + "session_clear", + ], + }) + if selected_index > 0: + payload["selected_item"] = dict(state.get("selected_item") or {}) + payload["suggested_actions"] = [ + *list(payload.get("suggested_actions") or []), + "smart_entry.text=详情", + "smart_entry.text=决策", + "smart_entry.text=计划", + "smart_entry.text=确认", + "smart_entry.text=原生", + "smart_entry.text=影巢", + "smart_entry.text=盘搜", + ] + elif kind == "assistant_update_check": + items = state.get("items") or [] + payload.update({ + "result_count": len(items), + "items_preview": [ + { + "index": self._safe_int(item.get("_index") or item.get("index"), idx + 1), + "source_type": self._clean_text(item.get("_update_source")), + "title": self._clean_text(item.get("note") or item.get("title") or item.get("remark")), + "provider": self._clean_text(item.get("channel") or item.get("pan_type")), + } + for idx, item in enumerate(items[:8]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=计划", "session_clear"], + }) + elif kind == "assistant_hdhive": + payload["recommend_handoff"] = self._assistant_recommend_handoff_public_data(state) + if stage == "candidate": + candidates = state.get("candidates") or [] + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "page": current_page, + "page_size": page_size, + "total_candidates": len(candidates), + "total_pages": total_pages, + "candidates_preview": [ + { + "index": start + idx + 1, + "tmdb_id": self._clean_text(item.get("tmdb_id")), + "title": self._clean_text(item.get("title")), + "year": self._clean_text(item.get("year")), + "media_type": self._clean_text(item.get("media_type")), + "actors": item.get("actors") or [], + } + for idx, item in enumerate(candidates[start:end]) + if isinstance(item, dict) + ], + "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif stage == "resource": + resources = state.get("resources") or [] + selected_candidate = dict(state.get("selected_candidate") or {}) + current_page = max(1, self._safe_int(state.get("page"), 1)) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + start = (current_page - 1) * page_size + end = start + page_size + payload.update({ + "selected_candidate": { + "tmdb_id": self._clean_text(selected_candidate.get("tmdb_id")), + "title": self._clean_text(selected_candidate.get("title")), + "year": self._clean_text(selected_candidate.get("year")), + "media_type": self._clean_text(selected_candidate.get("media_type")), + "actors": selected_candidate.get("actors") or [], + }, + "page": current_page, + "page_size": page_size, + "total_resources": len(resources), + "total_pages": total_pages, + "resource_count_115": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "115"]), + "resource_count_quark": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "quark"]), + "resources_preview": [ + { + "index": self._safe_int(item.get("pick_index"), idx + 1), + "provider": self._clean_text(item.get("pan_type")), + "title": self._clean_text(item.get("title") or item.get("matched_title")), + "points": item.get("unlock_points"), + "points_text": self._resource_points_text(item), + "quality": self._clean_text(self._list_text(item.get("video_resolution")) or item.get("quality")), + "source": self._clean_text(self._list_text(item.get("source"))), + "size": self._clean_text(item.get("share_size") or item.get("size")), + "episodes": self._resource_episode_text(item), + "subtitle": self._resource_subtitle_text(item), + "remark": self._resource_remark_text(item), + "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, + "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", + "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", + "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], + } + for idx, item in enumerate(resources[start:end], start=start + 1) + if isinstance(item, dict) + ], + "score_summary": self._score_summary(resources, limit=5), + "suggested_actions": ["smart_pick.choice", "smart_pick.action=下一页", "session_clear"], + }) + if payload.get("recommend_handoff"): + payload["suggested_actions"] = [ + "smart_entry.text=回推荐", + "smart_entry.text=决策", + "smart_entry.text=盘搜", + "smart_entry.text=原生", + *list(payload.get("suggested_actions") or []), + ] + elif kind == "assistant_p115_login": + payload.update({ + "client_type": self._clean_text(state.get("client_type")) or self._p115_client_type, + "has_qrcode_session": bool(self._clean_text(state.get("uid")) and self._clean_text(state.get("time")) and self._clean_text(state.get("sign"))), + "suggested_actions": ["smart_entry.text=检查115登录", "p115_status", "session_clear"], + }) + else: + payload["suggested_actions"] = ["smart_entry", "session_clear"] + if saved_plan.get("has_pending"): + payload["suggested_actions"] = ["execute_plan.session", *list(payload.get("suggested_actions") or [])] + payload["action_templates"] = self._assistant_action_templates(payload) + payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) + return payload + + def _assistant_session_brief_public_data(self, session_id: str, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: + payload = dict(state or {}) + name = str(session_id or "") + session_name = name.split("assistant::", 1)[1] if name.startswith("assistant::") else name or "default" + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=name) + result: Dict[str, Any] = { + "session": session_name, + "session_id": name or self._assistant_session_id(session_name), + "kind": self._clean_text(payload.get("kind")), + "stage": self._clean_text(payload.get("stage")), + "updated_at": self._safe_int(payload.get("updated_at"), 0), + "updated_at_text": self._format_unix_time(payload.get("updated_at")), + "keyword": self._clean_text(payload.get("keyword")), + "target_path": self._clean_text(payload.get("target_path")), + "has_pending_p115": bool(self._clean_text(((payload.get("pending_p115") or {}).get("share_url")))), + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": saved_plan.get("latest"), + } + if result["kind"] == "assistant_pansou": + result["result_count"] = len(payload.get("items") or []) + elif result["kind"] == "assistant_mp_recommend": + result["result_count"] = len(payload.get("items") or []) + result["source"] = self._clean_text(payload.get("source")) + elif result["kind"] == "assistant_hdhive": + if result["stage"] == "candidate": + candidates = payload.get("candidates") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 + result["total_candidates"] = len(candidates) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["stage"] == "resource": + selected = dict(payload.get("selected_candidate") or {}) + resources = payload.get("resources") or [] + page_size = max(1, self._safe_int(payload.get("page_size"), 10)) + current_page = max(1, self._safe_int(payload.get("page"), 1)) + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + result["selected_title"] = self._clean_text(selected.get("title")) + result["selected_year"] = self._clean_text(selected.get("year")) + result["total_resources"] = len(resources) + result["page"] = current_page + result["total_pages"] = total_pages + elif result["kind"] == "assistant_p115_login": + result["client_type"] = self._clean_text(payload.get("client_type")) or self._p115_client_type + result["recovery"] = self._assistant_recovery_public_data( + session_state={ + **result, + "pending_p115": self._pending_p115_public_data(payload), + "saved_plan": saved_plan, + }, + action_templates=[ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": result.get("session_id"), "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": result.get("session_id")}, + ), + ], + ) + return result + + def _assistant_plan_only_session_brief_public_data(self, session_id: str) -> Dict[str, Any]: + session_name = self._assistant_session_name_from_id(session_id) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) + latest = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest.get("plan_id")) + latest_executed = bool(latest.get("executed")) + return { + "session": session_name, + "session_id": self._assistant_session_id(session_name), + "kind": "assistant_workflow_plan", + "stage": "planned", + "updated_at": self._safe_int(latest.get("created_at"), 0), + "updated_at_text": self._clean_text(latest.get("created_at_text")), + "keyword": "", + "target_path": "", + "has_pending_p115": False, + "has_saved_plan": bool(saved_plan.get("has_plan")), + "has_pending_plan": bool(saved_plan.get("has_pending")), + "saved_plan": latest or None, + "recovery": { + "mode": "resume_saved_plan" if saved_plan.get("has_pending") else "followup_executed_plan" if latest_executed and latest_plan_id else "inspect_plan_only_session", + "reason": "当前会话只有 dry_run 计划,尚未生成交互会话缓存", + "can_resume": bool(saved_plan.get("has_pending") or (latest_executed and latest_plan_id)), + "recommended_action": "execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + "recommended_tool": "agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + "action_template": self._assistant_action_template( + name="execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", + description="按 session_id 执行该会话最近一条待执行计划" if saved_plan.get("has_pending") else "按最近已执行计划自动追踪下载、订阅或入库后续状态" if latest_executed and latest_plan_id else "查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute" if saved_plan.get("has_pending") else "/api/v1/plugin/AgentResourceOfficer/assistant/action" if latest_executed and latest_plan_id else "/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True} if saved_plan.get("has_pending") else {"session_id": self._assistant_session_id(session_name), "name": "query_execution_followup", "plan_id": latest_plan_id} if latest_executed and latest_plan_id else {"session_id": self._assistant_session_id(session_name)}, + ), + "alternatives": ["execute_session_latest_plan", "inspect_session"] if saved_plan.get("has_pending") else ["query_execution_followup", "inspect_session"] if latest_executed and latest_plan_id else ["inspect_session"], + }, + } + + @staticmethod + def _assistant_action_template( + *, + name: str, + description: str, + endpoint: str, + body: Dict[str, Any], + method: str = "POST", + tool: str = "", + ) -> Dict[str, Any]: + body_payload = dict(body or {}) + compact_paths = [ + "/assistant/action", + "/assistant/actions", + "/assistant/workflow", + "/assistant/plan/execute", + "/assistant/recover", + "/assistant/route", + "/assistant/pick", + "/assistant/session", + "/assistant/sessions", + "/assistant/history", + "/assistant/plans", + "/assistant/readiness", + "/assistant/capabilities", + ] + if "compact" not in body_payload and any(path in endpoint for path in compact_paths): + body_payload["compact"] = True + action_body: Dict[str, Any] = {"name": name} + for key in [ + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "status", + "hash", + "target", + "control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "top", + "page", + "plan_id", + "prefer_unexecuted", + "preferences", + "compact", + "mode", + "source", + ]: + if key in body_payload: + action_body[key] = body_payload.get(key) + return { + "name": name, + "description": description, + "endpoint": endpoint, + "method": method, + "tool": tool, + "body": body_payload, + "action_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "action_tool": "agent_resource_officer_execute_action", + "action_body": action_body, + } + + @staticmethod + def _assistant_compact_action_templates( + primary: Optional[Dict[str, Any]] = None, + templates: Optional[List[Dict[str, Any]]] = None, + *, + limit: int = 6, + ) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + seen: set[str] = set() + for item in [primary, *(templates or [])]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(dict(item)) + if len(result) >= max(1, limit): + break + return result + + @staticmethod + def _assistant_compact_next_actions( + primary: Optional[List[Any]] = None, + secondary: Optional[List[Any]] = None, + *, + limit: int = 6, + ) -> List[str]: + result: List[str] = [] + seen: set[str] = set() + for item in [*(primary or []), *(secondary or [])]: + name = str(item or "").strip() + if not name or name in seen: + continue + seen.add(name) + result.append(name) + if len(result) >= max(1, limit): + break + return result + + def _assistant_action_templates(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: + session_name = self._clean_text(data.get("session")) or "default" + session_id = self._clean_text(data.get("session_id")) or self._assistant_session_id(session_name) + base_route = { + "session": session_name, + "session_id": session_id, + } + base_pick = { + "session": session_name, + "session_id": session_id, + } + base_state = { + "session": session_name, + "session_id": session_id, + } + templates: List[Dict[str, Any]] = [] + preference_status = self._assistant_preferences_status_brief(session=session_name) + preference_template = self._assistant_action_template( + name="preferences_save", + description="保存智能体片源偏好;首次接入建议先询问用户后再保存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + tool="agent_resource_officer_preferences", + body={**base_state, "preferences": self._assistant_default_preferences_template()}, + ) + + if not data.get("has_session"): + templates = [] + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + saved_plan = dict(data.get("saved_plan") or {}) + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + templates.extend([ + self._assistant_action_template( + name="start_pansou_search", + description="发起新的盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "pansou", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="start_hdhive_search", + description="发起新的影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "hdhive", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + self._assistant_action_template( + name="query_mp_media_detail", + description="使用 MoviePilot 原生识别确认媒体信息和 TMDB/Douban/IMDB ID", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_media_detail", "keyword": "<关键词>", "media_type": "auto"}, + ), + self._assistant_action_template( + name="start_mp_recommendations", + description="查看 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "start_mp_recommendations", "source": "tmdb_trending", "media_type": "all"}, + ), + self._assistant_action_template( + name="query_mp_download_tasks", + description="查看 MP 下载任务状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_tasks", "status": "downloading"}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看 MP 下载历史,并关联整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="聚合查看 MP 下载任务、下载历史和整理/入库历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_lifecycle_status", "keyword": "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="query_mp_sites", + description="查看 MP 站点启用状态和 Cookie 是否存在,不返回 Cookie 明文", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看 MP 订阅列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_subscribes", "status": "all", "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看 MP 最近整理/入库历史,用于判断下载后是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_route, "name": "query_mp_transfer_history", "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="start_115_login", + description="发起新的 115 扫码登录", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_start"}, + ), + ]) + return templates + + kind = self._clean_text(data.get("kind")) + stage = self._clean_text(data.get("stage")) + pending = dict(data.get("pending_p115") or {}) + saved_plan = dict(data.get("saved_plan") or {}) + target_path = self._clean_text(data.get("target_path")) + + if saved_plan.get("has_pending"): + templates.append( + self._assistant_action_template( + name="execute_latest_plan", + description="执行当前会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={**base_state, "prefer_unexecuted": True}, + ) + ) + if preference_status.get("needs_onboarding"): + templates.append(preference_template) + + templates.append( + self._assistant_action_template( + name="inspect_session_state", + description="重新获取当前会话详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body=base_state, + ) + ) + + if kind == "assistant_pansou": + templates.extend([ + self._assistant_action_template( + name="pick_pansou_result", + description="按编号选择盘搜结果继续转存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._p115_default_path}, + ), + self._assistant_action_template( + name="plan_pansou_result", + description="按编号生成盘搜转存计划,不立即写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._p115_default_path}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前盘搜结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_pansou_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + self._assistant_action_template( + name="handoff_pansou_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_detail", + description="查看当前盘搜首选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_pansou_best_plan", + description="为当前盘搜首选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_pansou_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前盘搜首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_pansou_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_mp": + templates.extend([ + self._assistant_action_template( + name="query_mp_best_result_detail", + description="查看当前 MP 搜索结果里评分最高的 PT 候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_best_result_detail"}, + ), + self._assistant_action_template( + name="pick_mp_best_download", + description="按当前评分最高的 MP 搜索结果生成下载计划;不会静默下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_best_download"}, + ), + self._assistant_action_template( + name="query_mp_search_result_detail", + description="按编号查看 MP 原生搜索结果详情和 PT 评分理由", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_search_result_detail", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="pick_mp_download", + description="按编号为 MP 原生搜索结果生成下载计划;不会立即下载", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "pick_mp_download", "choice": "<1-N>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe", + description="按当前关键词生成 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_subscribe_search", + description="按当前关键词生成“订阅并搜索”计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe_search", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前原生搜索结果对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_mp_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_mp_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="handoff_mp_best_detail", + description="查看当前 MP 搜索里评分最高的候选详情", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "详情"}, + ), + self._assistant_action_template( + name="handoff_mp_best_plan", + description="为当前 MP 搜索里评分最高的候选生成待确认计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "计划"}, + ), + self._assistant_action_template( + name="handoff_mp_confirm", + description="优先执行当前待确认计划;如无待计划则直接执行当前 MP 首选", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "确认"}, + ), + self._assistant_action_template( + name="switch_mp_to_hdhive", + description="基于当前推荐条目切到影巢结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "影巢"}, + ), + ]) + elif kind == "assistant_mp_download_tasks": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="pause_mp_download", + description="按编号暂停下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_download", + description="按编号恢复下载任务;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_download", + description="按编号删除下载任务;默认不删除文件", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_download_control", "control": "delete", "target": "<1-N>", "delete_files": False}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="当前没有下载中任务,改查下载历史和整理状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_download_history": + templates.extend([ + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": data.get("keyword") or "<关键词>", "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_downloaders": + templates.extend([ + self._assistant_action_template( + name="query_mp_sites", + description="查看 PT 站点启用状态和 Cookie 是否存在", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_sites", "status": "active", "limit": 30}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_sites": + templates.extend([ + self._assistant_action_template( + name="query_mp_downloaders", + description="查看 MP 下载器配置摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_downloaders"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_subscribes": + has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 + if has_items: + templates.extend([ + self._assistant_action_template( + name="search_mp_subscribe", + description="按编号触发订阅搜索;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "search", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="pause_mp_subscribe", + description="按编号暂停订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "pause", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="resume_mp_subscribe", + description="按编号恢复订阅;写入动作建议先 dry_run 生成计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "resume", "target": "<1-N>"}, + ), + self._assistant_action_template( + name="delete_mp_subscribe", + description="按编号删除订阅", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "mp_subscribe_control", "control": "delete", "target": "<1-N>"}, + ), + ]) + else: + templates.extend([ + self._assistant_action_template( + name="start_mp_subscribe", + description="按关键词生成新的 MP 订阅计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_lifecycle_status": + templates.extend([ + self._assistant_action_template( + name="query_mp_download_history", + description="继续查看 MP 下载历史", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "title": data.get("keyword") or "", "limit": 10}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, + ), + ]) + elif kind == "assistant_mp_recommend": + templates.extend([ + self._assistant_action_template( + name="pick_recommend_smart_decision", + description="按编号选择推荐条目并进入统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_decision"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_plan", + description="按编号选择推荐条目并直接生成统一资源计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_plan"}, + ), + self._assistant_action_template( + name="pick_recommend_smart_execute", + description="按编号选择推荐条目并直接进入统一资源执行链", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "smart_execute"}, + ), + self._assistant_action_template( + name="pick_recommend_mp_search", + description="按编号选择推荐条目并进入 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "mp"}, + ), + self._assistant_action_template( + name="pick_recommend_hdhive_search", + description="按编号选择推荐条目并进入影巢候选搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "hdhive"}, + ), + self._assistant_action_template( + name="pick_recommend_pansou_search", + description="按编号选择推荐条目并进入盘搜搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "mode": "pansou"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "candidate": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_candidate", + description="按编号选择影巢候选影片进入资源列表", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="candidate_detail", + description="补充当前候选页详情,例如主演", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "detail"}, + ), + self._assistant_action_template( + name="candidate_next_page", + description="翻到候选下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢候选对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_hdhive" and stage == "resource": + templates.extend([ + self._assistant_action_template( + name="pick_hdhive_resource", + description="按编号选择影巢资源,解锁并路由到对应网盘", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="plan_hdhive_resource", + description="按编号生成影巢解锁/转存计划,不立即扣分或写入", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._hdhive_default_path}, + ), + self._assistant_action_template( + name="resource_next_page", + description="翻到影巢资源下一页", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", + tool="agent_resource_officer_smart_pick", + body={**base_pick, "action": "next_page"}, + ), + ]) + if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): + templates.extend([ + self._assistant_action_template( + name="return_to_recommendations", + description="返回当前影巢资源列表对应的推荐榜单会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "回推荐"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_pansou", + description="基于当前推荐条目切到盘搜结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "盘搜"}, + ), + self._assistant_action_template( + name="handoff_hdhive_decision", + description="基于当前推荐条目回到统一资源决策", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "决策"}, + ), + self._assistant_action_template( + name="switch_hdhive_to_mp", + description="基于当前推荐条目切到 MP 原生搜索结果", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "text": "原生"}, + ), + ]) + elif kind == "assistant_p115_login": + templates.extend([ + self._assistant_action_template( + name="check_115_login", + description="检查 115 扫码是否已确认,并在成功后自动继续待任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_qrcode_check"}, + ), + self._assistant_action_template( + name="show_115_status", + description="查看当前 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_status"}, + ), + ]) + + if pending.get("has_pending"): + templates.extend([ + self._assistant_action_template( + name="resume_pending_115", + description="继续当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_resume"}, + ), + self._assistant_action_template( + name="cancel_pending_115", + description="取消当前会话里待处理的 115 任务", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "action": "p115_cancel"}, + ), + ]) + + templates.append( + self._assistant_action_template( + name="clear_current_session", + description="清理当前会话缓存", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session/clear", + tool="agent_resource_officer_session_clear", + body=base_state, + ) + ) + return templates + + def _assistant_sessions_public_data( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> Dict[str, Any]: + kind_filter = self._clean_text(kind) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + items_by_session: Dict[str, Dict[str, Any]] = {} + for session_id, payload in (self._session_cache or {}).items(): + if not str(session_id).startswith("assistant::"): + continue + session = dict(payload or {}) + if self._is_session_expired(session): + continue + brief = self._assistant_session_brief_public_data(str(session_id), session) + items_by_session[brief.get("session_id") or str(session_id)] = brief + for plan in (self._workflow_plans or {}).values(): + current = dict(plan or {}) + plan_session_id = self._clean_text(current.get("session_id")) + if not plan_session_id: + continue + brief = items_by_session.get(plan_session_id) + if brief: + if not brief.get("has_saved_plan"): + refreshed = self._assistant_session_brief_public_data(plan_session_id, self._load_session(plan_session_id) or {}) + items_by_session[plan_session_id] = refreshed + continue + items_by_session[plan_session_id] = self._assistant_plan_only_session_brief_public_data(plan_session_id) + items: List[Dict[str, Any]] = [] + for brief in items_by_session.values(): + if kind_filter and brief.get("kind") != kind_filter: + continue + if has_pending_p115 is not None and bool(brief.get("has_pending_p115")) != bool(has_pending_p115): + continue + items.append(brief) + items.sort(key=lambda item: self._safe_int(item.get("updated_at"), 0), reverse=True) + return { + "total": len(items), + "limit": max_limit, + "items": items[:max_limit], + "filters": { + "kind": kind_filter, + "has_pending_p115": has_pending_p115, + }, + "action_templates": [ + self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": "", "prefer_unexecuted": True}, + ), + self._assistant_action_template( + name="inspect_session", + description="查看某个会话的详细状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", + tool="agent_resource_officer_session_state", + body={"session_id": ""}, + ), + self._assistant_action_template( + name="clear_session_by_id", + description="按 session_id 清理单个会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"session_id": ""}, + ), + ], + "recovery": ( + dict((items[:max_limit][0] or {}).get("recovery") or {}) + if (items[:max_limit] and isinstance(items[:max_limit][0], dict)) + else { + "mode": "start_new", + "reason": "当前没有活跃会话,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + } + + def _assistant_recover_public_data( + self, + *, + session: str = "", + session_id: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + requested_session = self._clean_text(session) + requested_session_id = self._clean_text(session_id) + max_limit = min(max(1, self._safe_int(limit, 20)), 100) + if requested_session or requested_session_id: + session_name, normalized_session_id = self._normalize_assistant_session_ref( + session=requested_session or "default", + session_id=requested_session_id, + ) + state = self._assistant_session_public_data(session=session_name) + return { + "scope": "session", + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "selected_session": { + "session": session_name, + "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), + "kind": state.get("kind"), + "stage": state.get("stage"), + "keyword": state.get("keyword"), + "has_pending_plan": bool((state.get("saved_plan") or {}).get("has_pending")), + "has_pending_p115": bool((state.get("pending_p115") or {}).get("has_pending")), + }, + "session_state": state, + "sessions": None, + "recovery": dict(state.get("recovery") or self._assistant_recovery_public_data(session_state=state)), + } + + sessions = self._assistant_sessions_public_data(limit=max_limit) + items = [dict(item or {}) for item in (sessions.get("items") or []) if isinstance(item, dict)] + selected: Optional[Dict[str, Any]] = None + + current_recovery = dict(sessions.get("recovery") or {}) + template_body = dict(((current_recovery.get("action_template") or {}).get("body") or {})) + preferred_session_id = self._clean_text(template_body.get("session_id")) + if preferred_session_id: + selected = next((item for item in items if self._clean_text(item.get("session_id")) == preferred_session_id), None) + if not selected: + selected = next((item for item in items if bool((item.get("recovery") or {}).get("can_resume"))), None) + if not selected: + selected = next((item for item in items if item.get("has_pending_plan") or item.get("has_pending_p115")), None) + if not selected and items: + selected = items[0] + + if selected: + session_name = self._clean_text(selected.get("session")) or "default" + selected_session_id = self._clean_text(selected.get("session_id")) or self._assistant_session_id(session_name) + state = self._assistant_session_public_data(session=session_name) + recovery = dict(state.get("recovery") or selected.get("recovery") or current_recovery) + selected = {**selected, "recovery": recovery} + return { + "scope": "global", + "session": session_name, + "session_id": selected_session_id, + "selected_session": selected, + "session_state": state, + "sessions": sessions, + "recovery": recovery, + } + + state = self._assistant_session_public_data(session="default") + return { + "scope": "global", + "session": "default", + "session_id": state.get("session_id") or self._assistant_session_id("default"), + "selected_session": None, + "session_state": state, + "sessions": sessions, + "recovery": dict(state.get("recovery") or current_recovery), + } + + def _format_assistant_recover_text(self, data: Dict[str, Any]) -> str: + recovery = dict((data or {}).get("recovery") or {}) + selected = dict((data or {}).get("selected_session") or {}) + lines = [ + "Agent影视助手 恢复入口", + f"范围:{(data or {}).get('scope') or 'session'}", + f"会话:{(data or {}).get('session') or 'default'}", + f"模式:{recovery.get('mode') or 'unknown'}", + f"原因:{recovery.get('reason') or '-'}", + f"可恢复:{'是' if recovery.get('can_resume') else '否'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + if recovery.get("recommended_tool"): + lines.append(f"推荐 Tool:{recovery.get('recommended_tool')}") + if selected.get("kind") or selected.get("keyword"): + detail = " / ".join( + str(item) + for item in [ + selected.get("kind"), + selected.get("stage"), + selected.get("keyword"), + ] + if item + ) + lines.append(f"当前状态:{detail}") + if recovery.get("can_resume"): + lines.append("如需直接恢复,可调用 assistant/recover 并传 execute=true。") + return "\n".join(lines) + + def _assistant_recover_response_data(self, data: Dict[str, Any], compact: bool = False) -> Dict[str, Any]: + if not compact: + return self._assistant_response_data(session=(data or {}).get("session") or "default", data=data) + + payload = dict(data or {}) + session_state = dict(payload.pop("session_state", {}) or {}) + payload.pop("sessions", None) + recovery = dict(payload.get("recovery") or {}) + selected = payload.get("selected_session") + if isinstance(selected, dict) and selected.get("recovery"): + selected = dict(selected) + selected.pop("recovery", None) + payload["selected_session"] = selected + + session_name = self._clean_text(payload.get("session") or session_state.get("session")) or "default" + session_id = self._clean_text(payload.get("session_id") or session_state.get("session_id")) or self._assistant_session_id(session_name) + action_template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + session_templates = session_state.get("action_templates") if isinstance(session_state.get("action_templates"), list) else [] + payload.update({ + "protocol_version": "assistant.v1", + "compact": True, + "session": session_name, + "session_id": session_id, + "next_actions": [ + item for item in [ + recovery.get("recommended_action"), + *(session_state.get("suggested_actions") or []), + ] + if item + ][:6], + "action_templates": self._assistant_compact_action_templates(action_template, session_templates), + }) + return payload + + def _assistant_session_compact_data(self, session_state: Dict[str, Any]) -> Dict[str, Any]: + state = dict(session_state or {}) + recovery = dict(state.get("recovery") or {}) + saved_plan = dict(state.get("saved_plan") or {}) + pending_p115 = dict(state.get("pending_p115") or {}) + payload: Dict[str, Any] = { + "protocol_version": "assistant.v1", + "action": "session_state", + "ok": True, + "compact": True, + "has_session": bool(state.get("has_session")), + "session": self._clean_text(state.get("session")) or "default", + "session_id": self._clean_text(state.get("session_id")), + "kind": self._clean_text(state.get("kind")), + "stage": self._clean_text(state.get("stage")), + "keyword": self._clean_text(state.get("keyword")), + "target_path": self._clean_text(state.get("target_path")), + "updated_at": state.get("updated_at"), + "updated_at_text": state.get("updated_at_text"), + "saved_plan": { + "has_pending": bool(saved_plan.get("has_pending")), + "plan_id": self._clean_text((saved_plan.get("latest") or {}).get("plan_id") or saved_plan.get("plan_id")), + }, + "pending_p115": { + "has_pending": bool(pending_p115.get("has_pending")), + "target_path": self._clean_text(pending_p115.get("target_path")), + "retry_count": pending_p115.get("retry_count"), + }, + "recovery": recovery, + "next_actions": state.get("suggested_actions") or [], + "action_templates": self._assistant_compact_action_templates( + recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None, + state.get("action_templates") if isinstance(state.get("action_templates"), list) else [], + ), + } + for key in [ + "result_count", + "total_candidates", + "page", + "total_pages", + "selected_candidate", + "total_resources", + "resource_count_115", + "resource_count_quark", + "score_summary", + "client_type", + ]: + if key in state: + payload[key] = state.get(key) + return payload + + def _assistant_sessions_compact_data(self, sessions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(sessions_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + recovery = dict(item.get("recovery") or {}) + items.append({ + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "keyword": self._clean_text(item.get("keyword")), + "target_path": self._clean_text(item.get("target_path")), + "updated_at": item.get("updated_at"), + "updated_at_text": item.get("updated_at_text"), + "has_pending_plan": bool(item.get("has_pending_plan")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "recovery_mode": self._clean_text(recovery.get("mode")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + }) + recovery = dict(data.get("recovery") or {}) + return { + "protocol_version": "assistant.v1", + "action": "sessions", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "filters": data.get("filters") or {}, + "items": items, + "recovery": recovery, + "next_actions": [recovery.get("recommended_action")] if recovery.get("recommended_action") else [], + "action_templates": [recovery.get("action_template")] if isinstance(recovery.get("action_template"), dict) else [], + } + + def _assistant_history_compact_data(self, history_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(history_data or {}) + items: List[Dict[str, Any]] = [] + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + summary = dict(item.get("summary") or {}) + steps = summary.get("steps") + items.append({ + "time_text": self._clean_text(item.get("time_text")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "workflow": self._clean_text(summary.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "message_head": self._clean_text(item.get("message_head")), + "steps": steps if isinstance(steps, int) else None, + }) + return { + "protocol_version": "assistant.v1", + "action": "history", + "ok": True, + "compact": True, + "total": data.get("total") or 0, + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "items": items, + } + + def _assistant_plans_compact_data(self, plans_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plans_data or {}) + items: List[Dict[str, Any]] = [] + first_pending: Optional[Dict[str, Any]] = None + for item in data.get("items") or []: + if not isinstance(item, dict): + continue + plan_id = self._clean_text(item.get("plan_id")) + executed = bool(item.get("executed")) + compact_item = { + "plan_id": plan_id, + "workflow": self._clean_text(item.get("workflow")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "executed": executed, + "action_count": self._safe_int(item.get("action_count"), 0), + "created_at_text": self._clean_text(item.get("created_at_text")), + "last_success": item.get("last_success"), + "last_message": self._clean_text(item.get("last_message")), + } + items.append(compact_item) + if not executed and first_pending is None and plan_id: + first_pending = compact_item + templates: List[Dict[str, Any]] = [] + if first_pending: + templates.append(self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": first_pending.get("plan_id"), + "session": first_pending.get("session"), + "session_id": first_pending.get("session_id"), + "prefer_unexecuted": True, + }, + )) + return { + "protocol_version": "assistant.v1", + "action": "plans", + "ok": True, + "compact": True, + "total": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_matching": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), + "total_all": data.get("total_all") if data.get("total_all") is not None else (data.get("total") or 0), + "limit": data.get("limit") or len(items), + "session": self._clean_text(data.get("session")), + "session_id": self._clean_text(data.get("session_id")), + "executed": data.get("executed"), + "include_actions": False, + "items": items, + "next_actions": ["execute_plan"] if first_pending else [], + "action_templates": templates, + } + + def _assistant_compact_action_results(self, rows: Any) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for item in rows or []: + if not isinstance(item, dict): + continue + results.append({ + "index": item.get("index"), + "name": self._clean_text(item.get("name")), + "success": bool(item.get("success")), + "action": self._clean_text(item.get("action")), + "ok": bool(item.get("ok")) if "ok" in item else bool(item.get("success")), + "message_head": self._clean_text(item.get("message_head")), + "session": self._clean_text(item.get("session")), + "session_id": self._clean_text(item.get("session_id")), + "kind": self._clean_text(item.get("kind")), + "stage": self._clean_text(item.get("stage")), + "has_pending_p115": bool(item.get("has_pending_p115")), + "next_actions": item.get("next_actions") or [], + }) + if isinstance(item.get("score_summary"), dict): + results[-1]["score_summary"] = item.get("score_summary") + if isinstance(item.get("diagnosis_summary"), dict): + results[-1]["diagnosis_summary"] = item.get("diagnosis_summary") + return results + + def _assistant_plan_execute_followup( + self, + *, + workflow: str, + session: str, + session_id: str, + session_state: Optional[Dict[str, Any]] = None, + ok: bool, + plan_id: str = "", + ) -> Dict[str, Any]: + workflow_name = self._clean_text(workflow) + if not ok and workflow_name != "smart_resource_plan": + return {"next_actions": [], "action_templates": [], "recommended_action": "", "follow_up_hint": ""} + + workflow_stage = workflow_name + state = dict(session_state or {}) + session_name = self._clean_text(session or state.get("session")) or "default" + session_cache = self._clean_text(session_id or state.get("session_id")) or self._assistant_session_id(session_name) + keyword = self._clean_text(state.get("keyword")) + plan_execute_body = dict(state.get("plan_execute_body") or {}) + if workflow_name == "smart_resource_plan": + inferred_source = self._clean_text( + state.get("source_type") + or ((state.get("best_candidate") or {}).get("source_type")) + or plan_execute_body.get("mode") + ).lower() + if inferred_source == "mp": + inferred_source = "mp_pt" + if inferred_source == "mp_pt": + workflow_name = "mp_best_download" + elif inferred_source in {"pansou", "hdhive"}: + workflow_name = inferred_source + base_route = { + "session": session_name, + "session_id": session_cache, + } + base_state = { + "session": session_name, + "session_id": session_cache, + } + keyword_value = keyword or "<关键词>" + next_actions: List[str] = [] + templates: List[Dict[str, Any]] = [] + follow_up_hint = "" + clean_plan_id = self._clean_text(plan_id) + + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动去查下载历史,再继续判断是否已整理、已入库或失败。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询下载后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_download_history", + description="查看下载历史,并继续追踪整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_download_history", "keyword": keyword_value, "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_lifecycle_status", + description="按关键词聚合查看下载任务、下载历史和整理/入库状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_lifecycle_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总失败线索并给出本地/PT 入库诊断建议", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + next_actions = ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查订阅列表,再决定是否继续搜索或进入入库追踪。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询订阅后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_subscribes", + description="查看订阅列表,确认当前订阅是否已生效", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_subscribes", "status": "all", "keyword": keyword, "limit": 20}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按关键词判断当前处于下载、整理、入库还是失败阶段", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="start_mp_media_search", + description="按当前关键词重新发起 MP 原生搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={**base_route, "mode": "mp", "keyword": keyword_value}, + ), + ] + elif workflow_name in {"share_transfer", "pansou_transfer_selected", "hdhive_unlock", "hdhive_unlock_selected", "pansou", "hdhive"}: + next_actions = ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + follow_up_hint = "可以先执行统一后续追踪;它会自动查整理/入库历史,失败时再切到本地诊断。" + templates = [ + self._assistant_action_template( + name="query_execution_followup", + description="按最近已执行计划自动查询云盘转存后的统一后续状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={ + **base_state, + "name": "query_execution_followup", + **({"plan_id": clean_plan_id} if clean_plan_id else {}), + }, + ), + self._assistant_action_template( + name="query_mp_transfer_history", + description="查看最近整理/入库历史,确认转存资源是否已落库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_transfer_history", "keyword": keyword, "status": "all", "limit": 10}, + ), + self._assistant_action_template( + name="query_mp_local_diagnose", + description="汇总下载后未入库或整理失败的诊断线索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + ] + if workflow_stage == "smart_resource_plan" and keyword: + next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_local_diagnose"] + follow_up_hint = "推荐先跟进当前发现链的落库状态;它会先看统一后续,再判断是否已入库或需要本地诊断。" + templates = [ + templates[0], + self._assistant_action_template( + name="query_mp_ingest_status", + description="按推荐标题查看当前是否已下载、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + templates[-1], + ] + elif workflow_name == "ai_replay_failed_sample": + next_actions = ["query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + follow_up_hint = "先回看 AI 工作清单和失败样本,确认这次二次识别是否减少了失败样本。" + if keyword: + next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] + follow_up_hint = "先看本地诊断或入库状态,再回看 AI 工作清单确认失败样本是否已减少。" + templates = [] + if keyword: + templates.extend([ + self._assistant_action_template( + name="query_mp_local_diagnose", + description="查看这次二次识别关联的本地/PT 入库诊断", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, + ), + self._assistant_action_template( + name="query_mp_ingest_status", + description="按目标标题查看当前是否已重新识别、整理或入库", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, + ), + ]) + templates.extend([ + self._assistant_action_template( + name="query_ai_sample_worklist", + description="回看 AI 工作清单,继续处理剩余失败样本", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_worklist", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_failed_samples", + description="回看 AI 原始失败样本,确认当前仍有哪些标题或路径失败", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_failed_samples", "keyword": keyword}, + ), + self._assistant_action_template( + name="query_ai_sample_insights", + description="查看 AI 样本洞察,确认失败原因是否仍然集中", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={**base_state, "name": "query_ai_sample_insights", "keyword": keyword}, + ), + ]) + + category = "cloud_write" + if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: + category = "mp_download" + elif workflow_name in {"mp_subscribe", "mp_subscribe_and_search", "mp_subscribe_control"}: + category = "mp_subscribe" + elif workflow_name == "ai_replay_failed_sample": + category = "ai_reingest" + followup_summary = self._assistant_followup_summary( + category=category, + stage=self._clean_text(workflow_stage), + recommended_action=next_actions[0] if next_actions else "", + follow_up_hint=follow_up_hint, + next_actions=next_actions, + action_templates=templates, + keyword=keyword, + ) + return { + "next_actions": next_actions, + "action_templates": templates, + "recommended_action": next_actions[0] if next_actions else "", + "follow_up_hint": follow_up_hint, + "followup_summary": followup_summary, + } + + async def _assistant_execution_followup( + self, + request: Request, + *, + session: str, + session_id: str, + plan_id: str = "", + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + target_plan_id = self._clean_text(plan_id) + plan = self._find_workflow_plan( + plan_id=target_plan_id, + session=session_name, + session_id=cache_key, + executed=True if not target_plan_id else None, + ) + if target_plan_id and plan and not bool(plan.get("executed")): + return { + "success": False, + "message": f"计划 {target_plan_id} 还没有执行,暂时无法做执行后追踪。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "plan_not_executed", + "plan_id": target_plan_id, + }), + } + if not plan: + latest_any = self._find_workflow_plan(session=session_name, session_id=cache_key, executed=None) + error_code = "executed_plan_not_found" + if latest_any and not bool(latest_any.get("executed")): + error_code = "latest_plan_not_executed" + return { + "success": False, + "message": "当前会话没有可追踪的已执行计划,请先执行下载、订阅或转存计划。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": error_code, + "plan_id": target_plan_id, + }), + } + + source_plan = self._workflow_plan_public_item(plan, include_actions=False) + state = self._load_session(self._clean_text(plan.get("session_id"))) or {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(plan.get("workflow")), + session=self._clean_text(plan.get("session")) or session_name, + session_id=self._clean_text(plan.get("session_id")) or cache_key, + session_state=state, + ok=bool(plan.get("last_success")) if plan.get("last_success") is not None else True, + plan_id=self._clean_text(plan.get("plan_id")), + ) + action_templates = [item for item in (followup.get("action_templates") or []) if isinstance(item, dict)] + resolved_template = next( + (item for item in action_templates if self._clean_text(item.get("name")) != "query_execution_followup"), + {}, + ) + resolved_name = self._clean_text(resolved_template.get("name")) + if not resolved_name: + return { + "success": False, + "message": "当前执行计划没有可自动继续的只读追踪动作。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "execution_followup", + "ok": False, + "error_code": "followup_not_available", + "plan_id": self._clean_text(plan.get("plan_id")), + "source_plan": source_plan, + }), + } + action_body = dict(resolved_template.get("action_body") or resolved_template.get("body") or {}) + action_body["compact"] = False + action_body["apikey"] = self._extract_apikey(request, {}) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "execution_followup", + "ok": bool(result.get("success")), + "plan_id": self._clean_text(plan.get("plan_id")), + "workflow": self._clean_text(plan.get("workflow")), + "source_plan": source_plan, + "recommended_action": self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(followup.get("follow_up_hint")), + "resolved_followup_action": resolved_name, + "followup_summary": followup.get("followup_summary") or {}, + }) + message_lines = [ + f"已执行后续追踪:{resolved_name}", + self._clean_text(result.get("message")), + ] + message_lines.extend(self._format_followup_summary_lines(followup.get("followup_summary"))) + return { + "success": bool(result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + async def _assistant_smart_followup( + self, + request: Request, + *, + session: str, + session_id: str, + keyword: str = "", + hash_value: str = "", + limit: int = 5, + ) -> Dict[str, Any]: + session_name, cache_key = self._normalize_assistant_session_ref( + session=session or "default", + session_id=session_id, + ) + title = self._clean_text(keyword) + hash_text = self._clean_text(hash_value) + saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=cache_key) + latest_plan = dict(saved_plan.get("latest") or {}) + latest_plan_id = self._clean_text(latest_plan.get("plan_id")) + latest_executed = bool(latest_plan.get("executed")) + + if title or hash_text: + result = await self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=title, + hash_value=hash_text, + limit=limit, + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_lifecycle_status", + "smart_mode": "keyword_lifecycle", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按关键词查看本地/PT 状态", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if latest_executed and latest_plan_id: + result = await self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=latest_plan_id, + ) + payload = dict(result.get("data") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": self._clean_text(payload.get("resolved_followup_action")) or "execution_followup", + "smart_mode": "executed_plan_followup", + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:按最近已执行计划继续追踪", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + if saved_plan.get("has_pending"): + template = self._assistant_action_template( + name="execute_session_latest_plan", + description="按 session_id 执行该会话最近一条待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True}, + ) + return { + "success": False, + "message": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "smart_followup", + "ok": False, + "error_code": "latest_plan_not_executed", + "recommended_action": "execute_session_latest_plan", + "follow_up_hint": "当前会话还有待执行计划,先执行计划,再继续跟进。", + "action_templates": [template], + "smart_mode": "pending_plan_blocked", + }), + } + + result = await self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=max(5, limit), + ) + payload = dict(result.get("data") or {}) + followup_summary = {} + if isinstance(payload.get("followup_summary"), dict): + followup_summary = dict(payload.get("followup_summary") or {}) + elif isinstance(payload.get("diagnosis_summary"), dict): + followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) + payload.update({ + "action": "smart_followup", + "ok": bool(result.get("success")), + "resolved_followup_action": "mp_recent_activity", + "smart_mode": "recent_activity_fallback", + "followup_summary": followup_summary, + }) + return { + "success": bool(result.get("success")), + "message": "\n".join( + line for line in [ + "智能跟进:当前没有指定片名,也没有已执行计划,先看最近活动", + self._clean_text(result.get("message")), + ] if line + ).strip(), + "data": self._assistant_response_data(session=session_name, data=payload), + } + + def _assistant_actions_compact_data(self, actions_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(actions_data or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "execute_actions", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=self._clean_text(data.get("error_code")), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=self._assistant_result_message_head(data.get("message") or data.get("message_head")), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in ["decision_mode", "decision_reason"]: + if key in data: + payload[key] = data.get(key) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return payload + + @staticmethod + def _assistant_compact_command_summary(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + data = dict(payload or {}) + score_summary = data.get("score_summary") if isinstance(data.get("score_summary"), dict) else {} + candidates = [ + ("error_summary", data.get("error_summary") if isinstance(data.get("error_summary"), dict) else {}), + ("followup_summary", data.get("followup_summary") if isinstance(data.get("followup_summary"), dict) else {}), + ("decision_summary", data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}), + ("score_summary", score_summary.get("decision") if isinstance(score_summary.get("decision"), dict) else {}), + ] + for source, summary in candidates: + if not isinstance(summary, dict) or not summary: + continue + compact_commands = [ + AgentResourceOfficer._clean_text(item) + for item in (summary.get("compact_commands") or summary.get("recommended_commands") or []) + if AgentResourceOfficer._clean_text(item) + ] + preferred_command = AgentResourceOfficer._clean_text(summary.get("preferred_command")) + fallback_command = AgentResourceOfficer._clean_text(summary.get("fallback_command")) + if preferred_command or compact_commands: + return { + "command_source": source, + "command_policy": AgentResourceOfficer._clean_text(summary.get("command_policy")) or ("confirm_then_resume" if bool(summary.get("preferred_requires_confirmation")) else "safe_read_only"), + "preferred_requires_confirmation": bool(summary.get("preferred_requires_confirmation")), + "fallback_requires_confirmation": bool(summary.get("fallback_requires_confirmation")), + "can_auto_run_preferred": bool(summary.get("can_auto_run_preferred")) if "can_auto_run_preferred" in summary else not bool(summary.get("preferred_requires_confirmation")), + "preferred_command": preferred_command or (compact_commands[0] if compact_commands else ""), + "fallback_command": fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), + "compact_commands": compact_commands[:2], + "recommended_agent_behavior": AgentResourceOfficer._clean_text(summary.get("recommended_agent_behavior")), + "auto_run_command": AgentResourceOfficer._clean_text(summary.get("auto_run_command")), + "confirm_command": AgentResourceOfficer._clean_text(summary.get("confirm_command")), + "display_command": AgentResourceOfficer._clean_text(summary.get("display_command")), + "detail_short_command": AgentResourceOfficer._clean_text(summary.get("detail_short_command")), + "decision_short_command": AgentResourceOfficer._clean_text(summary.get("decision_short_command")), + "plan_short_command": AgentResourceOfficer._clean_text(summary.get("plan_short_command")), + "confirm_short_command": AgentResourceOfficer._clean_text(summary.get("confirm_short_command")), + "pansou_short_command": AgentResourceOfficer._clean_text(summary.get("pansou_short_command")), + "hdhive_short_command": AgentResourceOfficer._clean_text(summary.get("hdhive_short_command")), + "mp_short_command": AgentResourceOfficer._clean_text(summary.get("mp_short_command")), + } + return {} + + def _assistant_plan_execute_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + results = self._assistant_compact_action_results(data.get("results")) + success_count = len([item for item in results if item.get("success")]) + last_result = results[-1] if results else {} + followup = self._assistant_plan_execute_followup( + workflow=self._clean_text(data.get("workflow")), + session=self._clean_text(data.get("session") or session_state.get("session")) or "default", + session_id=self._clean_text(data.get("session_id") or session_state.get("session_id")), + session_state=session_state, + ok=bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + plan_id=self._clean_text(data.get("plan_id")), + ) + payload = { + "protocol_version": "assistant.v1", + "action": "execute_plan", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or "write", + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "plan_id": self._clean_text(data.get("plan_id")), + "workflow": self._clean_text(data.get("workflow")), + "plan_auto_selected": bool(data.get("plan_auto_selected")), + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "plan_executed_at": data.get("plan_executed_at"), + "plan_executed_at_text": data.get("plan_executed_at_text"), + "executed_count": data.get("executed_count") or len(results), + "requested_count": data.get("requested_count") or len(results), + "stopped_on_error": bool(data.get("stopped_on_error")), + "halted_at": data.get("halted_at") or 0, + "results": results, + "result_summary": { + "success_count": success_count, + "failure_count": max(len(results) - success_count, 0), + "last_action": self._clean_text(last_result.get("action")), + "last_message_head": self._clean_text(last_result.get("message_head")), + }, + "recommended_action": self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")), + "follow_up_hint": self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")), + "next_actions": self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or session_state.get("suggested_actions") or [], + ), + "action_templates": self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ), + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=payload.get("recommended_action"), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("recovery"), dict): + payload["recovery"] = data.get("recovery") + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_single_action_compact_response(self, name: str, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or self._clean_text(name) or "execute_action", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action")) or self._clean_text(name)), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "name": self._clean_text(name), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target")), + ) + if error_summary: + payload["error_summary"] = error_summary + for key in ["plan_id", "workflow", "plan_auto_selected", "has_session", "has_pending"]: + if key in data: + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + for key in [ + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "decision_mode", + "decision_reason", + "detail_short_command", + "decision_short_command", + "plan_short_command", + "confirm_short_command", + "pansou_short_command", + "hdhive_short_command", + "mp_short_command", + "auto_run_command", + "confirm_command", + "display_command", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + ]: + if key in data: + payload[key] = data.get(key) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_interaction_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: + response = dict(result or {}) + data = dict(response.get("data") or {}) + session_state = dict(data.get("session_state") or {}) + payload = { + "protocol_version": "assistant.v1", + "action": self._clean_text(data.get("action")) or "assistant_interaction", + "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), + "compact": True, + "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action"))), + "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "message_head": self._assistant_result_message_head(response.get("message")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "keyword": self._clean_text(session_state.get("keyword")), + "target_path": self._clean_text(session_state.get("target_path")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "action_templates": data.get("action_templates") or [], + } + error_summary = self._assistant_error_summary( + error_code=payload.get("error_code"), + recommended_action=self._clean_text(data.get("recommended_action")), + message_head=payload.get("message_head"), + next_actions=payload.get("next_actions"), + action_templates=payload.get("action_templates"), + keyword=self._clean_text(session_state.get("keyword")), + hash_value=self._clean_text(session_state.get("hash")), + target=self._clean_text(data.get("target") or session_state.get("keyword")), + ) + if error_summary: + payload["error_summary"] = error_summary + if isinstance(data.get("score_summary"), dict): + payload["score_summary"] = data.get("score_summary") + if isinstance(data.get("decision_summary"), dict): + payload["decision_summary"] = data.get("decision_summary") + if isinstance(data.get("diagnosis_summary"), dict): + payload["diagnosis_summary"] = data.get("diagnosis_summary") + if isinstance(data.get("followup_summary"), dict): + payload["followup_summary"] = data.get("followup_summary") + if isinstance(data.get("scoring_policy"), dict): + payload["scoring_policy"] = data.get("scoring_policy") + if isinstance(data.get("effective_preferences"), dict): + payload["effective_preferences"] = data.get("effective_preferences") + if isinstance(data.get("session_preference_overrides"), dict): + payload["session_preference_overrides"] = data.get("session_preference_overrides") + if isinstance(data.get("insights"), dict): + payload["insights"] = data.get("insights") + if isinstance(data.get("recommend_handoff"), dict): + payload["recommend_handoff"] = data.get("recommend_handoff") + for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in [ + "provider", + "source", + "requested_source", + "fallback_source", + "media_type", + "page", + "total_pages", + "selected_candidate", + "selected_resource", + "plan_id", + "workflow", + "recommended_action", + "follow_up_hint", + "resolved_followup_action", + "smart_mode", + "smart_plan_auto_selected", + "smart_execute_auto_selected", + "decision_mode", + "decision_reason", + "sample_index", + "resolved", + "resolved_by_identifiers", + "resolved_by_recognizer", + "sample_removed", + "selected_index", + "return_short_command", + ]: + if key in data: + payload[key] = data.get(key) + for key in ["best_candidate"]: + if isinstance(data.get(key), dict): + payload[key] = data.get(key) + for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: + if isinstance(data.get(key), list): + payload[key] = data.get(key) + if isinstance(data.get("preference_status"), dict): + payload["preference_status"] = data.get("preference_status") + payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) + if isinstance(data.get("items"), list): + payload["items"] = data.get("items") + for key in ["items", "candidates", "resources"]: + if isinstance(data.get(key), list): + payload[f"{key}_count"] = len(data.get(key) or []) + pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} + if pending_p115: + payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) + command_summary = self._assistant_compact_command_summary(payload) + if command_summary: + payload.update(command_summary) + return { + "success": bool(response.get("success")), + "message": response.get("message") or "", + "data": payload, + } + + def _assistant_workflow_plan_compact_data(self, plan_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(plan_data or {}) + session_state = dict(data.get("session_state") or {}) + plan_id = self._clean_text(data.get("plan_id")) + template = self._assistant_action_template( + name="execute_plan", + description="执行刚生成的 dry_run 计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={"plan_id": plan_id}, + ) if plan_id else None + return { + "protocol_version": "assistant.v1", + "action": "workflow_plan", + "ok": bool(data.get("ok")), + "compact": True, + "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "plan_id": plan_id, + "workflow": self._clean_text(data.get("workflow")), + "dry_run": True, + "estimated_steps": data.get("estimated_steps") or 0, + "ready_to_execute": bool(data.get("ready_to_execute")), + "execute_plan_endpoint": data.get("execute_plan_endpoint"), + "execute_plan_body": data.get("execute_plan_body") or {"plan_id": plan_id}, + "plan_created_at": data.get("plan_created_at"), + "plan_created_at_text": data.get("plan_created_at_text"), + "preference_status": data.get("preference_status") or {}, + "needs_onboarding": bool((data.get("preference_status") or {}).get("needs_onboarding")), + "score_summary": data.get("score_summary") or {}, + "decision_summary": data.get("decision_summary") or {}, + "best_candidate": data.get("best_candidate") or {}, + "sources_checked": data.get("sources_checked") or [], + "available_sources": data.get("available_sources") or [], + "blocked_sources": data.get("blocked_sources") or [], + "effective_preferences": data.get("effective_preferences") or {}, + "session_preference_overrides": data.get("session_preference_overrides") or {}, + "smart_plan_auto_selected": bool(data.get("smart_plan_auto_selected")), + "detail_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("detail_short_command")), + "decision_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_short_command")), + "plan_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("plan_short_command")), + "confirm_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("confirm_short_command")), + "pansou_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("pansou_short_command")), + "hdhive_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("hdhive_short_command")), + "mp_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("mp_short_command")), + "next_actions": ["execute_plan"] if plan_id else [], + "action_templates": [template] if template else [], + } + + def _format_assistant_sessions_text( + self, + *, + kind: str = "", + has_pending_p115: Optional[bool] = None, + limit: int = 20, + ) -> str: + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + items = data.get("items") or [] + if not items: + return "当前没有活跃的 Agent影视助手 会话。" + lines = [ + f"当前活跃会话:{data.get('total') or 0} 个", + "可直接用 assistant/session 查看单个会话详情,也可按 session_id 直接恢复最近计划。", + ] + for idx, item in enumerate(items, 1): + line = f"{idx}. {item.get('session')} | {item.get('kind') or '-'} | {item.get('stage') or '-'}" + if item.get("keyword"): + line = f"{line} | {item.get('keyword')}" + lines.append(line) + detail_parts: List[str] = [] + if item.get("target_path"): + detail_parts.append(f"目录:{item.get('target_path')}") + if item.get("updated_at_text"): + detail_parts.append(f"更新:{item.get('updated_at_text')}") + if item.get("has_pending_p115"): + detail_parts.append("含待继续115任务") + if item.get("has_pending_plan"): + detail_parts.append("含待执行计划") + if item.get("selected_title"): + detail_parts.append(f"已选:{item.get('selected_title')}") + if item.get("result_count"): + detail_parts.append(f"结果:{item.get('result_count')}") + if item.get("total_candidates"): + detail_parts.append(f"候选:{item.get('total_candidates')}") + if item.get("total_resources"): + detail_parts.append(f"资源:{item.get('total_resources')}") + if detail_parts: + lines.append(" " + " | ".join(detail_parts)) + return "\n".join(lines) + + def _clear_assistant_sessions( + self, + *, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + cleared_ids: List[str] = [] + if self._clean_text(session_id) or self._clean_text(session): + _, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if cache_key in self._session_cache: + self._session_cache.pop(cache_key, None) + cleared_ids.append(cache_key) + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + kind_filter = self._clean_text(kind) + for current_session_id, payload in list((self._session_cache or {}).items()): + if len(cleared_ids) >= max_limit: + break + if not str(current_session_id).startswith("assistant::"): + continue + current = dict(payload or {}) + expired = self._is_session_expired(current) + if stale_only and not expired: + continue + if not stale_only and expired: + continue + if not all_sessions: + if kind_filter and self._clean_text(current.get("kind")) != kind_filter: + continue + if has_pending_p115 is not None: + has_pending = bool(self._clean_text(((current.get("pending_p115") or {}).get("share_url")))) + if has_pending != bool(has_pending_p115): + continue + if not kind_filter and has_pending_p115 is None and not stale_only: + continue + self._session_cache.pop(current_session_id, None) + cleared_ids.append(str(current_session_id)) + + self._persist_relevant_sessions() + return { + "cleared_count": len(cleared_ids), + "cleared_session_ids": cleared_ids, + "limit": max_limit, + } + + def _format_assistant_session_summary(self, session: str = "default") -> str: + data = self._assistant_session_public_data(session=session) + if not data.get("has_session"): + return "\n".join([ + "当前没有活跃会话。", + "可直接调用 smart_entry 发起新操作,例如:", + "1. text=盘搜搜索 大君夫人", + "2. text=影巢搜索 蜘蛛侠", + "3. text=链接 https://115cdn.com/s/xxxx path=/待整理", + ]) + + lines = [ + "当前会话状态", + f"会话:{data.get('session')}", + f"类型:{data.get('kind') or '-'}", + f"阶段:{data.get('stage') or '-'}", + ] + if data.get("keyword"): + lines.append(f"关键词:{data.get('keyword')}") + if data.get("target_path"): + lines.append(f"目录:{data.get('target_path')}") + if data.get("updated_at_text"): + lines.append(f"最近更新:{data.get('updated_at_text')}") + if data.get("kind") == "assistant_pansou": + lines.append(f"结果数:{data.get('result_count') or 0}") + lines.append("下一步:调用 smart_pick,传入 choice=编号") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "candidate": + lines.append(f"候选数:{data.get('total_candidates') or 0}") + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=编号,或 action=详情 / 下一页") + elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "resource": + selected = data.get("selected_candidate") or {} + if selected.get("title"): + lines.append(f"已选影片:{selected.get('title')} ({selected.get('year') or '-'})") + lines.append(f"资源数:{data.get('total_resources') or 0}") + if data.get("page") and data.get("total_pages"): + lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") + lines.append("下一步:smart_pick 可传 choice=资源编号,或 action=下一页") + elif data.get("kind") == "assistant_p115_login": + lines.append(f"扫码客户端:{data.get('client_type') or self._p115_client_type}") + lines.append("下一步:调用 smart_entry,传入 text=检查115登录") + + pending = data.get("pending_p115") or {} + if pending.get("has_pending"): + lines.append("存在待继续的 115 任务") + lines.append(f"任务:{pending.get('title')}") + lines.append(f"待转目录:{pending.get('target_path')}") + + actions = data.get("suggested_actions") or [] + if actions: + lines.append("建议动作:" + " / ".join(str(item) for item in actions if item)) + return "\n".join(lines) + + def _session_key_for_tool(self, session: str = "default") -> str: + clean_session = self._clean_text(session) or "default" + if clean_session.startswith("assistant::"): + return clean_session + return self._assistant_session_id(clean_session) + + def _execute_pending_p115_share( + self, + *, + session_id: str, + state: Dict[str, Any], + trigger: str, + ) -> Tuple[bool, str, Dict[str, Any]]: + pending = dict((state or {}).get("pending_p115") or {}) + share_url = self._clean_text(pending.get("share_url")) + if not share_url: + return False, "", {} + target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path + transfer_ok, result, transfer_message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(pending.get("access_code")), + path=target_path, + trigger=trigger, + ) + if transfer_ok: + self._clear_pending_p115_share(session_id) + message = "\n".join( + [ + "115 转存已完成", + f"目录:{result.get('path') or target_path}", + f"结果:{transfer_message or result.get('message') or 'success'}", + ] + ) + return True, message, {"provider": "115", "result": result} + + failure_message = self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path, + ) + current_state = self._load_session(session_id) or dict(state or {}) + pending["retry_count"] = max(0, self._safe_int(pending.get("retry_count"), 0)) + 1 + pending["last_attempt_at"] = int(time.time()) + pending["last_error"] = failure_message + current_state["pending_p115"] = pending + if not current_state.get("kind"): + current_state["kind"] = "assistant_p115_pending" + current_state["stage"] = "pending_login" + self._save_session(session_id, current_state) + return False, failure_message, {"provider": "115", "result": result} + + async def _resume_pending_p115_share( + self, + request: Request, + body: Dict[str, Any], + *, + session_id: str, + state: Dict[str, Any], + ) -> Tuple[bool, str, Dict[str, Any]]: + return self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 115 登录后自动继续", + ) + + def _format_p115_status_summary(self, *, title: str = "115 当前状态") -> str: + status = self._p115_status_snapshot() + lines = [ + title, + f"可用状态:{'可用' if status.get('ready') else '待修复'}", + f"默认目录:{status.get('default_target_path') or self._p115_default_path}", + f"扫码客户端:{status.get('client_type') or self._p115_client_type}", + ] + if status.get("direct_source"): + lines.append(f"直转来源:{status.get('direct_source')}") + elif status.get("helper_ready"): + lines.append("直转来源:P115StrmHelper") + if status.get("cookie_mode") == "client_cookie": + lines.append("当前会话:已保存扫码会话") + elif status.get("cookie_mode") == "invalid_cookie": + lines.append("当前会话:已配置但看起来不是扫码会话") + else: + lines.append("当前会话:复用 115 助手客户端") + if status.get("message") and not status.get("ready"): + lines.append(f"详情:{status.get('message')}") + lines.append(self._format_p115_next_actions(status)) + return "\n".join(lines) + + def _format_p115_help_text(self) -> str: + status = self._p115_status_snapshot() + final_path = status.get("default_target_path") or self._p115_default_path + lines = [ + "115 使用帮助", + f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}", + f"默认目录:{final_path}", + "如果 115 转存因登录问题失败,我会记住这次任务;扫码成功后回复 检查115登录,会自动继续执行。", + "常用示例:", + f"1. 链接 https://115cdn.com/s/xxxx path={final_path}", + "2. 影巢搜索 蜘蛛侠", + "3. 盘搜搜索 大君夫人", + "4. 115登录", + "5. 检查115登录", + "6. 115状态", + "7. 继续115任务 / 取消115任务", + self._format_p115_next_actions(status), + ] + return "\n".join(lines) + + def _format_assistant_help_text(self, session: str = "default") -> str: + session_name = self._clean_text(session) or "default" + lines = [ + "Agent影视助手 使用帮助", + f"当前会话:{session_name}", + "推荐优先使用原生 Tool:agent_resource_officer_smart_entry 与 agent_resource_officer_smart_pick。", + "smart_entry 常用示例:", + "1. text=盘搜搜索 大君夫人", + "2. text=搜索 大君夫人 默认走盘搜", + "3. text=云盘搜索 大君夫人 只走盘搜 + 影巢", + "4. text=影巢搜索 蜘蛛侠", + "5. text=MP搜索 蜘蛛侠 或 PT搜索 蜘蛛侠", + "6. text=115登录", + "7. text=检查115登录", + "8. text=链接 https://115cdn.com/s/xxxx path=/待整理", + "9. text=链接 https://pan.quark.cn/s/xxxx 位置=分享", + "10. text=转存 蜘蛛侠 默认等同 115转存;text=下载 蜘蛛侠 只走 MP/PT,先展示候选和 PT 资源,不自动提交下载", + "11. text=下载任务;暂停下载 1 / 恢复下载 1 / 删除下载 1 会先生成计划", + "12. text=站点状态;下载器状态 用于排查 PT 搜索/下载环境", + "13. text=记录 片名 用于判断资源是否提交过下载并进入整理流程", + "14. text=状态 片名 一次查看下载任务、下载历史和入库历史", + "15. text=识别 片名 使用 MoviePilot 原生识别确认 TMDB/Douban/IMDB 信息", + "16. text=订阅列表;搜索订阅 1 / 暂停订阅 1 / 恢复订阅 1 / 删除订阅 1 会先生成计划", + "17. text=入库记录;入库失败 片名 用于判断下载后是否已经整理落库", + "18. text=执行计划 执行当前会话最近待执行计划;text=执行 plan-xxxx 精确执行指定计划", + "19. text=偏好 / 保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库 / 重置偏好", + "20. text=后续 / 最近 / 入库 片名 / 诊断 片名 是更省 token 的本地/PT 跟踪短命令", + "21. text=跟进 / 跟进 片名 是统一跟进入口:有已执行计划时自动跟执行后状态,有片名时直接看生命周期", + "smart_pick 常用示例:", + "1. choice=1", + "2. action=详情", + "3. action=下一页", + "MP 搜索结果里,choice=1 会先展示 PT 详情和评分理由;确认下载再发 text=下载1。", + "MP 搜索结果里,action=最佳 会展示当前评分最高候选,适合智能体省 token 决策。", + "MP 搜索结果里,text=下载最佳 会按当前最高分候选生成下载计划,不会静默下载。", + "说明:同一个 session 会自动串起候选列表、资源列表、115 待任务与扫码续跑。", + self._format_p115_next_actions(self._p115_status_snapshot()), + ] + pending_summary = self._pending_p115_summary(self._load_session(self._assistant_session_id(session_name)) or {}) + if pending_summary: + lines.extend(["", pending_summary]) + return "\n".join(line for line in lines if line) + + def _assistant_capabilities_public_data(self) -> Dict[str, Any]: + return { + "version": self.plugin_version, + "defaults": { + "hdhive_path": self._hdhive_default_path, + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "p115_client_type": self._p115_client_type, + "hdhive_candidate_page_size": self._hdhive_candidate_page_size, + "hdhive_resource_enabled": self._hdhive_resource_enabled, + "hdhive_max_unlock_points": self._hdhive_max_unlock_points, + "hdhive_checkin_enabled": self._hdhive_checkin_enabled, + "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, + "pt_min_seeders": self._default_assistant_preferences().get("pt_min_seeders"), + "auto_ingest": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_enabled": self._default_assistant_preferences().get("auto_ingest_enabled"), + "auto_ingest_score_threshold": self._default_assistant_preferences().get("auto_ingest_score_threshold"), + "confirm_score_threshold": self._default_assistant_preferences().get("confirm_score_threshold"), + }, + "smart_entry": { + "supports_text": True, + "supports_structured_fields": True, + "modes": ["mp", "pansou", "hdhive"], + "actions": [ + "assistant_help", + "p115_qrcode_start", + "p115_qrcode_check", + "p115_status", + "p115_help", + "p115_pending", + "p115_resume", + "p115_cancel", + "hdhive_checkin", + "hdhive_checkin_history", + "mp_media_detail", + "mp_download_tasks", + "mp_download_history", + "mp_lifecycle_status", + "mp_download_control", + "mp_downloaders", + "mp_sites", + "mp_subscribes", + "mp_subscribe_control", + "mp_transfer_history", + "mp_download", + "mp_download_best", + "mp_subscribe", + "mp_subscribe_search", + "mp_recommendations", + "execute_plan", + "plans_list", + "plans_clear", + "scoring_policy", + "preferences_get", + "preferences_save", + "preferences_reset", + ], + "structured_fields": [ + "session", + "session_id", + "path", + "mode", + "keyword", + "url", + "access_code", + "media_type", + "year", + "client_type", + "is_gambler", + "action", + "plan_id", + "status", + "hash", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "downloader", + "download_control", + "subscribe_control", + "delete_files", + "page", + "compact", + ], + }, + "assistant_preferences": { + "fields": ["session", "session_id", "user_key", "preferences", "reset", "compact"], + "description": "智能体片源偏好画像:云盘与 PT 分源评分都会读取这里;无偏好时建议先完成一次偏好询问。", + }, + "scoring_policy": self._assistant_scoring_policy_public_data(), + "smart_pick": { + "fields": ["session", "session_id", "choice", "action", "path", "compact"], + "actions": ["detail", "next_page"], + }, + "assistant_session": { + "fields": ["session", "session_id", "compact"], + "description": "compact=true 时返回低 token 会话快照,不嵌套完整 session_state。", + }, + "assistant_capabilities": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 能力清单,不嵌套完整 session_state。", + }, + "assistant_readiness": { + "fields": ["compact"], + "description": "compact=true 时返回低 token 就绪状态,不嵌套完整 session_state。", + }, + "assistant_sessions": { + "fields": ["kind", "has_pending_p115", "compact", "limit"], + "description": "compact=true 时返回低 token 会话列表,不嵌套 default session_state。", + }, + "assistant_history": { + "fields": ["session", "session_id", "compact", "limit"], + "description": "compact=true 时返回低 token 执行历史,不嵌套 default session_state。", + }, + "assistant_action": { + "fields": [ + "name", + "session", + "session_id", + "choice", + "path", + "keyword", + "media_type", + "year", + "url", + "access_code", + "client_type", + "source", + "status", + "hash", + "target", + "name", + "site_name", + "subscribe_id", + "subscribe_name", + "control", + "subscribe_control", + "downloader", + "delete_files", + "kind", + "has_pending_p115", + "stale_only", + "all_sessions", + "limit", + "page", + "plan_id", + "prefer_unexecuted", + "compact", + ], + "description": "compact=true 时返回低 token 单动作摘要,不嵌套完整 session_state。", + }, + "assistant_actions": { + "fields": [ + "actions", + "session", + "session_id", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 批量执行摘要,不嵌套完整 session_state。", + }, + "assistant_workflow": self._assistant_workflow_catalog(), + "assistant_plan_execute": { + "fields": [ + "plan_id", + "session", + "session_id", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + ], + "description": "compact=true 时返回低 token 计划执行摘要,不嵌套完整 session_state。", + }, + "assistant_plans": { + "fields": [ + "session", + "session_id", + "executed", + "include_actions", + "compact", + "limit", + ], + "description": "compact=true 时返回低 token 计划列表,不嵌套 default session_state。", + }, + "assistant_plans_clear": { + "fields": [ + "plan_id", + "session", + "session_id", + "executed", + "all_plans", + "limit", + ], + }, + "assistant_recover": { + "fields": [ + "session", + "session_id", + "execute", + "prefer_unexecuted", + "stop_on_error", + "include_raw_results", + "compact", + "limit", + ], + "description": "单入口恢复协议:不传 session 时自动挑选最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可返回低 token 回执。", + }, + "assistant_maintain": { + "fields": [ + "execute", + "limit", + ], + "description": "低风险维护入口:execute=false 只返回建议;execute=true 清理过期会话和已执行计划,不清理待执行计划。", + }, + "assistant_request_templates": { + "fields": [ + "limit", + ], + "description": "轻量请求模板入口:返回外部智能体常用 assistant 请求模板,适合缓存为调用说明。", + }, + "session_tools": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/toolbox", + "assistant/request_templates", + "assistant/selfcheck", + "assistant/readiness", + "assistant/history", + "assistant/action", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + "assistant/plans/clear", + "assistant/recover", + "assistant/sessions", + "assistant/sessions/clear", + "assistant/session", + "assistant/session/clear", + ], + "response_envelope": { + "fields": [ + "protocol_version", + "action", + "ok", + "session", + "session_id", + "session_state", + "next_actions", + "action_templates", + ], + "description": "assistant/route 与 assistant/pick 返回的 data 中会统一附带当前会话状态、建议下一步动作与可直接调用的动作模板,上层智能体可直接按结构化字段继续编排。", + }, + "agent_tools": [ + "agent_resource_officer_capabilities", + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_toolbox", + "agent_resource_officer_request_templates", + "agent_resource_officer_selfcheck", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_history", + "agent_resource_officer_execute_action", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_plans", + "agent_resource_officer_plans_clear", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_preferences", + "agent_resource_officer_help", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + "agent_resource_officer_sessions", + "agent_resource_officer_sessions_clear", + "agent_resource_officer_session_state", + "agent_resource_officer_session_clear", + ], + } + + def _assistant_capabilities_compact_data(self, capabilities_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(capabilities_data or {}) + workflow_catalog = dict(data.get("assistant_workflow") or {}) + workflows = [ + self._clean_text(item.get("name")) + for item in workflow_catalog.get("workflows") or [] + if isinstance(item, dict) and self._clean_text(item.get("name")) + ] + compact_endpoints = [ + "assistant/capabilities", + "assistant/startup", + "assistant/maintain", + "assistant/request_templates", + "assistant/readiness", + "assistant/recover", + "assistant/session", + "assistant/sessions", + "assistant/history", + "assistant/actions", + "assistant/workflow", + "assistant/preferences", + "assistant/plan/execute", + "assistant/plans", + ] + return { + "protocol_version": "assistant.v1", + "action": "capabilities", + "ok": True, + "compact": True, + "version": data.get("version"), + "defaults": data.get("defaults") or {}, + "smart_entry_modes": (data.get("smart_entry") or {}).get("modes") or [], + "smart_entry_actions": (data.get("smart_entry") or {}).get("actions") or [], + "smart_pick_actions": (data.get("smart_pick") or {}).get("actions") or [], + "workflows": workflows, + "request_templates": bool(data.get("assistant_request_templates")), + "scoring_policy": data.get("scoring_policy") or {}, + "recommended_start": [ + "assistant/pulse", + "assistant/startup", + "assistant/maintain", + "assistant/selfcheck", + "assistant/toolbox", + "assistant/request_templates", + "assistant/readiness?compact=true", + ], + "compact_endpoints": compact_endpoints, + "agent_tools": data.get("agent_tools") or [], + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_readiness", "smart_entry", "assistant_workflow"], + } + + def _format_assistant_capabilities_text(self) -> str: + data = self._assistant_capabilities_public_data() + defaults = data.get("defaults") or {} + lines = [ + "Agent影视助手 能力说明", + f"版本:{data.get('version')}", + "推荐上层调用顺序:", + "1. 先看 capabilities 或 assistant/startup", + "2. 如需恢复会话,可先看 assistant/sessions", + "3. 再调用 smart_entry", + "4. 之后用 assistant/session 或 session_state 判断下一步", + "5. 最后再调用 smart_pick 或 session_clear", + "默认目录:", + f"- 影巢:{defaults.get('hdhive_path')}", + f"- 115:{defaults.get('p115_path')}", + f"- 夸克:{defaults.get('quark_path')}", + f"- 115 客户端:{defaults.get('p115_client_type')}", + f"影巢资源入口:{'开启' if defaults.get('hdhive_resource_enabled') else '关闭'};单资源积分上限:{defaults.get('hdhive_max_unlock_points')} 分(0 表示不限制)", + "默认评分策略:", + f"- PT 最低做种数:{defaults.get('pt_min_seeders')}", + f"- 建议确认分数线:{defaults.get('confirm_score_threshold')}", + f"- 自动入库:{'开启' if defaults.get('auto_ingest_enabled') else '关闭'};自动入库分数线:{defaults.get('auto_ingest_score_threshold')}", + "启动聚合包:assistant/startup,一次返回 pulse、自检、核心工具、端点和恢复建议,适合外部智能体开场调用", + "轻量启动探针:assistant/pulse,返回版本、关键服务状态与最佳恢复建议,适合外部智能体每次开场调用", + "轻量工具清单:assistant/toolbox,返回推荐工具、端点、工作流和命令示例,适合外部智能体初始化系统提示", + "轻量协议自检:assistant/selfcheck,返回 compact 模板、布尔解析和低 token 入口健康状态", + "启动探针:assistant/readiness,可直接判断外部智能体是否可以开始调用;compact=true 可减少嵌套回执", + "执行历史:assistant/history,可查看最近 action/workflow 的成功状态和摘要;compact=true 可减少嵌套回执", + "smart_entry 结构化字段:session / session_id / path / mode / keyword / url / access_code / media_type / year / client_type / action / plan_id", + "smart_entry 结构化模式:mp / pansou / hdhive", + "smart_entry 动作:assistant_help / p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel", + "smart_entry 与 smart_pick 支持 compact=true,可减少搜索与选择链路嵌套回执", + "smart_pick 字段:session / session_id / choice / action / path / compact", + "smart_pick 动作:detail / next_page", + "动作执行入口:assistant/action,可直接执行 action_templates 里的 name + body;compact=true 可减少嵌套回执", + "批量动作入口:assistant/actions,可一次执行多步 action_body;compact=true 可减少嵌套回执", + "预设工作流入口:assistant/workflow,可用 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / share_transfer / p115_status 等短参数场景;compact=true 可减少嵌套回执", + "计划执行入口:assistant/plan/execute,可执行 dry_run 返回的 plan_id;compact=true 可减少嵌套回执", + "自然语言计划确认:smart_entry 支持“执行计划”和“执行 plan-xxxx”,用于确认已生成的下载、订阅或控制计划", + "计划管理入口:assistant/plans 与 assistant/plans/clear,可查询或清理 dry_run 保存的计划;compact=true 可减少嵌套回执", + "单入口恢复:assistant/recover,可自动选择最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可减少回执字段", + "统一回执字段:protocol_version / action / ok / session / session_id / session_state / next_actions / action_templates", + ] + return "\n".join(lines) + + def _assistant_readiness_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + sessions = self._assistant_sessions_public_data(limit=10) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_resource_enabled: + warnings.append("影巢资源搜索/解锁已关闭,外部智能体应改用 MP 搜索或盘搜") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置,影巢相关工作流不可用") + if not p115_status.get("ready"): + warnings.append("115 当前不可用,需要先扫码或修复执行层") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置,夸克转存可能需要先刷新") + + ready_for_external_agent = bool(self._enabled) + pending_plans = self._assistant_plans_public_data(executed=False, limit=5) + pending_plan_templates = [ + self._assistant_action_template( + name="execute_plan", + description="执行待处理计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + tool="agent_resource_officer_execute_plan", + body={ + "plan_id": item.get("plan_id"), + "session": item.get("session"), + "session_id": item.get("session_id"), + "prefer_unexecuted": True, + }, + ) + for item in (pending_plans.get("items") or []) + if isinstance(item, dict) and self._clean_text(item.get("plan_id")) + ] + return { + "version": self.plugin_version, + "enabled": self._enabled, + "ready_for_external_agent": ready_for_external_agent, + "can_start": ready_for_external_agent, + "services": { + "p115": p115_status, + "hdhive": { + "configured": bool(self._hdhive_api_key), + "base_url": self._hdhive_base_url, + "default_path": self._hdhive_default_path, + }, + "quark": { + "configured": bool(self._quark_cookie), + "default_path": self._quark_default_path, + "auto_import_cookiecloud": self._quark_auto_import_cookiecloud, + }, + }, + "active_sessions": { + "total": sessions.get("total") or 0, + "preview": sessions.get("items") or [], + }, + "saved_plans": { + "total": len(self._workflow_plans or {}), + "pending": len(pending_plans.get("items") or []), + "pending_preview": pending_plans.get("items") or [], + "action_templates": pending_plan_templates, + }, + "recovery": ( + { + "mode": "resume_saved_plan", + "reason": "当前存在待执行计划,可直接恢复", + "can_resume": True, + "recommended_action": self._clean_text((pending_plan_templates[0] or {}).get("name")) if pending_plan_templates else "", + "recommended_tool": self._clean_text((pending_plan_templates[0] or {}).get("tool")) if pending_plan_templates else "", + "action_template": pending_plan_templates[0] if pending_plan_templates else None, + "alternatives": [ + self._clean_text(item.get("name")) + for item in pending_plan_templates[:5] + if self._clean_text(item.get("name")) + ], + } + if pending_plan_templates + else { + "mode": "start_new", + "reason": "当前没有待恢复计划,可直接开始新任务", + "can_resume": False, + "recommended_action": "", + "recommended_tool": "", + "action_template": None, + "alternatives": [], + } + ), + "recommended_entrypoints": [ + "GET /api/v1/plugin/AgentResourceOfficer/assistant/startup", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/readiness", + "GET /api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/actions", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "POST /api/v1/plugin/AgentResourceOfficer/assistant/route", + ], + "recommended_tools": [ + "agent_resource_officer_startup", + "agent_resource_officer_readiness", + "agent_resource_officer_feishu_health", + "agent_resource_officer_run_workflow", + "agent_resource_officer_execute_actions", + "agent_resource_officer_execute_plan", + "agent_resource_officer_smart_entry", + ], + "warnings": warnings, + "suggested_first_call": { + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "body": { + "name": "pansou_search", + "session": "external-agent-demo", + "keyword": "片名", + }, + }, + } + + def _assistant_readiness_compact_data(self, readiness_data: Dict[str, Any]) -> Dict[str, Any]: + data = dict(readiness_data or {}) + services = dict(data.get("services") or {}) + p115 = dict(services.get("p115") or {}) + hdhive = dict(services.get("hdhive") or {}) + quark = dict(services.get("quark") or {}) + recovery = dict(data.get("recovery") or {}) + saved_plans = dict(data.get("saved_plans") or {}) + template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None + return { + "protocol_version": "assistant.v1", + "action": "readiness", + "ok": bool(data.get("can_start")), + "compact": True, + "version": data.get("version"), + "enabled": bool(data.get("enabled")), + "can_start": bool(data.get("can_start")), + "services": { + "p115_ready": bool(p115.get("ready")), + "hdhive_configured": bool(hdhive.get("configured")), + "quark_configured": bool(quark.get("configured")), + }, + "active_sessions_total": (data.get("active_sessions") or {}).get("total") or 0, + "saved_plans_total": saved_plans.get("total") or 0, + "saved_plans_pending": saved_plans.get("pending") or 0, + "recovery": { + "mode": self._clean_text(recovery.get("mode")), + "can_resume": bool(recovery.get("can_resume")), + "recommended_action": self._clean_text(recovery.get("recommended_action")), + "recommended_tool": self._clean_text(recovery.get("recommended_tool")), + "reason": self._clean_text(recovery.get("reason")), + }, + "warnings": data.get("warnings") or [], + "next_actions": [ + item for item in [ + recovery.get("recommended_action") if recovery.get("can_resume") else "", + "assistant_workflow", + "smart_entry", + ] + if item + ], + "action_templates": [template] if template else [], + } + + def _format_assistant_readiness_text(self) -> str: + data = self._assistant_readiness_public_data() + services = data.get("services") or {} + p115 = services.get("p115") or {} + hdhive = services.get("hdhive") or {} + quark = services.get("quark") or {} + lines = [ + "Agent影视助手 启动就绪", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"外部智能体:{'可以启动' if data.get('can_start') else '暂不可启动'}", + f"115:{'可用' if p115.get('ready') else '不可用'}", + f"影巢:{'已配置' if hdhive.get('configured') else '未配置'}", + f"夸克:{'已配置' if quark.get('configured') else '未配置'}", + f"活跃会话:{(data.get('active_sessions') or {}).get('total') or 0}", + f"待执行计划:{(data.get('saved_plans') or {}).get('pending') or 0}", + "推荐入口:assistant/workflow 或 assistant/actions", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_pulse_public_data(self) -> Dict[str, Any]: + p115_status = self._p115_status_snapshot() + recovery_data = self._assistant_recover_public_data(limit=10) + recovery_data.update({ + "action": "recover", + "ok": True, + "execute_requested": False, + "executed": False, + }) + recovery_compact = self._assistant_recover_response_data(recovery_data, compact=True) + warnings: List[str] = [] + if not self._enabled: + warnings.append("插件未启用") + if not self._hdhive_api_key: + warnings.append("影巢 API Key 未配置") + if not p115_status.get("ready"): + warnings.append("115 当前不可用") + if not self._quark_cookie: + warnings.append("夸克 Cookie 未配置") + return { + "protocol_version": "assistant.v1", + "action": "pulse", + "ok": bool(self._enabled), + "version": self.plugin_version, + "enabled": self._enabled, + "can_start": bool(self._enabled), + "services": { + "p115_ready": bool(p115_status.get("ready")), + "p115_direct_ready": bool(p115_status.get("direct_ready")), + "hdhive_configured": bool(self._hdhive_api_key), + "quark_configured": bool(self._quark_cookie), + }, + "warnings": warnings, + "session": recovery_compact.get("session"), + "session_id": recovery_compact.get("session_id"), + "recovery": recovery_compact.get("recovery") or {}, + "selected_session": recovery_compact.get("selected_session"), + "next_actions": recovery_compact.get("next_actions") or [], + "action_templates": recovery_compact.get("action_templates") or [], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + }, + } + + def _format_assistant_pulse_text(self) -> str: + data = self._assistant_pulse_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + lines = [ + "Agent影视助手 轻量启动状态", + f"版本:{data.get('version')}", + f"插件:{'已启用' if data.get('enabled') else '未启用'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'}", + f"影巢:{'已配置' if services.get('hdhive_configured') else '未配置'}", + f"夸克:{'已配置' if services.get('quark_configured') else '未配置'}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + ] + if recovery.get("recommended_action"): + lines.append(f"推荐动作:{recovery.get('recommended_action')}") + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + return "\n".join(lines) + + def _assistant_maintenance_snapshot(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + pending_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and not bool(item.get("executed")) + ) + executed_plan_count = sum( + 1 + for item in (self._workflow_plans or {}).values() + if isinstance(item, dict) and bool(item.get("executed")) + ) + stale_session_count = sum( + 1 + for item in (self._session_cache or {}).values() + if isinstance(item, dict) and self._is_session_expired(item) + ) + action_templates = [ + self._assistant_action_template( + name="clear_stale_sessions", + description="清理过期 assistant 会话,不影响仍有效的当前会话", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", + tool="agent_resource_officer_sessions_clear", + body={"stale_only": True, "limit": max_limit}, + ), + self._assistant_action_template( + name="clear_executed_plans", + description="清理已执行的保存计划,不影响待执行计划", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plans/clear", + tool="agent_resource_officer_plans_clear", + body={"executed": True, "limit": max_limit}, + ), + ] + recommended_actions = [ + item.get("name") + for item in action_templates + if ( + (item.get("name") == "clear_stale_sessions" and stale_session_count > 0) + or (item.get("name") == "clear_executed_plans" and executed_plan_count > 0) + ) + ] + return { + "active_sessions": len(self._session_cache or {}), + "stale_sessions": stale_session_count, + "saved_plans_total": len(self._workflow_plans or {}), + "saved_plans_pending": pending_plan_count, + "saved_plans_executed": executed_plan_count, + "recommended_actions": recommended_actions, + "action_templates": action_templates, + "safe_to_execute": bool(recommended_actions), + "dry_run_method": "GET", + "execute_method": "POST", + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "execute_body": {"execute": True, "limit": max_limit}, + "execution_note": "GET 只返回 dry-run;只有 POST execute=true 会实际清理,并写入 assistant/history。", + "limit": max_limit, + } + + def _assistant_maintain_public_data(self, execute: bool = False, limit: int = 100) -> Dict[str, Any]: + before = self._assistant_maintenance_snapshot(limit=limit) + executed_actions: List[Dict[str, Any]] = [] + if execute: + if before.get("stale_sessions", 0) > 0: + result = self._clear_assistant_sessions(stale_only=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_stale_sessions", + "success": True, + "removed": result.get("cleared_count") or 0, + }) + if before.get("saved_plans_executed", 0) > 0: + result = self._clear_workflow_plans(executed=True, limit=before.get("limit") or limit) + executed_actions.append({ + "name": "clear_executed_plans", + "success": bool(result.get("ok")), + "removed": result.get("removed") or 0, + }) + after = self._assistant_maintenance_snapshot(limit=limit) + return { + "protocol_version": "assistant.v1", + "action": "maintain", + "ok": True, + "compact": True, + "version": self.plugin_version, + "execute_requested": bool(execute), + "executed": bool(executed_actions), + "executed_actions": executed_actions, + "before": before, + "after": after, + "next_actions": after.get("recommended_actions") or [], + "action_templates": after.get("action_templates") or [], + } + + def _format_assistant_maintain_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_maintain_public_data(execute=False) + before = payload.get("before") or {} + after = payload.get("after") or before + lines = [ + "Agent影视助手 低风险维护", + f"版本:{payload.get('version')}", + f"执行:{'是' if payload.get('execute_requested') else '否'}", + "维护前:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**before), + "维护后:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**after), + ] + executed_actions = payload.get("executed_actions") or [] + if executed_actions: + lines.append("已执行:" + " / ".join(f"{item.get('name')}({item.get('removed')})" for item in executed_actions)) + recommended = after.get("recommended_actions") or [] + if recommended: + lines.append("仍建议:" + " / ".join(str(item) for item in recommended if item)) + return "\n".join(lines) + + def _assistant_startup_public_data(self) -> Dict[str, Any]: + pulse = self._assistant_pulse_public_data() + selfcheck = self._assistant_selfcheck_public_data() + toolbox = self._assistant_toolbox_public_data() + maintenance = self._assistant_maintenance_snapshot(limit=100) + request_templates = self._assistant_request_templates_public_data(limit=100) + tools = toolbox.get("tools") or {} + endpoints = toolbox.get("endpoints") or {} + key_names = [ + "startup", + "maintain", + "pulse", + "selfcheck", + "request_templates", + "recover", + "workflow", + "route", + "pick", + "execute_action", + "execute_actions", + ] + key_tools = {name: tools.get(name) for name in key_names if tools.get(name)} + key_endpoints = { + name: endpoints.get(name) + for name in ["startup", "maintain", "pulse", "selfcheck", "request_templates", "recover", "workflow", "action", "actions", "route", "pick"] + if endpoints.get(name) + } + recovery = pulse.get("recovery") or {} + recommended_templates_recipe = "continue" if bool(recovery.get("can_resume")) else "bootstrap" + recommended_templates_reason = ( + "检测到可恢复会话,优先读取继续会话流程。" + if recommended_templates_recipe == "continue" + else "未检测到必须恢复的会话,优先读取安全启动流程。" + ) + return { + "protocol_version": "assistant.v1", + "action": "startup", + "ok": bool(pulse.get("can_start")) and bool(selfcheck.get("ok")), + "compact": True, + "version": self.plugin_version, + "services": pulse.get("services") or {}, + "warnings": pulse.get("warnings") or [], + "session": pulse.get("session"), + "session_id": pulse.get("session_id"), + "recovery": pulse.get("recovery") or {}, + "selected_session": pulse.get("selected_session"), + "action_templates": pulse.get("action_templates") or [], + "maintenance": maintenance, + "selfcheck": { + "ok": bool(selfcheck.get("ok")), + "checks": selfcheck.get("checks") or {}, + }, + "defaults": toolbox.get("defaults") or {}, + "startup_order": toolbox.get("startup_order") or [], + "tools": key_tools, + "endpoints": key_endpoints, + "workflows": [item.get("name") for item in (toolbox.get("workflows") or []) if item.get("name")], + "actions": toolbox.get("actions") or [], + "command_examples": (toolbox.get("command_examples") or [])[:6], + "request_templates": request_templates, + "request_templates_schema_version": self.request_templates_schema_version, + "recommended_request_templates": self._assistant_recommended_request_templates_data( + recipe=recommended_templates_recipe, + reason=recommended_templates_reason, + ), + "next_actions": pulse.get("next_actions") or ["assistant_recover", "assistant_workflow", "smart_entry"], + "recommended_endpoints": key_endpoints, + } + + def _assistant_recommended_request_templates_data(self, recipe: str = "bootstrap", reason: str = "") -> Dict[str, Any]: + recipe_name = self._clean_text(recipe) or "bootstrap" + return { + "recipe": recipe_name, + "reason": self._clean_text(reason), + "include_templates": False, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/request_templates?apikey={MP_API_TOKEN}", + "tool": "agent_resource_officer_request_templates", + "tool_args": { + "recipe": recipe_name, + "include_templates": False, + }, + } + + def _format_assistant_startup_text(self) -> str: + data = self._assistant_startup_public_data() + services = data.get("services") or {} + recovery = data.get("recovery") or {} + checks = (data.get("selfcheck") or {}).get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 启动聚合包", + f"版本:{data.get('version')}", + f"可启动:{'是' if data.get('ok') else '否'}", + f"115:{'可用' if services.get('p115_ready') else '不可用'};影巢:{'已配' if services.get('hdhive_configured') else '未配'};夸克:{'已配' if services.get('quark_configured') else '未配'}", + f"自检:{'通过' if not failed else '失败 ' + ', '.join(failed)}", + f"恢复模式:{recovery.get('mode') or 'unknown'}", + f"可执行模板:{len(data.get('action_templates') or [])} 个", + "状态:活跃会话 {active_sessions};过期会话 {stale_sessions};保存计划 {saved_plans_total};待执行计划 {saved_plans_pending};已执行计划 {saved_plans_executed}".format(**(data.get("maintenance") or {})), + "下一步:优先按 recovery 建议执行;没有待恢复任务时使用 workflow 或 smart_entry", + ] + warnings = data.get("warnings") or [] + if warnings: + lines.append("提示:" + ";".join(str(item) for item in warnings if item)) + maintenance = data.get("maintenance") or {} + recommended_actions = maintenance.get("recommended_actions") or [] + if recommended_actions: + lines.append("维护建议:" + " / ".join(str(item) for item in recommended_actions if item)) + return "\n".join(lines) + + def _assistant_toolbox_public_data(self) -> Dict[str, Any]: + workflows = [dict(item or {}) for item in (self._assistant_workflow_catalog().get("workflows") or []) if isinstance(item, dict)] + return { + "protocol_version": "assistant.v1", + "action": "toolbox", + "ok": True, + "version": self.plugin_version, + "defaults": { + "p115_path": self._p115_default_path, + "quark_path": self._quark_default_path, + "hdhive_path": self._hdhive_default_path, + "p115_client_type": self._p115_client_type, + }, + "startup_order": [ + "agent_resource_officer_startup", + "agent_resource_officer_maintain", + "agent_resource_officer_pulse", + "agent_resource_officer_selfcheck", + "agent_resource_officer_request_templates", + "agent_resource_officer_recover", + "agent_resource_officer_run_workflow", + "agent_resource_officer_smart_entry", + "agent_resource_officer_smart_pick", + ], + "endpoints": { + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "request_templates": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", + "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "action": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", + "route": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "pick": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + }, + "tools": { + "startup": "agent_resource_officer_startup", + "maintain": "agent_resource_officer_maintain", + "pulse": "agent_resource_officer_pulse", + "toolbox": "agent_resource_officer_toolbox", + "request_templates": "agent_resource_officer_request_templates", + "selfcheck": "agent_resource_officer_selfcheck", + "recover": "agent_resource_officer_recover", + "workflow": "agent_resource_officer_run_workflow", + "route": "agent_resource_officer_smart_entry", + "pick": "agent_resource_officer_smart_pick", + "execute_action": "agent_resource_officer_execute_action", + "execute_actions": "agent_resource_officer_execute_actions", + }, + "workflows": [ + { + "name": item.get("name"), + "fields": item.get("fields") or [], + } + for item in workflows + ], + "actions": [ + "start_pansou_search", + "start_smart_resource_search", + "start_smart_resource_decision", + "start_smart_resource_plan", + "start_smart_resource_execute", + "pick_pansou_result", + "start_hdhive_search", + "pick_hdhive_candidate", + "candidate_detail", + "candidate_next_page", + "pick_hdhive_resource", + "route_share", + "start_115_login", + "check_115_login", + "show_115_status", + "resume_pending_115", + "execute_latest_plan", + "execute_session_latest_plan", + "query_mp_download_tasks", + "query_mp_download_history", + "query_mp_lifecycle_status", + "query_mp_ingest_status", + "query_execution_followup", + "query_mp_media_detail", + "query_mp_search_result_detail", + "query_mp_best_result_detail", + "pick_mp_best_download", + "query_mp_downloaders", + "query_mp_sites", + "query_mp_subscribes", + "query_mp_transfer_history", + "query_mp_ingest_failures", + "query_mp_recent_activity", + "query_mp_local_diagnose", + "clear_stale_sessions", + "clear_executed_plans", + ], + "command_examples": [ + "盘搜搜索 大君夫人", + "智能搜索 蜘蛛侠", + "影巢搜索 蜘蛛侠", + "1大君夫人", + "2蜘蛛侠", + "链接 https://pan.quark.cn/s/xxxx path=/飞书", + "选择 1", + "详情", + "下一页", + "识别 蜘蛛侠", + "115登录", + ], + "request_templates": self._assistant_request_templates_public_data(limit=100), + "request_templates_schema_version": self.request_templates_schema_version, + } + + def _assistant_request_templates_public_data(self, limit: int = 100) -> Dict[str, Any]: + max_limit = min(max(1, self._safe_int(limit, 100)), 500) + return { + "startup_probe": { + "description": "读取启动聚合包,适合外部智能体开场获取状态、端点、工具和恢复建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "tool": "agent_resource_officer_startup", + "tool_args": {}, + "query": {}, + }, + "selfcheck_probe": { + "description": "执行协议自检,确认模板、compact、布尔解析和核心入口是否健康。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", + "tool": "agent_resource_officer_selfcheck", + "tool_args": {}, + "query": {}, + }, + "maintain_preview": { + "description": "预览低风险维护建议,不执行清理;适合高频探测。", + "side_effect": "dry_run", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 30, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": False, "limit": max_limit}, + "query": {"execute": True, "limit": max_limit}, + }, + "maintain_execute": { + "description": "执行低风险维护,清理过期会话和已执行计划;会写入 assistant/history。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "tool": "agent_resource_officer_maintain", + "tool_args": {"execute": True, "limit": max_limit}, + "body": {"execute": True, "limit": max_limit}, + }, + "preferences_get": { + "description": "读取智能体片源偏好画像;未初始化时应先询问用户偏好。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": {"session": "assistant", "compact": True}, + "query": {"session": "assistant", "compact": True}, + }, + "preferences_save": { + "description": "保存智能体片源偏好画像;影响云盘与 PT 评分、自动化建议和安全阈值。", + "side_effect": "state", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", + "tool": "agent_resource_officer_preferences", + "tool_args": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + "body": { + "session": "assistant", + "preferences": self._default_assistant_preferences(), + "compact": True, + }, + }, + "scoring_policy": { + "description": "读取插件内置云盘/PT 评分策略、硬门槛和 score_summary 使用约定;只读,可缓存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "medium_lived", + "cache_ttl_seconds": 3600, + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/capabilities", + "tool": "agent_resource_officer_capabilities", + "tool_args": {"compact": True}, + "query": {"compact": True}, + "response_field": "scoring_policy", + }, + "workflow_dry_run": { + "description": "生成并保存工作流计划,不实际执行;适合先让用户确认。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "static_template", + "cache_ttl_seconds": 3600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": { + "name": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + "body": { + "workflow": "hdhive_candidates", + "keyword": "蜘蛛侠", + "media_type": "auto", + "session": "assistant", + "dry_run": True, + "compact": True, + }, + }, + "mp_search": { + "description": "执行 MP 原生搜索,返回 PT 候选与 PT 评分摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "smart_search": { + "description": "按用户偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只返回推荐意见,不直接下载或转存。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_decision": { + "description": "按用户偏好统一执行盘搜 -> 影巢 -> MP/PT 决策,并给出查看详情、生成计划或直接执行的首选建议。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_plan": { + "description": "按用户偏好自动搜索并为当前首选生成待确认 plan_id,不直接执行下载、解锁或转存。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "smart_search_execute": { + "description": "按用户偏好自动搜索当前首选并立即执行写入动作;仅在用户已明确要求直接执行时使用。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_media_detail": { + "description": "使用 MoviePilot 原生识别确认片名、年份、类型和 TMDB/Douban/IMDB ID;适合搜索前消歧。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, + }, + "mp_search_detail": { + "description": "执行 MP 原生搜索并查看指定编号的 PT 详情和评分理由;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, + }, + "mp_search_best": { + "description": "执行 MP 原生搜索并展示当前评分最高的 PT 候选详情;只读,不下载。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 600, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "mp_best_download_plan": { + "description": "在已有 MP 搜索会话中按当前最高分候选生成下载计划;不会直接下载。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + "body": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, + }, + "mp_search_download_plan": { + "description": "MP 原生搜索并选择编号下载;写入动作默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_tasks": { + "description": "查询 MP 下载任务状态,可按下载中、等待、已暂停等状态过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_download_control_plan": { + "description": "暂停、恢复或删除 MP 下载任务;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_download_history": { + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_lifecycle_status": { + "description": "一次查询 MP 下载任务、下载历史和整理/入库历史,适合追踪资源当前卡在哪一步。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_ingest_status": { + "description": "按片名或 hash 输出下载到入库的当前阶段,并附带结构化 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_downloaders": { + "description": "查询 MP 下载器配置摘要,不返回密码、Cookie 或 Token。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_downloaders", "session": "assistant", "compact": True}, + "body": {"workflow": "mp_downloaders", "session": "assistant", "compact": True}, + }, + "mp_sites": { + "description": "查询 MP PT 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, + }, + "mp_subscribe_plan": { + "description": "按关键词创建 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribe_search_plan": { + "description": "创建 MP 订阅并立即触发搜索;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_and_search", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_subscribes": { + "description": "查询 MP 订阅列表,可按状态、类型和关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_subscribe_control_plan": { + "description": "搜索、暂停、恢复或删除 MP 订阅;默认只生成 plan_id,确认后执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_transfer_history": { + "description": "查询 MP 最近整理/入库历史,判断下载后是否已经落库;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_ingest_failures": { + "description": "聚合最近整理/入库失败记录,并返回失败态 diagnosis_summary。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_failed_samples": { + "description": "读取 AI 识别增强插件保存的失败样本,可按关键词过滤;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_worklist": { + "description": "读取 AI 失败样本工作清单,适合先挑需要二次识别重放的样本;只返回摘要。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, + }, + "ai_sample_insights": { + "description": "读取 AI 失败样本洞察,查看主要失败原因、重复样本组和优先处理项。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, + }, + "ai_replay_failed_sample_plan": { + "description": "对指定 AI 失败样本生成二次识别重放计划;确认后才会真正执行。", + "side_effect": "plan_write", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + "body": {"workflow": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, + }, + "mp_recent_activity": { + "description": "查看最近下载和最近入库活动,适合先发现目标再进入单资源追踪。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 120, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, + }, + "mp_local_diagnose": { + "description": "面向“为什么没入库/卡在哪”的一站式只读诊断入口。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, + }, + "mp_recommend": { + "description": "读取 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "mp_recommend_search": { + "description": "读取 MP 原生推荐并按编号继续搜索;mode 可选 smart_decision、smart_plan、smart_execute、mp、hdhive、pansou。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "session_cache", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, + }, + "smart_discovery": { + "description": "读取 MP 原生热门推荐,并优先引导到统一资源决策链。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 300, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + "body": {"workflow": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, + }, + "saved_plan_execute": { + "description": "执行已保存的 dry_run 工作流计划,可按 session 自动选择未执行计划。", + "side_effect": "write", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "tool": "agent_resource_officer_execute_plan", + "tool_args": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + "body": { + "session": "assistant", + "prefer_unexecuted": True, + "compact": True, + }, + }, + "execution_followup": { + "description": "按最近已执行计划自动追踪下载、订阅或入库后续状态,由插件决定先查哪个只读动作。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "query_execution_followup", + "session": "assistant", + "compact": True, + }, + }, + "smart_followup": { + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动。", + "side_effect": "read_only", + "requires_confirmation": False, + "cache_scope": "short_lived", + "cache_ttl_seconds": 60, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "tool": "agent_resource_officer_run_workflow", + "tool_args": {"name": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + "body": {"workflow": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, + }, + "action_execute": { + "description": "按动作名执行单个 action template,适合无映射继续执行。", + "side_effect": "depends_on_action", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "tool": "agent_resource_officer_execute_action", + "tool_args": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + "body": { + "name": "show_115_status", + "session": "assistant", + "compact": True, + }, + }, + "route_text": { + "description": "统一自然语言入口,适合 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体直接转发用户文本。", + "side_effect": "depends_on_text", + "requires_confirmation": False, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + "body": { + "text": "盘搜搜索 大君夫人", + "session": "agent:demo", + "compact": True, + }, + }, + "pick_continue": { + "description": "按编号继续当前会话,适合盘搜、影巢候选或资源列表选择。", + "side_effect": "depends_on_session", + "requires_confirmation": True, + "cache_scope": "no_cache", + "cache_ttl_seconds": 0, + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", + "tool": "agent_resource_officer_smart_pick", + "tool_args": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + "body": { + "session": "agent:demo", + "choice": 1, + "compact": True, + }, + }, + } + + def _assistant_request_template_names(self, value: Any) -> List[str]: + if isinstance(value, (list, tuple, set)): + rows = value + else: + rows = re.split(r"[,,\s]+", self._clean_text(value)) + names: List[str] = [] + for item in rows: + name = self._clean_text(item) + if name and name not in names: + names.append(name) + return names + + def _assistant_request_templates_response_data( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> Dict[str, Any]: + all_templates = self._assistant_request_templates_public_data(limit=limit) + recipe_templates_map = { + "safe_bootstrap": ["startup_probe", "selfcheck_probe", "maintain_preview"], + "plan_then_confirm": ["workflow_dry_run", "saved_plan_execute"], + "post_execute_followup": ["smart_followup", "execution_followup", "mp_download_history", "mp_lifecycle_status", "mp_subscribes", "mp_transfer_history"], + "continue_existing_session": ["pick_continue"], + "maintenance_cycle": ["maintain_preview", "maintain_execute"], + "external_agent_quickstart": ["startup_probe", "route_text", "pick_continue"], + "preferences_onboarding": ["preferences_get", "scoring_policy", "preferences_save"], + "smart_search": ["smart_search", "preferences_get", "scoring_policy"], + "smart_decision": ["smart_decision", "preferences_get", "scoring_policy"], + "smart_search_plan": ["smart_search_plan", "preferences_get", "scoring_policy", "saved_plan_execute"], + "smart_search_execute": ["smart_search_execute", "preferences_get", "scoring_policy", "post_execute_followup"], + "mp_pt_mainline": [ + "mp_media_detail", + "mp_search", + "mp_search_detail", + "mp_search_best", + "mp_search_download_plan", + "mp_best_download_plan", + "mp_download_tasks", + "mp_download_control_plan", + "mp_download_history", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_downloaders", + "mp_sites", + "mp_subscribe_plan", + "mp_subscribe_search_plan", + "mp_subscribes", + "mp_subscribe_control_plan", + "mp_transfer_history", + "mp_ingest_failures", + "mp_recent_activity", + "mp_local_diagnose", + "saved_plan_execute", + ], + "mp_recommendation": [ + "smart_discovery", + "mp_recommend", + "mp_recommend_search", + "mp_search", + "mp_search_best", + "mp_search_download_plan", + "saved_plan_execute", + ], + "ai_reingest_readonly": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + ], + "ai_reingest": [ + "mp_ingest_failures", + "mp_local_diagnose", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "ai_replay_failed_sample_plan", + "saved_plan_execute", + "execution_followup", + ], + "local_ingest": [ + "smart_followup", + "mp_lifecycle_status", + "mp_ingest_status", + "mp_download_history", + "mp_transfer_history", + "mp_ingest_failures", + "ai_failed_samples", + "ai_sample_worklist", + "ai_sample_insights", + "mp_recent_activity", + "mp_local_diagnose", + ], + } + recipe_aliases = { + "bootstrap": "safe_bootstrap", + "safe": "safe_bootstrap", + "start": "safe_bootstrap", + "启动": "safe_bootstrap", + "plan": "plan_then_confirm", + "dry_run": "plan_then_confirm", + "confirm": "plan_then_confirm", + "计划": "plan_then_confirm", + "followup": "post_execute_followup", + "follow": "post_execute_followup", + "post_execute": "post_execute_followup", + "post-execute": "post_execute_followup", + "after_execute": "post_execute_followup", + "after-execute": "post_execute_followup", + "执行后": "post_execute_followup", + "执行后追踪": "post_execute_followup", + "后续追踪": "post_execute_followup", + "跟进": "post_execute_followup", + "进展": "post_execute_followup", + "continue": "continue_existing_session", + "pick": "continue_existing_session", + "resume": "continue_existing_session", + "继续": "continue_existing_session", + "选择": "continue_existing_session", + "maintain": "maintenance_cycle", + "maintenance": "maintenance_cycle", + "cleanup": "maintenance_cycle", + "维护": "maintenance_cycle", + "external_agent": "external_agent_quickstart", + "external-agent": "external_agent_quickstart", + "agent": "external_agent_quickstart", + "外部智能体": "external_agent_quickstart", + "微信智能体": "external_agent_quickstart", + "workbuddy": "external_agent_quickstart", + "work_buddy": "external_agent_quickstart", + "workbody": "external_agent_quickstart", + "work_body": "external_agent_quickstart", + "preferences": "preferences_onboarding", + "preference": "preferences_onboarding", + "prefs": "preferences_onboarding", + "pref": "preferences_onboarding", + "片源偏好": "preferences_onboarding", + "偏好画像": "preferences_onboarding", + "评分偏好": "preferences_onboarding", + "mp": "mp_pt_mainline", + "pt": "mp_pt_mainline", + "mp_native": "mp_pt_mainline", + "mp-native": "mp_pt_mainline", + "mp_pt": "mp_pt_mainline", + "mp-pt": "mp_pt_mainline", + "pt_mainline": "mp_pt_mainline", + "pt-mainline": "mp_pt_mainline", + "原生mp": "mp_pt_mainline", + "mp原生": "mp_pt_mainline", + "原生搜索": "mp_pt_mainline", + "pt下载": "mp_pt_mainline", + "下载订阅": "mp_pt_mainline", + "local_ingest": "local_ingest", + "local-ingest": "local_ingest", + "ingest": "local_ingest", + "local": "local_ingest", + "本地入库": "local_ingest", + "入库诊断": "local_ingest", + "ai_reingest": "ai_reingest", + "ai-reingest": "ai_reingest", + "失败样本": "ai_reingest_readonly", + "失败样本诊断": "ai_reingest_readonly", + "识别重放": "ai_reingest", + "recommend": "mp_recommendation", + "recommendation": "mp_recommendation", + "discover": "mp_recommendation", + "discovery": "mp_recommendation", + "智能发现": "mp_recommendation", + "热门发现": "mp_recommendation", + "mp_recommend": "mp_recommendation", + "mp-recommend": "mp_recommendation", + "推荐": "mp_recommendation", + "热门": "mp_recommendation", + "smart_search": "smart_search", + "smart-search": "smart_search", + "smart": "smart_search", + "智能搜索": "smart_search", + "smart_decision": "smart_decision", + "smart-decision": "smart_decision", + "decision": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart_search_plan": "smart_search_plan", + "smart-search-plan": "smart_search_plan", + "smartplan": "smart_search_plan", + "智能计划": "smart_search_plan", + "smart_search_execute": "smart_search_execute", + "smart-search-execute": "smart_search_execute", + "smartexecute": "smart_search_execute", + "智能执行": "smart_search_execute", + } + requested_recipe = self._clean_text(recipe) + selected_recipe = recipe_aliases.get(requested_recipe, requested_recipe) + invalid_recipe = requested_recipe if requested_recipe and selected_recipe not in recipe_templates_map else "" + selected_names = self._assistant_request_template_names(names) + if not selected_names and selected_recipe in recipe_templates_map: + selected_names = list(recipe_templates_map[selected_recipe]) + invalid_names = [name for name in selected_names if name not in all_templates] + templates = { + name: all_templates[name] + for name in selected_names + if name in all_templates + } if selected_names else all_templates + confirmation_required = [ + name + for name, item in templates.items() + if bool((item or {}).get("requires_confirmation")) + ] + safe_without_confirmation = [ + name + for name, item in templates.items() + if not bool((item or {}).get("requires_confirmation")) + ] + write_side_effects = [ + name + for name, item in templates.items() + if self._clean_text((item or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ] + cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) > 0 + ] + non_cacheable_templates = [ + name + for name, item in templates.items() + if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) <= 0 + ] + auth = { + "mode": "query_apikey", + "query_param": "apikey", + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}", + "description": "调用插件 HTTP 接口时推荐使用 ?apikey=你的MP_API_TOKEN;MP Tool 调用不需要此参数。", + } + external_agent_execution_policy_contract = { + "auto_continue": "直接执行 auto_run_command。", + "auto_continue_then_wait_confirmation": "先执行 auto_run_command,再停止并向用户展示 confirm_command。", + "wait_user_confirmation": "不要自动执行;先向用户展示 confirm_command 或 display_command。", + "show_only": "只展示 display_command,不要自动继续。", + "stop": "当前没有适合自动继续的命令,不要继续执行。", + } + external_agent_execution_loop_contract = [ + { + "step": "startup", + "template": "startup_probe", + "when": "新会话开始时先读取启动聚合包。", + }, + { + "step": "decide", + "template": "startup_probe", + "when": "结合恢复信息和推荐 recipe,确定先继续会话还是开始新流程。", + }, + { + "step": "route", + "template": "route_text", + "when": "处理用户的自然语言资源指令。", + }, + { + "step": "policy", + "template": "", + "when": "读取 recommended_agent_behavior、auto_run_command、confirm_command 决定自动继续、确认或停止。", + }, + { + "step": "followup", + "template": "execution_followup", + "when": "执行计划后继续追踪下载、入库或失败诊断。", + }, + ] + entry_patterns = { + "external_agent": { + "label": "外部智能体", + "client_role": "客户端调度层", + "start_with": "startup", + "decide_with": "decide --summary-only", + "route_with": "route --summary-only", + "followup_with": "followup --summary-only", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "notes": "适用于 WorkBuddy、Hermes、OpenClaw(小龙虾)等外部智能体;优先使用 Skill/helper。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体", + "client_role": "客户端调度层", + "start_with": "assistant/request_templates", + "decide_with": "agent_resource_officer_request_templates", + "route_with": "agent_resource_officer_smart_entry", + "followup_with": "agent_resource_officer_execution_followup", + "read_fields": [ + "recommended_recipe_detail", + "recommended_agent_behavior", + "preferred_command", + "compact_commands", + ], + "notes": "优先走 Agent Tool / request_templates,不在模型侧直拼底层资源 API。", + }, + "feishu_channel": { + "label": "飞书入口", + "client_role": "客户端消息入口", + "start_with": "feishu message -> route", + "decide_with": "插件内置命令解析", + "route_with": "route/pick/followup", + "followup_with": "followup / smart_followup", + "read_fields": [ + "recommended_agent_behavior", + "preferred_command", + "fallback_command", + "compact_commands", + ], + "notes": "飞书只负责把消息送进同一套 assistant 协议;确认策略与外部智能体保持一致。", + }, + } + orchestration_contract = { + "service_role": "Agent影视助手 / AgentResourceOfficer 负责服务端能力执行。", + "client_role": "外部智能体、MP 内置智能体、飞书入口负责客户端调度与展示。", + "recommended_first_call": "startup", + "recommended_decision_call": "decide --summary-only", + "recommended_route_call": "route --summary-only", + "recommended_followup_call": "followup --summary-only", + "recommended_read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "confirmation_rule": "写入动作默认确认制;只有明确标记可自动继续的只读步骤才自动续跑。", + } + entry_playbooks = { + "external_agent": { + "label": "外部智能体最小执行流", + "transport": "skill_helper_or_http", + "steps": [ + { + "step": "startup", + "helper_command": "python3 scripts/aro_request.py startup", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "读取启动状态、恢复建议和推荐 recipe。", + "read_fields": ["recommended_request_templates", "recovery", "services"], + }, + { + "step": "decide", + "helper_command": "python3 scripts/aro_request.py decide --summary-only", + "http_call": { + "method": "GET", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", + }, + "purpose": "决定继续会话、初始化偏好还是直接进入 route。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "confirm_command"], + }, + { + "step": "route", + "helper_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/route?apikey={MP_API_TOKEN}", + }, + "purpose": "执行自然语言主入口。", + "read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "preferred_command", + "compact_commands", + ], + }, + { + "step": "followup", + "helper_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", + "http_call": { + "method": "POST", + "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", + "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/action?apikey={MP_API_TOKEN}", + }, + "purpose": "执行计划后继续追踪下载、入库或失败诊断。", + "read_fields": ["recommended_agent_behavior", "auto_run_command", "followup_summary"], + }, + ], + "auto_rule": "优先读取 recommended_agent_behavior;只读步骤可自动续跑,写入步骤默认确认。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体最小执行流", + "transport": "moviepilot_agent_tool", + "steps": [ + { + "step": "request_templates", + "tool": "agent_resource_officer_request_templates", + "tool_args": {"recipe": "external_agent", "compact": True}, + "purpose": "读取最小流程、确认策略和推荐入口。", + "read_fields": ["orchestration_contract", "entry_patterns", "recommended_recipe_detail"], + }, + { + "step": "route", + "tool": "agent_resource_officer_smart_entry", + "tool_args": {"text": "<用户原始指令>"}, + "purpose": "处理搜索、链接、登录状态等主入口。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "followup", + "tool": "agent_resource_officer_execution_followup", + "tool_args": {}, + "purpose": "执行计划后继续查看下载、入库和失败状态。", + "read_fields": ["followup_summary", "preferred_command", "compact_commands"], + }, + ], + "auto_rule": "依然遵守服务端 compact 协议;不要在模型侧拼底层资源站点 API。", + }, + "feishu_channel": { + "label": "飞书入口最小执行流", + "transport": "feishu_channel", + "steps": [ + { + "step": "message_in", + "channel": "feishu", + "purpose": "用户消息进入内置 Channel。", + "read_fields": ["session", "text", "reply_target"], + }, + { + "step": "route", + "internal": "route / pick / followup", + "purpose": "复用同一套 assistant 协议,不维护单独状态机。", + "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], + }, + { + "step": "reply", + "channel": "feishu", + "purpose": "按确认策略回消息、展示编号或提示下一步。", + "read_fields": ["display_command", "confirm_command", "message"], + }, + ], + "auto_rule": "飞书只负责消息承载;写入动作依然由插件服务端统一确认。", + }, + } + recommended_sequence = [ + { + "step": "bootstrap", + "template": "startup_probe", + "when": "每次新会话开始时先读取启动聚合包。", + }, + { + "step": "healthcheck", + "template": "selfcheck_probe", + "when": "当外部智能体需要确认协议健康或怀疑环境变化时执行。", + }, + { + "step": "maintenance_preview", + "template": "maintain_preview", + "when": "长会话或多轮执行前先看是否有低风险维护建议。", + }, + { + "step": "plan", + "template": "workflow_dry_run", + "when": "需要先生成计划、等待用户确认或减少重复大 JSON 时使用。", + }, + { + "step": "execute_saved_plan", + "template": "saved_plan_execute", + "when": "已确认 dry_run 计划后执行。", + }, + { + "step": "post_execute_followup", + "template": "execution_followup", + "when": "计划执行成功后,自动决定先查下载历史、生命周期、订阅或入库历史。", + }, + { + "step": "continue_session", + "template": "pick_continue", + "when": "盘搜、影巢候选或资源列表需要按编号继续时使用。", + }, + { + "step": "preferences_onboarding", + "template": "preferences_get", + "when": "外部智能体首次接入时,先读取偏好和评分策略,再保存用户片源偏好。", + }, + { + "step": "mp_pt_mainline", + "template": "mp_search", + "when": "需要 MP 原生 PT 搜索、评分、下载计划、订阅或任务追踪时使用。", + }, + { + "step": "mp_recommendation", + "template": "mp_recommend", + "when": "需要 TMDB、豆瓣、Bangumi 等 MP 原生推荐,并继续搜索时使用。", + }, + ] + recipes = [ + { + "name": "safe_bootstrap", + "description": "新会话安全启动:先拿启动聚合包,再自检,再看维护建议。", + "templates": recipe_templates_map["safe_bootstrap"], + }, + { + "name": "plan_then_confirm", + "description": "先生成计划,等待用户确认后再执行保存计划。", + "templates": recipe_templates_map["plan_then_confirm"], + }, + { + "name": "post_execute_followup", + "description": "执行计划后的统一只读跟踪:由插件自动选择最合适的后续查询动作。", + "templates": recipe_templates_map["post_execute_followup"], + }, + { + "name": "continue_existing_session", + "description": "已有盘搜、影巢或资源会话时,直接按编号继续。", + "templates": recipe_templates_map["continue_existing_session"], + }, + { + "name": "maintenance_cycle", + "description": "先预览维护建议,再在确认后执行低风险维护。", + "templates": recipe_templates_map["maintenance_cycle"], + }, + { + "name": "external_agent_quickstart", + "description": "外部智能体接入:启动探测后,把用户文本交给统一入口,再按编号继续。", + "templates": recipe_templates_map["external_agent_quickstart"], + }, + { + "name": "preferences_onboarding", + "description": "首次接入时先读取偏好、评分策略,再保存用户的片源偏好画像。", + "templates": recipe_templates_map["preferences_onboarding"], + }, + { + "name": "smart_decision", + "description": "统一搜索并返回明确的下一步决策:查看详情、生成计划或直接执行。", + "templates": recipe_templates_map["smart_decision"], + }, + { + "name": "smart_search_plan", + "description": "统一搜索决策后,直接为当前首选生成待确认计划;仍需后续执行计划才会真正写入。", + "templates": recipe_templates_map["smart_search_plan"], + }, + { + "name": "smart_search_execute", + "description": "统一搜索决策后,直接执行当前首选写入动作;只适用于用户明确要求立即执行的场景。", + "templates": recipe_templates_map["smart_search_execute"], + }, + { + "name": "mp_pt_mainline", + "description": "MP 原生 PT 主线:识别、搜索、评分、下载计划、任务、订阅、站点和入库追踪。", + "templates": recipe_templates_map["mp_pt_mainline"], + }, + { + "name": "mp_recommendation", + "description": "MP 原生推荐主线:读取热门推荐,再按编号进入 MP、影巢或盘搜搜索。", + "templates": recipe_templates_map["mp_recommendation"], + }, + ] + recipe_summaries: List[Dict[str, Any]] = [] + for recipe in recipes: + template_names = [ + self._clean_text(item) + for item in (recipe.get("templates") or []) + if self._clean_text(item) + ] + current_templates = [ + templates[name] + for name in template_names + if isinstance(templates.get(name), dict) + ] + recipe_summaries.append({ + **recipe, + "requires_confirmation": any(bool(item.get("requires_confirmation")) for item in current_templates), + "has_write_effect": any( + self._clean_text(item.get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + for item in current_templates + ), + "cache_ttl_seconds": min( + [self._safe_int(item.get("cache_ttl_seconds"), 0) for item in current_templates] or [0] + ), + }) + recommended_recipe = "safe_bootstrap" + recommended_recipe_reason = "默认优先安全启动,先读取启动聚合包、自检并查看维护建议。" + selected_set = set(selected_names or []) + if selected_recipe in recipe_templates_map: + recommended_recipe = selected_recipe + recommended_recipe_reason = f"当前请求显式指定 recipe={selected_recipe}。" + elif {"workflow_dry_run", "saved_plan_execute"} & selected_set: + recommended_recipe = "plan_then_confirm" + recommended_recipe_reason = "当前模板集合包含 dry_run 或执行保存计划,更适合先计划后确认。" + elif "pick_continue" in selected_set: + recommended_recipe = "continue_existing_session" + recommended_recipe_reason = "当前模板集合包含 pick_continue,说明更像继续既有会话。" + elif {"preferences_get", "preferences_save", "scoring_policy"} & selected_set: + recommended_recipe = "preferences_onboarding" + recommended_recipe_reason = "当前模板集合包含偏好或评分策略模板,优先推荐偏好初始化流程。" + elif {"maintain_preview", "maintain_execute"} & selected_set: + recommended_recipe = "maintenance_cycle" + recommended_recipe_reason = "当前模板集合包含维护模板,优先推荐维护流程。" + recommended_recipe_detail = next( + (item for item in recipe_summaries if item.get("name") == recommended_recipe), + {}, + ) + recommended_recipe_templates = [ + name + for name in (recommended_recipe_detail.get("templates") or []) + if name in all_templates + ] + first_template = recommended_recipe_templates[0] if recommended_recipe_templates else "" + first_template_data = all_templates.get(first_template) or {} + recommended_recipe_calls = [] + for template_name in recommended_recipe_templates: + template_data = all_templates.get(template_name) or {} + if not template_data: + continue + recommended_recipe_calls.append({ + "template": template_name, + "auth": auth, + "method": template_data.get("method"), + "endpoint": template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(template_data.get("endpoint")), + ), + "query": template_data.get("query") or {}, + "body": template_data.get("body") or {}, + "tool": template_data.get("tool"), + "tool_args": template_data.get("tool_args") or {}, + "requires_confirmation": bool(template_data.get("requires_confirmation")), + "side_effect": template_data.get("side_effect"), + }) + recommended_recipe_detail = { + **recommended_recipe_detail, + "templates": recommended_recipe_templates, + "first_template": first_template, + "confirmation_required_templates": [ + name + for name in recommended_recipe_templates + if bool((all_templates.get(name) or {}).get("requires_confirmation")) + ], + "write_templates": [ + name + for name in recommended_recipe_templates + if self._clean_text((all_templates.get(name) or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} + ], + "first_call": { + "template": first_template, + "auth": auth, + "method": first_template_data.get("method"), + "endpoint": first_template_data.get("endpoint"), + "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( + "{endpoint}", + self._clean_text(first_template_data.get("endpoint")), + ), + "query": first_template_data.get("query") or {}, + "body": first_template_data.get("body") or {}, + "tool": first_template_data.get("tool"), + "tool_args": first_template_data.get("tool_args") or {}, + "requires_confirmation": bool(first_template_data.get("requires_confirmation")), + "side_effect": first_template_data.get("side_effect"), + } if first_template_data else {}, + "calls": recommended_recipe_calls, + } + if recommended_recipe == "external_agent_quickstart": + recommended_recipe_detail["execution_policy_contract"] = external_agent_execution_policy_contract + recommended_recipe_detail["execution_loop_contract"] = external_agent_execution_loop_contract + recommended_recipe_detail["entry_patterns"] = entry_patterns + recommended_recipe_detail["orchestration_contract"] = orchestration_contract + recommended_recipe_detail["entry_playbooks"] = entry_playbooks + confirmation_templates = recommended_recipe_detail.get("confirmation_required_templates") or [] + recommended_recipe_detail["first_confirmation_template"] = confirmation_templates[0] if confirmation_templates else "" + recommended_recipe_detail["confirmation_message"] = ( + f"执行 {recommended_recipe} 的 {recommended_recipe_detail['first_confirmation_template']} 前需要用户确认。" + if confirmation_templates + else f"{recommended_recipe} 当前推荐流程无需用户确认。" + ) + return { + "protocol_version": "assistant.v1", + "action": "request_templates", + "ok": True, + "compact": True, + "version": self.plugin_version, + "schema_version": self.request_templates_schema_version, + "auth": auth, + "templates_included": bool(include_templates), + "request_templates": templates if include_templates else {}, + "available_names": list(all_templates.keys()), + "available_recipes": list(recipe_templates_map.keys()), + "recipe_aliases": recipe_aliases, + "selected_names": selected_names, + "invalid_names": invalid_names, + "requested_recipe": requested_recipe, + "selected_recipe": selected_recipe if selected_recipe in recipe_templates_map else "", + "invalid_recipe": invalid_recipe, + "execution_policy": { + "safe_without_confirmation": safe_without_confirmation, + "confirmation_required": confirmation_required, + "write_side_effects": write_side_effects, + "cacheable_templates": cacheable_templates, + "non_cacheable_templates": non_cacheable_templates, + }, + "recommended_sequence": recommended_sequence, + "recipes": recipe_summaries, + "recommended_recipe": recommended_recipe, + "recommended_recipe_reason": recommended_recipe_reason, + "recommended_recipe_detail": recommended_recipe_detail, + "external_agent_execution_policy_contract": external_agent_execution_policy_contract, + "external_agent_execution_loop_contract": external_agent_execution_loop_contract, + "entry_patterns": entry_patterns, + "orchestration_contract": orchestration_contract, + "entry_playbooks": entry_playbooks, + } + + def _format_assistant_request_templates_text(self, data: Optional[Dict[str, Any]] = None) -> str: + payload = data or self._assistant_request_templates_response_data() + templates = payload.get("request_templates") or {} + lines = [ + "Agent影视助手 请求模板", + f"版本:{payload.get('version')}", + ] + detail = payload.get("recommended_recipe_detail") or {} + first_call = detail.get("first_call") or {} + if payload.get("recommended_recipe"): + lines.append(f"推荐流程:{payload.get('recommended_recipe')}") + if detail.get("first_template"): + lines.append( + "首步:{template} -> {method} {endpoint}".format( + template=detail.get("first_template"), + method=first_call.get("method") or "", + endpoint=first_call.get("endpoint") or "", + ).strip() + ) + if detail.get("confirmation_message"): + lines.append(f"确认提示:{detail.get('confirmation_message')}") + if detail.get("execution_policy_contract"): + lines.append("外部智能体执行分支:" + " / ".join(str(key) for key in (detail.get("execution_policy_contract") or {}).keys())) + if detail.get("execution_loop_contract"): + lines.append( + "外部智能体最小循环:" + + " -> ".join(self._clean_text(item.get("step")) for item in (detail.get("execution_loop_contract") or []) if self._clean_text(item.get("step"))) + ) + orchestration_contract = payload.get("orchestration_contract") or detail.get("orchestration_contract") or {} + if orchestration_contract: + lines.append( + "最小执行流:{startup} -> {decide} -> {route} -> policy -> {followup}".format( + startup=self._clean_text(orchestration_contract.get("recommended_first_call")) or "startup", + decide=self._clean_text(orchestration_contract.get("recommended_decision_call")) or "decide --summary-only", + route=self._clean_text(orchestration_contract.get("recommended_route_call")) or "route --summary-only", + followup=self._clean_text(orchestration_contract.get("recommended_followup_call")) or "followup --summary-only", + ) + ) + read_fields = [ + self._clean_text(item) + for item in (orchestration_contract.get("recommended_read_fields") or []) + if self._clean_text(item) + ] + if read_fields: + lines.append("优先读取字段:" + " / ".join(read_fields)) + entry_patterns = payload.get("entry_patterns") or detail.get("entry_patterns") or {} + for key in ["external_agent", "mp_builtin_agent", "feishu_channel"]: + item = entry_patterns.get(key) or {} + if not item: + continue + lines.append( + "{label}:{start} -> {route}".format( + label=self._clean_text(item.get("label")) or key, + start=self._clean_text(item.get("start_with")) or "", + route=self._clean_text(item.get("route_with")) or "", + ) + ) + entry_playbooks = payload.get("entry_playbooks") or detail.get("entry_playbooks") or {} + external_playbook = (entry_playbooks.get("external_agent") or {}).get("steps") or [] + if external_playbook: + lines.append( + "外部智能体脚手架:" + + " -> ".join( + self._clean_text(item.get("step")) + for item in external_playbook + if self._clean_text(item.get("step")) + ) + ) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "pick_continue", + ]: + item = templates.get(name) or {} + if item: + tool = self._clean_text(item.get("tool")) + suffix = f" -> {tool}" if tool else "" + lines.append(f"{name}: {item.get('method')} {item.get('endpoint')}{suffix}") + return "\n".join(lines) + + def _format_assistant_toolbox_text(self) -> str: + data = self._assistant_toolbox_public_data() + workflows = data.get("workflows") or [] + lines = [ + "Agent影视助手 轻量工具清单", + f"版本:{data.get('version')}", + "推荐启动顺序:" + " -> ".join(str(item) for item in (data.get("startup_order") or [])[:5]), + "常用工作流:" + " / ".join(str(item.get("name")) for item in workflows if item.get("name")), + "默认目录:115={p115_path};夸克={quark_path};影巢={hdhive_path}".format(**(data.get("defaults") or {})), + ] + return "\n".join(lines) + + def _assistant_selfcheck_public_data(self) -> Dict[str, Any]: + action_template = self._assistant_action_template( + name="show_115_status", + description="自检模板:查看 115 状态", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", + tool="agent_resource_officer_execute_action", + body={"session": "selfcheck"}, + ) + route_template = self._assistant_action_template( + name="start_hdhive_search", + description="自检模板:发起影巢搜索", + endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", + tool="agent_resource_officer_smart_entry", + body={"session": "selfcheck", "keyword": "蜘蛛侠", "media_type": "movie"}, + ) + bool_cases = { + "true": self._parse_bool_value("true", False), + "false": self._parse_bool_value("false", True), + "one": self._parse_bool_value("1", False), + "zero": self._parse_bool_value("0", True), + "off": self._parse_bool_value("off", True), + "default_true": self._parse_bool_value(None, True), + "default_false": self._parse_bool_value(None, False), + } + compact_templates_ok = all([ + (action_template.get("body") or {}).get("compact") is True, + (action_template.get("action_body") or {}).get("compact") is True, + (route_template.get("body") or {}).get("compact") is True, + (route_template.get("action_body") or {}).get("compact") is True, + ]) + bool_parse_ok = ( + bool_cases["true"] is True + and bool_cases["false"] is False + and bool_cases["one"] is True + and bool_cases["zero"] is False + and bool_cases["off"] is False + and bool_cases["default_true"] is True + and bool_cases["default_false"] is False + ) + pulse = self._assistant_pulse_public_data() + toolbox = self._assistant_toolbox_public_data() + startup_request_templates = self._assistant_recommended_request_templates_data() + startup_continue_request_templates = self._assistant_recommended_request_templates_data( + recipe="continue", + reason="selfcheck", + ) + maintain = self._assistant_maintain_public_data(execute=False) + request_templates = self._assistant_request_templates_public_data(limit=100) + filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute,missing_template", + ) + recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="startup_probe,maintain_preview,workflow_dry_run,saved_plan_execute,pick_continue,maintain_execute", + include_templates=False, + ) + recipe_filtered_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="plan", + include_templates=False, + ) + external_recipe_request_templates = self._assistant_request_templates_response_data( + limit=5, + recipe="external_agent", + include_templates=False, + ) + policy_only_request_templates = self._assistant_request_templates_response_data( + limit=5, + names="maintain_execute", + include_templates=False, + ) + maintenance_templates = maintain.get("action_templates") or [] + maintenance_template_names = { + self._clean_text(item.get("name")) + for item in maintenance_templates + if isinstance(item, dict) + } + maintenance_templates_compact_ok = all( + (item.get("body") or {}).get("compact") is True + and (item.get("action_body") or {}).get("compact") is True + for item in maintenance_templates + if isinstance(item, dict) + ) + maintain_dry_run_ok = ( + maintain.get("action") == "maintain" + and maintain.get("execute_requested") is False + and maintain.get("executed") is False + and {"clear_stale_sessions", "clear_executed_plans"}.issubset(maintenance_template_names) + ) + protocol_ok = ( + pulse.get("protocol_version") == "assistant.v1" + and toolbox.get("protocol_version") == "assistant.v1" + and pulse.get("action") == "pulse" + and toolbox.get("action") == "toolbox" + and maintain.get("protocol_version") == "assistant.v1" + ) + startup_request_templates_ok = ( + startup_request_templates.get("recipe") == "bootstrap" + and startup_request_templates.get("include_templates") is False + and startup_request_templates.get("tool") == "agent_resource_officer_request_templates" + and "{MP_API_TOKEN}" in self._clean_text(startup_request_templates.get("url_template")) + and startup_continue_request_templates.get("recipe") == "continue" + and ((startup_continue_request_templates.get("tool_args") or {}).get("recipe")) == "continue" + and bool(self._clean_text(startup_continue_request_templates.get("reason"))) + ) + request_templates_ok = all( + isinstance(request_templates.get(name), dict) + and self._clean_text((request_templates.get(name) or {}).get("endpoint")) + and self._clean_text((request_templates.get(name) or {}).get("method")) + and self._clean_text((request_templates.get(name) or {}).get("tool")) + and self._clean_text((request_templates.get(name) or {}).get("description")) + and self._clean_text((request_templates.get(name) or {}).get("side_effect")) + and isinstance((request_templates.get(name) or {}).get("requires_confirmation"), bool) + and self._clean_text((request_templates.get(name) or {}).get("cache_scope")) + and isinstance((request_templates.get(name) or {}).get("cache_ttl_seconds"), int) + and isinstance((request_templates.get(name) or {}).get("tool_args"), dict) + for name in [ + "startup_probe", + "selfcheck_probe", + "maintain_preview", + "maintain_execute", + "workflow_dry_run", + "saved_plan_execute", + "action_execute", + "route_text", + "pick_continue", + ] + ) + request_templates_filter_ok = ( + list((filtered_request_templates.get("request_templates") or {}).keys()) == ["maintain_execute"] + and filtered_request_templates.get("selected_names") == ["maintain_execute", "missing_template"] + and filtered_request_templates.get("invalid_names") == ["missing_template"] + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("body") or {}).get("limit") == 5 + ) + request_templates_policy_ok = ( + "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("write_side_effects") or []) + ) + request_templates_schema_ok = filtered_request_templates.get("schema_version") == self.request_templates_schema_version + request_templates_cache_ok = ( + ((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_scope") == "no_cache" + and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_ttl_seconds")) == 0 + ) + request_templates_sequence_ok = ( + isinstance(filtered_request_templates.get("recommended_sequence"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "startup_probe" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("template")) == "saved_plan_execute" + for item in (filtered_request_templates.get("recommended_sequence") or []) + ) + ) + request_templates_recipes_ok = ( + isinstance(recipe_request_templates.get("recipes"), list) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "safe_bootstrap" + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) and self._clean_text(item.get("name")) == "plan_then_confirm" + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recipe_summary_ok = ( + any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "safe_bootstrap" + and item.get("requires_confirmation") is False + and item.get("has_write_effect") is False + for item in (recipe_request_templates.get("recipes") or []) + ) + and any( + isinstance(item, dict) + and self._clean_text(item.get("name")) == "plan_then_confirm" + and item.get("requires_confirmation") is True + and item.get("has_write_effect") is True + for item in (recipe_request_templates.get("recipes") or []) + ) + ) + request_templates_recommended_recipe_ok = ( + filtered_request_templates.get("recommended_recipe") == "maintenance_cycle" + and bool(self._clean_text(filtered_request_templates.get("recommended_recipe_reason"))) + and recipe_request_templates.get("recommended_recipe") == "plan_then_confirm" + ) + request_templates_recipe_filter_ok = ( + recipe_filtered_request_templates.get("requested_recipe") == "plan" + and recipe_filtered_request_templates.get("selected_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("selected_names") == ["workflow_dry_run", "saved_plan_execute"] + and recipe_filtered_request_templates.get("recommended_recipe") == "plan_then_confirm" + and recipe_filtered_request_templates.get("templates_included") is False + and "plan_then_confirm" in (recipe_filtered_request_templates.get("available_recipes") or []) + and ((recipe_filtered_request_templates.get("recipe_aliases") or {}).get("plan")) == "plan_then_confirm" + ) + recommended_recipe_detail = filtered_request_templates.get("recommended_recipe_detail") or {} + request_templates_recommended_recipe_detail_ok = ( + recommended_recipe_detail.get("name") == "maintenance_cycle" + and recommended_recipe_detail.get("first_template") == "maintain_preview" + and "maintain_execute" in (recommended_recipe_detail.get("confirmation_required_templates") or []) + and "maintain_execute" in (recommended_recipe_detail.get("write_templates") or []) + and recommended_recipe_detail.get("first_confirmation_template") == "maintain_execute" + and "maintain_execute" in self._clean_text(recommended_recipe_detail.get("confirmation_message")) + and ((recommended_recipe_detail.get("first_call") or {}).get("template")) == "maintain_preview" + and (((recommended_recipe_detail.get("first_call") or {}).get("auth") or {}).get("mode")) == "query_apikey" + and self._clean_text((recommended_recipe_detail.get("first_call") or {}).get("url_template")).endswith( + "/assistant/maintain?apikey={MP_API_TOKEN}" + ) + and ((recommended_recipe_detail.get("first_call") or {}).get("method")) == "GET" + and ((recommended_recipe_detail.get("first_call") or {}).get("tool")) == "agent_resource_officer_maintain" + and [ + (item or {}).get("template") + for item in (recommended_recipe_detail.get("calls") or []) + ] == ["maintain_preview", "maintain_execute"] + and all( + (((item or {}).get("auth") or {}).get("query_param")) == "apikey" + for item in (recommended_recipe_detail.get("calls") or []) + ) + and all( + "{MP_API_TOKEN}" in self._clean_text((item or {}).get("url_template")) + for item in (recommended_recipe_detail.get("calls") or []) + ) + ) + request_templates_policy_only_ok = ( + policy_only_request_templates.get("templates_included") is False + and (policy_only_request_templates.get("request_templates") or {}) == {} + and policy_only_request_templates.get("selected_names") == ["maintain_execute"] + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) + and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("non_cacheable_templates") or []) + ) + external_recipe_detail = external_recipe_request_templates.get("recommended_recipe_detail") or {} + request_templates_external_agent_contract_ok = ( + external_recipe_request_templates.get("selected_recipe") == "external_agent_quickstart" + and external_recipe_request_templates.get("recommended_recipe") == "external_agent_quickstart" + and bool((external_recipe_request_templates.get("external_agent_execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_request_templates.get("external_agent_execution_loop_contract") or []) >= 5 + and ((external_recipe_request_templates.get("orchestration_contract") or {}).get("recommended_first_call")) == "startup" + and bool(((external_recipe_request_templates.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with")) + and len((((external_recipe_request_templates.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or [])) >= 4 + and bool((external_recipe_detail.get("execution_policy_contract") or {}).get("auto_continue")) + and len(external_recipe_detail.get("execution_loop_contract") or []) >= 5 + and ((external_recipe_detail.get("orchestration_contract") or {}).get("recommended_route_call")) == "route --summary-only" + and bool(((external_recipe_detail.get("entry_patterns") or {}).get("feishu_channel") or {}).get("route_with")) + and bool(((((external_recipe_detail.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool")) + and any( + self._clean_text(item.get("step")) == "policy" + for item in (external_recipe_detail.get("execution_loop_contract") or []) + if isinstance(item, dict) + ) + ) + start_new_recovery = self._assistant_recovery_public_data( + session_state={"has_session": False}, + action_templates=[{"name": "start_pansou_search", "tool": "agent_resource_officer_smart_entry"}], + ) + start_new_recovery_ok = ( + start_new_recovery.get("mode") == "start_new" + and start_new_recovery.get("can_resume") is False + and start_new_recovery.get("recommended_action") == "start_pansou_search" + ) + executed_plan_recovery = self._assistant_recovery_public_data( + session_state={ + "has_session": True, + "session": "selfcheck", + "session_id": "assistant::selfcheck", + "saved_plan": { + "has_pending": False, + "has_plan": True, + "latest": { + "plan_id": "plan-selfcheck-executed", + "executed": True, + }, + }, + }, + ) + executed_plan_recovery_ok = ( + executed_plan_recovery.get("mode") == "followup_executed_plan" + and executed_plan_recovery.get("can_resume") is True + and executed_plan_recovery.get("recommended_action") == "query_execution_followup" + ) + execute_plan_followup_samples = { + workflow: self._assistant_plan_execute_followup( + workflow=workflow, + session="selfcheck", + session_id="selfcheck-session", + session_state={ + "keyword": keyword, + "session": "selfcheck", + "session_id": "selfcheck-session", + }, + ok=True, + plan_id=f"plan-selfcheck-{workflow}", + ) + for workflow, keyword in [ + ("mp_best_download", "蜘蛛侠"), + ("mp_subscribe", "钢铁侠"), + ("hdhive_unlock_selected", "复仇者联盟"), + ("ai_replay_failed_sample", "地狱乐"), + ] + } + execute_plan_followups_ok = ( + [item.get("name") for item in (execute_plan_followup_samples.get("mp_best_download") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_ingest_status", "query_mp_download_history", "query_mp_lifecycle_status", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("mp_best_download") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_best_download") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_best_download") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("mp_subscribe") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] + and (execute_plan_followup_samples.get("mp_subscribe") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("mp_subscribe") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("mp_subscribe") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("action_templates") or []] + == ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] + and (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("recommended_action") == "query_execution_followup" + and bool(self._clean_text((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("follow_up_hint"))) + and bool(self._clean_text(((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("followup_summary") or {}).get("label"))) + and [item.get("name") for item in (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("action_templates") or []] + == ["query_mp_local_diagnose", "query_mp_ingest_status", "query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] + and (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("recommended_action") == "query_mp_local_diagnose" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("preferred_command") == "诊断" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("fallback_command") == "入库状态" + and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("recommended_agent_behavior") == "auto_continue" + ) + ai_replay_execution_summary_resolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=True, + has_title=True, + ) + ai_replay_execution_summary_unresolved = self._assistant_ai_replay_execution_decision_summary( + ok=True, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_failed = self._assistant_ai_replay_execution_decision_summary( + ok=False, + resolved=False, + has_title=True, + ) + ai_replay_execution_summary_ok = ( + ai_replay_execution_summary_resolved.get("preferred_command") == "诊断" + and ai_replay_execution_summary_resolved.get("recommended_agent_behavior") == "auto_continue" + and ai_replay_execution_summary_unresolved.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_unresolved.get("fallback_command") == "样本洞察" + and ai_replay_execution_summary_failed.get("preferred_command") == "工作清单" + and ai_replay_execution_summary_failed.get("recommended_agent_behavior") == "show_only" + ) + checks = { + "compact_templates": compact_templates_ok, + "bool_parser": bool_parse_ok, + "protocol": protocol_ok, + "maintain_dry_run": maintain_dry_run_ok, + "maintenance_templates_compact": maintenance_templates_compact_ok, + "request_templates": request_templates_ok, + "request_templates_filter": request_templates_filter_ok, + "request_templates_policy": request_templates_policy_ok, + "request_templates_schema": request_templates_schema_ok, + "request_templates_cache": request_templates_cache_ok, + "request_templates_sequence": request_templates_sequence_ok, + "request_templates_recipes": request_templates_recipes_ok, + "request_templates_recipe_summary": request_templates_recipe_summary_ok, + "request_templates_recommended_recipe": request_templates_recommended_recipe_ok, + "request_templates_recipe_filter": request_templates_recipe_filter_ok, + "request_templates_recommended_recipe_detail": request_templates_recommended_recipe_detail_ok, + "request_templates_policy_only": request_templates_policy_only_ok, + "request_templates_external_agent_contract": request_templates_external_agent_contract_ok, + "startup_request_templates": startup_request_templates_ok, + "start_new_recovery_not_resumable": start_new_recovery_ok, + "executed_plan_recovery": executed_plan_recovery_ok, + "execute_plan_followups": execute_plan_followups_ok, + "ai_replay_execution_summary": ai_replay_execution_summary_ok, + "toolbox_startup_endpoint": bool((toolbox.get("endpoints") or {}).get("startup")), + "toolbox_maintain_endpoint": bool((toolbox.get("endpoints") or {}).get("maintain")), + "toolbox_request_templates_endpoint": bool((toolbox.get("endpoints") or {}).get("request_templates")), + "toolbox_maintain_tool": bool((toolbox.get("tools") or {}).get("maintain")), + "toolbox_request_templates_tool": bool((toolbox.get("tools") or {}).get("request_templates")), + "toolbox_selfcheck_endpoint": bool((toolbox.get("endpoints") or {}).get("selfcheck")), + } + ok = all(bool(value) for value in checks.values()) + return { + "protocol_version": "assistant.v1", + "action": "selfcheck", + "ok": ok, + "compact": True, + "version": self.plugin_version, + "checks": checks, + "bool_cases": bool_cases, + "template_samples": { + "action": { + "name": action_template.get("name"), + "body_compact": (action_template.get("body") or {}).get("compact"), + "action_body_compact": (action_template.get("action_body") or {}).get("compact"), + }, + "route": { + "name": route_template.get("name"), + "body_compact": (route_template.get("body") or {}).get("compact"), + "action_body_compact": (route_template.get("action_body") or {}).get("compact"), + }, + "request_templates": { + name: { + "method": (request_templates.get(name) or {}).get("method"), + "endpoint": (request_templates.get(name) or {}).get("endpoint"), + } + for name in ["maintain_execute", "workflow_dry_run", "saved_plan_execute"] + }, + "execute_plan_followups": { + workflow: { + "next_actions": (sample or {}).get("next_actions") or [], + "recommended_action": self._clean_text((sample or {}).get("recommended_action")), + "follow_up_hint": self._clean_text((sample or {}).get("follow_up_hint")), + "template_names": [ + self._clean_text(item.get("name")) + for item in ((sample or {}).get("action_templates") or []) + if isinstance(item, dict) and self._clean_text(item.get("name")) + ], + } + for workflow, sample in execute_plan_followup_samples.items() + }, + }, + "next_actions": ["assistant_startup", "assistant_maintain", "assistant_pulse", "assistant_toolbox", "assistant_readiness"], + "recommended_endpoints": { + "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", + "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", + "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", + "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", + "readiness": "/api/v1/plugin/AgentResourceOfficer/assistant/readiness?compact=true", + }, + } + + def _format_assistant_selfcheck_text(self) -> str: + data = self._assistant_selfcheck_public_data() + checks = data.get("checks") or {} + failed = [key for key, value in checks.items() if not value] + lines = [ + "Agent影视助手 协议自检", + f"版本:{data.get('version')}", + f"结果:{'通过' if data.get('ok') else '失败'}", + ] + if failed: + lines.append("失败项:" + " / ".join(failed)) + return "\n".join(lines) + + def _assistant_response_data( + self, + *, + session: str, + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + payload = dict(data or {}) + session_name = self._clean_text(session) or "default" + session_state = self._assistant_session_public_data(session=session_name) + payload["action"] = self._clean_text(payload.get("action")) or "assistant_response" + payload["ok"] = bool(payload.get("ok", True)) + payload["write_effect"] = payload.get("write_effect") or self._assistant_write_effect_for_action(payload["action"]) + payload["error_code"] = self._clean_text(payload.get("error_code")) or ("" if payload["ok"] else "assistant_error") + payload["protocol_version"] = "assistant.v1" + payload["session"] = session_name + payload["session_id"] = session_state.get("session_id") or self._assistant_session_id(session_name) + payload["session_state"] = session_state + payload["preference_status"] = payload.get("preference_status") or self._assistant_preferences_status_brief(session=session_name) + payload["next_actions"] = payload.get("next_actions") or session_state.get("suggested_actions") or [] + if payload["preference_status"].get("needs_onboarding") and "preferences.init" not in payload["next_actions"]: + payload["next_actions"] = ["preferences.init", *list(payload["next_actions"] or [])] + payload["action_templates"] = payload.get("action_templates") or session_state.get("action_templates") or [] + payload["recovery"] = payload.get("recovery") or session_state.get("recovery") or self._assistant_recovery_public_data(session_state=session_state) + if payload.get("action") == "mp_recommendations" or self._clean_text(session_state.get("kind")) == "assistant_mp_recommend": + payload["source"] = self._clean_text(payload.get("source") or session_state.get("source")) + payload["requested_source"] = self._clean_text(payload.get("requested_source") or session_state.get("requested_source")) + payload["fallback_source"] = self._clean_text(payload.get("fallback_source") or session_state.get("fallback_source")) + payload["media_type"] = self._clean_text(payload.get("media_type") or session_state.get("media_type")) + recommend_handoff = session_state.get("recommend_handoff") if isinstance(session_state.get("recommend_handoff"), dict) else {} + if recommend_handoff: + payload["recommend_handoff"] = recommend_handoff + payload["return_short_command"] = self._clean_text(payload.get("return_short_command") or recommend_handoff.get("return_short_command") or "回推荐") + return payload + + @staticmethod + def _assistant_write_effect_for_action(action: str) -> str: + action_name = str(action or "").strip() + if action_name in { + "share_route", + "hdhive_unlock", + "transfer_115", + "quark_transfer", + "quark_clear_default_dir", + "p115_clear_default_dir", + "p115_resume", + "p115_cancel", + "p115_qrcode_start", + "p115_qrcode_check", + "hdhive_checkin", + "mp_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_search", + "pick_mp_download", + "start_mp_subscribe", + "start_mp_subscribe_search", + "ai_replay_failed_sample", + "preferences_save", + "preferences_reset", + "execute_actions", + "execute_plan", + "maintain", + }: + return "write" + if action_name in {"workflow_plan", "plans_clear", "session_clear", "sessions_clear"}: + return "state" + return "read" + + def _merge_assistant_structured_input(self, body: Dict[str, Any], parsed: Dict[str, str]) -> Dict[str, str]: + merged = dict(parsed or {}) + body = dict(body or {}) + + mode = self._clean_text(body.get("mode")) + if mode in {"mp", "mp_download_title", "pansou", "hdhive", "smart", "smart_decision", "smart_plan", "smart_execute", "cloud_transfer_execute"}: + merged["mode"] = mode + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if keyword: + merged["keyword"] = keyword + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if share_url: + merged["url"] = share_url + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + if access_code: + merged["access_code"] = access_code + path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + if path: + merged["path"] = path + media_type = self._clean_text(body.get("media_type") or body.get("type")).lower() + if media_type: + merged["type"] = media_type + year = self._clean_text(body.get("year")) + if year: + merged["year"] = year + cloud_provider = self._clean_text(body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider in {"115", "quark"}: + merged["cloud_provider"] = cloud_provider + client_type = self._clean_text(body.get("client_type") or body.get("client")) + if client_type: + merged["client_type"] = P115TransferService.normalize_qrcode_client_type(client_type) + if "is_gambler" in body: + merged["is_gambler"] = "true" if self._parse_bool_value(body.get("is_gambler"), False) else "false" + action = self._clean_text(body.get("action")) + if action: + merged["action"] = action + result_filter = self._clean_text(body.get("result_filter") or body.get("filter")).lower() + if result_filter in {"latest_episode", "latest", "latest_episodes"}: + merged["result_filter"] = "latest_episode" + plan_id = self._clean_text(body.get("plan_id") or body.get("plan")) + if plan_id: + merged["plan_id"] = plan_id + download_control = self._clean_text(body.get("download_control") or body.get("control") or body.get("operation")) + if download_control: + merged["download_control"] = download_control + subscribe_control = self._clean_text(body.get("subscribe_control") or body.get("control") or body.get("operation")) + if subscribe_control: + merged["subscribe_control"] = subscribe_control + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index > 0: + merged["sample_index"] = str(sample_index) + if "remove_if_resolved" in body: + merged["remove_if_resolved"] = "true" if self._parse_bool_value(body.get("remove_if_resolved"), True) else "false" + return merged + + @staticmethod + def _parse_assistant_text(text: str) -> Dict[str, str]: + raw = str(text or "").strip() + compact = re.sub(r"\s+", "", raw).lower() + share_url = AgentResourceOfficer._extract_first_url(raw) + remain = raw.replace(share_url, " ").strip() if share_url else raw + mode, query = AgentResourceOfficer._normalize_search_prefix(remain) + plan_match = re.search(r"\bplan-[a-zA-Z0-9]+\b", raw) + options: Dict[str, str] = { + "text": raw, + "url": share_url, + "access_code": "", + "path": "", + "mode": mode, + "keyword": query or remain, + "result_filter": "", + "source_order_text": "", + "cloud_provider": "", + "type": "", + "year": "", + "action": "", + "client_type": "", + "status": "", + "hash": "", + "plan_id": plan_match.group(0) if plan_match else "", + "decision_intent": "", + "sample_index": "", + "remove_if_resolved": "true", + } + if options.get("mode") in {"smart", "smart_decision"} and options.get("keyword"): + cleaned_keyword, decision_intent = AgentResourceOfficer._extract_smart_decision_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if decision_intent: + options["decision_intent"] = decision_intent + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + if raw.startswith("云盘搜索") or raw.startswith("云盘搜"): + options["source_order_text"] = "pansou,hdhive" + transfer_provider_prefixes = [ + ("夸克转存资源", "quark"), + ("夸克转存", "quark"), + ("115转存资源", "115"), + ("115转存", "115"), + ] + for prefix, provider in transfer_provider_prefixes: + if raw == prefix: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = "" + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = provider + break + replay_match = re.match(r"^\s*(重放样本|重识别样本|重跑样本)\s*(\d+)?(?:\s+(.*))?$", raw) + if replay_match: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if replay_match.group(2): + options["sample_index"] = replay_match.group(2) + remain_text = AgentResourceOfficer._clean_text(replay_match.group(3)) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if options.get("plan_id") and compact.startswith(("执行plan-", "确认plan-", "executeplan-")): + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif options.get("plan_id") and compact.startswith(("取消plan-", "清理plan-", "删除plan-", "clearplan-", "cancelplan-")): + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "帮助", + "使用帮助", + "命令帮助", + "help", + "agenthelp", + "arohelp", + "插件帮助", + }: + options["action"] = "assistant_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "执行计划", + "执行最新计划", + "确认计划", + "确认执行计划", + "执行plan", + "执行最新plan", + "executeplan", + "executelatestplan", + }: + options["action"] = "execute_plan" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "计划列表", + "查看计划", + "待执行计划", + "保存计划", + "plans", + "listplans", + }: + options["action"] = "plans_list" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "偏好", + "片源偏好", + "查看偏好", + "偏好设置", + "智能体偏好", + "preferences", + "getpreferences", + }: + options["action"] = "preferences_get" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重置偏好", + "清除偏好", + "重设偏好", + "恢复默认偏好", + "resetpreferences", + }: + options["action"] = "preferences_reset" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "评分策略", + "评分规则", + "自动化规则", + "scoringpolicy", + "scoringrules", + }: + options["action"] = "scoring_policy" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消计划", + "清理计划", + "删除计划", + "cancelplan", + "clearplan", + "deleteplan", + }: + options["action"] = "plans_clear" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录", + "115扫码", + "扫码115", + "登录115", + "115login", + "115qrcode", + "p115login", + "p115qrcode", + }: + options["action"] = "p115_qrcode_start" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "检查115登录", + "检查115扫码", + "检查扫码", + "115check", + "check115login", + "p115check", + }: + options["action"] = "p115_qrcode_check" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115登录状态", + "115状态", + "查看115状态", + "115健康", + "115status", + "p115status", + }: + options["action"] = "p115_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115帮助", + "115命令", + "115使用", + "115help", + "p115help", + }: + options["action"] = "p115_help" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "115任务", + "待处理115", + "待继续115", + "115pending", + "p115pending", + }: + options["action"] = "p115_pending" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空夸克默认目录", + "清空夸克默认转存目录", + "清空夸克转存目录", + "清空夸克目录", + }: + options["action"] = "quark_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "清空115默认目录", + "清空115默认转存目录", + "清空115转存目录", + "清空115目录", + "清空115网盘转存目录", + "清空115网盘目录", + }: + options["action"] = "p115_clear_default_dir" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续115任务", + "重试115任务", + "继续115转存", + "重试115转存", + "continue115", + "resume115", + }: + options["action"] = "p115_resume" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "取消115任务", + "取消115转存", + "清除115任务", + "cancel115", + "clear115", + }: + options["action"] = "p115_cancel" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到", + "签到", + "hdhivecheckin", + "hdhivesign", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢签到日志", + "签到日志", + "影巢日志", + "hdhivecheckinhistory", + "hdhivesignhistory", + }: + options["action"] = "hdhive_checkin_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "影巢普通签到", + "普通签到", + "普通", + "hdhivenormalcheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "false" + elif compact in { + "影巢赌狗签到", + "赌狗签到", + "赌狗", + "hdhivegamblercheckin", + "gamblercheckin", + }: + options["action"] = "hdhive_checkin" + options["mode"] = "" + options["keyword"] = "" + options["is_gambler"] = "true" + elif compact in { + "资源决策", + "智能决策", + "decision", + "smartdecision", + }: + options["mode"] = "smart_decision" + options["keyword"] = "" + elif compact in { + "后续", + "执行后追踪", + "继续追踪", + "followup", + "postexecute", + }: + options["action"] = "execution_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "跟进", + "继续跟进", + "查看进展", + "进展", + "smartfollowup", + }: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "继续决策", + "继续资源决策", + "decisioncontinue", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_continue" + elif compact in { + "换影巢", + "切换影巢", + "走影巢", + "用影巢", + "decisionhdhive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_hdhive" + elif compact in { + "换盘搜", + "切换盘搜", + "走盘搜", + "用盘搜", + "decisionpansou", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_pansou" + elif compact in { + "换pt", + "换原生", + "换mp", + "切换pt", + "切换原生", + "切换mp", + "decisionmppt", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_mp_pt" + elif compact in { + "保守一点", + "更保守", + "保守模式", + "decisionconservative", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_conservative" + elif compact in { + "激进一点", + "更激进", + "激进模式", + "decisionaggressive", + }: + options["action"] = "smart_decision_adjust" + options["mode"] = "" + options["keyword"] = "" + options["decision_adjust"] = "decision_aggressive" + elif compact in { + "下载任务", + "下载状态", + "正在下载", + "下载列表", + "查看下载", + "下载进度", + "downloadtasks", + "downloadstatus", + }: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载最佳", + "下载推荐", + "下载最好", + "下载最佳片源", + "下载推荐片源", + "downloadbest", + }: + options["action"] = "mp_download_best" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载历史", + "下载记录", + "记录", + "历史下载", + "downloadhistory", + }: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近下载", + "recentdownloads", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["download_only"] = "true" + elif compact in { + "最近", + "最近动态", + "动态", + "recentactivity", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "追踪", + "资源追踪", + "下载追踪", + "媒体状态", + "落库状态", + "状态", + "进度", + "lifecyclestatus", + }: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库状态", + "本地入库", + "入库", + "ingeststatus", + }: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "识别", + "媒体识别", + "媒体详情", + "mp识别", + "mp媒体识别", + "mpmediadetail", + }: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "下载器", + "下载器状态", + "下载器列表", + "查看下载器", + "downloaders", + }: + options["action"] = "mp_downloaders" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "站点", + "站点状态", + "站点列表", + "pt站点", + "pt站点状态", + "sites", + }: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "订阅列表", + "订阅状态", + "查看订阅", + "mp订阅", + "subscribes", + }: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "入库历史", + "整理历史", + "转移历史", + "入库记录", + "最近整理", + "transferhistory", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "最近入库", + "recentingest", + }: + options["action"] = "mp_recent_activity" + options["mode"] = "" + options["keyword"] = "" + options["transfer_only"] = "true" + elif compact in { + "入库失败", + "整理失败", + "失败入库", + "失败整理", + "transferfailed", + }: + options["action"] = "mp_ingest_failures" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "failed" + elif compact in { + "入库成功", + "整理成功", + "成功入库", + "成功整理", + "transfersuccess", + }: + options["action"] = "mp_transfer_history" + options["mode"] = "" + options["keyword"] = "" + options["status"] = "success" + elif compact in { + "本地诊断", + "诊断", + "失败原因", + "localdiagnose", + }: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "失败样本", + "识别样本", + "ai失败样本", + "aifailedsamples", + }: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "工作清单", + "识别工作清单", + "样本清单", + "sampleworklist", + }: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "样本洞察", + "识别洞察", + "失败洞察", + "sampleinsights", + }: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = "" + elif compact in { + "重放样本", + "重识别样本", + "重跑样本", + "aireplay", + "replaysample", + }: + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + else: + for prefix, action in [ + ("执行计划", "execute_plan"), + ("执行", "execute_plan"), + ("确认计划", "execute_plan"), + ("确认", "execute_plan"), + ("查看计划", "plans_list"), + ("计划列表", "plans_list"), + ("取消计划", "plans_clear"), + ("清理计划", "plans_clear"), + ("删除计划", "plans_clear"), + ("保存偏好", "preferences_save"), + ("设置偏好", "preferences_save"), + ("更新偏好", "preferences_save"), + ("偏好设置", "preferences_save"), + ("偏好", "preferences_save"), + ("查看偏好", "preferences_get"), + ("片源偏好", "preferences_get"), + ("重置偏好", "preferences_reset"), + ("清除偏好", "preferences_reset"), + ("评分策略", "scoring_policy"), + ("评分规则", "scoring_policy"), + ("自动化规则", "scoring_policy"), + ]: + if raw.startswith(prefix + " ") or raw.startswith(prefix + ":") or raw.startswith(prefix + ":"): + remain_text = raw[len(prefix):].lstrip(" ::").strip() + if action in {"plans_list", "preferences_get", "preferences_reset"} or options.get("plan_id") or remain_text: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + if remain_text and not options.get("plan_id"): + match = re.search(r"\bplan-[a-zA-Z0-9]+\b", remain_text) + if match: + options["plan_id"] = match.group(0) + break + for prefix, control in [ + ("暂停下载", "pause"), + ("停止下载", "pause"), + ("恢复下载", "resume"), + ("继续下载", "resume"), + ("开始下载", "resume"), + ("删除下载", "delete"), + ("移除下载", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_download_control" + options["mode"] = "" + options["keyword"] = target_text + options["download_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载任务", "下载状态", "下载列表", "查看下载", "下载进度"]) + if prefix_match: + options["action"] = "mp_download_tasks" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源决策", "智能决策"]) + if prefix_match: + options["mode"] = "smart_decision" + options["keyword"] = prefix_match[1] + if not options.get("action") and not options.get("mode"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["继续跟进", "查看进展", "跟进", "进展"]) + if prefix_match: + options["action"] = "smart_followup" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载历史", "下载记录", "记录", "最近下载", "历史下载"]) + if prefix_match: + options["action"] = "mp_download_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源追踪", "下载追踪", "媒体状态", "落库状态", "状态", "进度", "追踪"]) + if prefix_match: + options["action"] = "mp_lifecycle_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["入库状态", "本地入库"]) + if prefix_match: + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["MP识别", "mp识别", "媒体识别", "媒体详情", "详情媒体", "识别"]) + if prefix_match: + options["action"] = "mp_media_detail" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["站点状态", "站点列表", "PT站点", "pt站点", "站点"]) + if prefix_match: + options["action"] = "mp_sites" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, control in [ + ("搜索订阅", "search"), + ("刷新订阅", "search"), + ("暂停订阅", "pause"), + ("恢复订阅", "resume"), + ("删除订阅", "delete"), + ("移除订阅", "delete"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + target_text = prefix_match[1] + if target_text: + options["action"] = "mp_subscribe_control" + options["mode"] = "" + options["keyword"] = target_text + options["subscribe_control"] = control + break + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["订阅列表", "订阅状态", "查看订阅", "MP订阅", "mp订阅"]) + if prefix_match: + options["action"] = "mp_subscribes" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + for prefix, status_name in [ + ("入库失败", "failed"), + ("整理失败", "failed"), + ("失败入库", "failed"), + ("失败整理", "failed"), + ("入库成功", "success"), + ("整理成功", "success"), + ("成功入库", "success"), + ("成功整理", "success"), + ("入库历史", "all"), + ("入库记录", "all"), + ("整理历史", "all"), + ("转移历史", "all"), + ("最近入库", "all"), + ("最近整理", "all"), + ]: + prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) + if prefix_match: + options["action"] = "mp_ingest_failures" if status_name == "failed" else "mp_transfer_history" + options["mode"] = "" + options["keyword"] = prefix_match[1] + options["status"] = status_name + break + if not options.get("action") and raw.startswith("入库"): + blocked_prefixes = ("入库失败", "入库成功", "入库历史", "入库记录", "入库状态") + if not any(raw.startswith(prefix) for prefix in blocked_prefixes): + remain_text = raw[len("入库"):].lstrip(" ::").strip() + options["action"] = "mp_ingest_status" + options["mode"] = "" + options["keyword"] = remain_text + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["为什么没入库", "本地诊断", "诊断", "失败原因"]) + if prefix_match: + options["action"] = "mp_local_diagnose" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["失败样本", "识别样本", "AI失败样本"]) + if prefix_match: + options["action"] = "ai_failed_samples" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["工作清单", "识别工作清单", "样本清单"]) + if prefix_match: + options["action"] = "ai_sample_worklist" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["样本洞察", "识别洞察", "失败洞察"]) + if prefix_match: + options["action"] = "ai_sample_insights" + options["mode"] = "" + options["keyword"] = prefix_match[1] + if not options.get("action"): + prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["重放样本", "重识别样本", "重跑样本"]) + if prefix_match: + remain_text = prefix_match[1] + match = re.search(r"\d+", remain_text) + options["action"] = "ai_replay_failed_sample" + options["mode"] = "" + options["keyword"] = "" + if match: + options["sample_index"] = match.group(0) + if "保留样本" in remain_text or "不移除" in remain_text: + options["remove_if_resolved"] = "false" + if not options.get("action"): + for prefix, action in [ + ("转存资源", "cloud_transfer"), + ("转存", "cloud_transfer"), + ("下载资源", "mp_download"), + ("下载", "mp_download"), + ("订阅并搜索", "mp_subscribe_search"), + ("订阅搜索", "mp_subscribe_search"), + ("订阅媒体", "mp_subscribe"), + ("订阅", "mp_subscribe"), + ("热门推荐", "mp_recommendations"), + ("推荐", "mp_recommendations"), + ("智能发现", "mp_recommendations"), + ("热门发现", "mp_recommendations"), + ]: + if raw == prefix: + options["action"] = action + options["mode"] = "" + options["keyword"] = "" + break + if raw.startswith(prefix + " "): + remain_text = raw[len(prefix):].strip() + if action == "cloud_transfer": + if remain_text: + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if download_match: + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + else: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + else: + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if raw.startswith(prefix): + remain_text = raw[len(prefix):].strip() + if not remain_text: + continue + if action == "cloud_transfer": + options["action"] = "" + options["mode"] = "cloud_transfer_execute" + options["keyword"] = remain_text + options["source_order_text"] = "pansou,hdhive" + options["cloud_provider"] = "115" + break + if action == "mp_download": + download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) + if not download_match: + options["action"] = "" + options["mode"] = "mp_download_title" + options["keyword"] = remain_text + options["source_order_text"] = "mp_pt" + break + options["action"] = action + options["mode"] = "" + options["keyword"] = download_match.group(1) + break + options["action"] = action + options["mode"] = "" + options["keyword"] = remain_text + break + if not options.get("action") and any( + marker in compact + for marker in [ + "热门影视", + "热门电影", + "热门电视剧", + "热门剧集", + "最近热门", + "有什么热门", + "看看热门", + "影视推荐", + "电影推荐", + "剧集推荐", + "电视剧推荐", + "豆瓣热门", + "豆瓣top250", + "正在热映", + "今日番剧", + "每日放送", + "bangumi", + "tmdb热门", + ] + ): + options["action"] = "mp_recommendations" + options["mode"] = "" + options["keyword"] = raw + 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"] = AgentResourceOfficer._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 key in {"client_type", "client", "客户端"} and value: + options["client_type"] = P115TransferService.normalize_qrcode_client_type(value) + continue + if key in {"hash", "download_hash", "任务hash"} and value: + options["hash"] = value.strip() + continue + if item.startswith("/") and not options["path"]: + options["path"] = AgentResourceOfficer._resolve_pan_path_value(item) + if options.get("mode") in {"mp", "mp_download_title"} and options.get("keyword"): + cleaned_keyword, result_filter = AgentResourceOfficer._extract_mp_result_filter_intent(options.get("keyword") or "") + options["keyword"] = cleaned_keyword.strip() + if result_filter: + options["result_filter"] = result_filter + return options + + 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 = [] + base_urls = [] + configured_base = self._clean_text(self._pansou_base_url).rstrip("/") + if configured_base: + base_urls.append(configured_base) + for fallback_base in ("http://host.docker.internal:805", "http://127.0.0.1:805"): + if fallback_base not in base_urls: + base_urls.append(fallback_base) + for query in queries: + for base_url in base_urls: + urls.append(f"{base_url}/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=self._pansou_timeout) 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 _normalize_pansou_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_pansou_channel_items( + self, + merged: Dict[str, Any], + channel_name: str, + limit: int = 6, + ) -> 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) + normalized = { + "channel": self._normalize_pansou_channel_name(channel_name), + "url": url, + "password": password, + "note": note, + "source": source, + "datetime": dt, + } + for extra_key in [ + "title", + "remark", + "description", + "desc", + "detail", + "details", + "summary", + "share_size", + "size", + "subtitle", + "subtitles", + "subtitle_language", + "subtitle_languages", + "video_resolution", + "videoFormat", + "media_type", + "type", + "episode", + "episodes", + "episode_range", + "update_status", + "update_info", + ]: + value = item.get(extra_key) + if value not in (None, "", []): + normalized[extra_key] = value + results.append(normalized) + if len(results) >= limit: + break + return results + + @staticmethod + def _extract_size_text(text: str) -> str: + match = re.search(r"(\d+(?:\.\d+)?)\s*(gb|g|mb|m)\b", str(text or ""), flags=re.IGNORECASE) + if not match: + return "" + value = match.group(1) + unit = match.group(2).upper() + if unit == "G": + unit = "GB" + if unit == "M": + unit = "MB" + return f"{value}{unit}" + + def _pansou_item_brief_summary(self, item: Dict[str, Any]) -> str: + text = self._score_text_blob(item) + parts: List[str] = [] + if "2160" in text or "4k" in text or "uhd" in text: + parts.append("4K") + elif "1080" in text: + parts.append("1080P") + elif "720" in text: + parts.append("720P") + if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): + parts.append("杜比视界") + elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): + parts.append("HDR") + if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "双语", "官中", "内封简繁"]): + parts.append("中字") + elif self._score_has_any(text, ["无中字", "无字幕"]): + parts.append("无中字") + progress = self._extract_series_progress(text) + if progress.get("episode_count", 0) >= max(3, progress.get("max_episode", 0)): + parts.append(f"E01-E{progress['max_episode']:02d}") + elif progress.get("max_episode", 0) > 0: + parts.append(f"更新到E{progress['max_episode']:02d}") + size_text = self._extract_size_text(text) or self._extract_size_text(self._list_text(item.get("share_size") or item.get("size"))) + if size_text: + parts.append(size_text) + return " | ".join(parts[:5]) + + @staticmethod + def _format_markdown_link(title: Any, url: Any) -> str: + clean_title = str(title or "").strip() + clean_url = str(url or "").strip() + if not clean_title: + return clean_url + if not clean_url: + return clean_title + safe_title = clean_title.replace("[", "[").replace("]", "]") + return f"[{safe_title}]({clean_url})" + + @staticmethod + def _parse_simple_cjk_number(text: str) -> Optional[int]: + raw = str(text or "").strip() + if not raw: + return None + if raw.isdigit(): + return int(raw) + mapping = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} + if raw == "十": + return 10 + if "十" in raw: + left, right = raw.split("十", 1) + left_value = 1 if left == "" else mapping.get(left) + if left_value is None: + return None + right_value = 0 if right == "" else mapping.get(right) + if right_value is None: + return None + return left_value * 10 + right_value + total = 0 + for ch in raw: + if ch not in mapping: + return None + total = total * 10 + mapping[ch] + return total if total >= 0 else None + + @classmethod + def _normalize_simple_cjk_numbers(cls, text: str) -> str: + raw = str(text or "") + if not raw: + return "" + pattern = re.compile(r"[零〇一二两三四五六七八九十]{1,3}") + def repl(match: re.Match[str]) -> str: + value = cls._parse_simple_cjk_number(match.group(0)) + return str(value) if value is not None else match.group(0) + return pattern.sub(repl, raw) + + @classmethod + def _build_keyword_variants(cls, keyword: str) -> List[str]: + raw = cls._clean_text(keyword) + if not raw: + return [] + variants: List[str] = [] + + def add(value: Any) -> None: + text = cls._clean_text(value) + if not text: + return + if text not in variants: + variants.append(text) + + add(raw) + trimmed = re.split(r"[::((【\[\-_/|]", raw, 1)[0].strip() + add(trimmed) + if "的" in raw: + add(raw.split("的", 1)[0].strip()) + base_candidates = list(variants) + for base in base_candidates: + numeric = cls._normalize_simple_cjk_numbers(base) + add(numeric) + add(numeric.replace("个月", "ヶ月").replace("個月", "ヶ月")) + add(numeric.replace("个月", "个月").replace("個月", "个月")) + add(base.replace("三个月", "3个月").replace("三個月", "3个月")) + add(base.replace("三个月", "3ヶ月").replace("三個月", "3ヶ月")) + add(re.sub(r"\s+", "", base)) + return variants + + def _pansou_payload_item_count(self, payload: Dict[str, Any]) -> int: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + total = 0 + for channel in ("115", "quark"): + items = merged.get(channel) or [] + if isinstance(items, list): + total += len(items) + return total + + def _call_pansou_search_with_variants(self, keyword: str) -> Tuple[bool, Dict[str, Any], str, str]: + variants = self._build_keyword_variants(keyword) + last_error = "" + last_payload: Dict[str, Any] = {} + for variant in variants: + ok, payload, message = self._call_pansou_search(variant) + if ok and self._pansou_payload_item_count(payload) > 0: + return True, payload, message, variant + last_error = message + last_payload = payload if isinstance(payload, dict) else {} + return False, last_payload, last_error or "盘搜暂无结果", self._clean_text(keyword) + + def _assistant_pansou_entry_summary(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(items) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "pansou_result", + "label": "先看条目再选编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "pansou_result", + "label": "盘搜列表已返回", + "decision_hint": "默认直接回编号即可转存;想先确认可回复“选择 编号 详情”。只有明确要生成计划时才发“计划选择 编号”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + def _best_series_progress_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return {} + candidates.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("max_episode")), 0), + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + ), + reverse=True, + ) + return candidates[0] + + def _latest_series_progress_items(self, items: List[Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]: + candidates: List[Dict[str, Any]] = [] + for index, item in enumerate(items or [], 1): + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + enriched = dict(item) + enriched["_series_progress"] = progress + enriched["_index"] = self._safe_int(enriched.get("index"), index) + candidates.append(enriched) + if not candidates: + return [] + best_episode = max(self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) for item in candidates) + if best_episode <= 0: + return [] + matches = [ + item for item in candidates + if self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) == best_episode + ] + matches.sort( + key=lambda value: ( + self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), + self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), + self._safe_int(value.get("updated_at"), 0), + self._clean_text(value.get("datetime")), + ), + reverse=True, + ) + return matches[: max(1, limit)] + + def _latest_episode_mp_items(self, items: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: + candidates: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + if max_episode <= 0: + continue + enriched = dict(item) + enriched["_series_progress"] = progress + candidates.append(enriched) + if not candidates: + return [], 0 + latest_episode = max(self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) for item in candidates) + if latest_episode <= 0: + return [], 0 + latest_items = [ + item for item in candidates + if self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) == latest_episode + ] + return latest_items, latest_episode + + def _episode_filter_mp_items(self, items: List[Dict[str, Any]], episode: int) -> List[Dict[str, Any]]: + target = self._safe_int(episode, 0) + if target <= 0: + return [] + matches: List[Dict[str, Any]] = [] + for item in items or []: + if not isinstance(item, dict): + continue + progress = self._extract_series_progress(self._score_text_blob(item)) + max_episode = self._safe_int(progress.get("max_episode"), 0) + episode_count = self._safe_int(progress.get("episode_count"), 0) + if max_episode == target: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + continue + if episode_count > 0 and max_episode >= target: + start_episode = max(1, max_episode - episode_count + 1) + if start_episode <= target <= max_episode: + enriched = dict(item) + enriched["_series_progress"] = progress + matches.append(enriched) + return matches + + def _latest_resource_date_text(self, items: List[Dict[str, Any]]) -> str: + latest_text = "" + latest_unix = 0 + for item in items or []: + if not isinstance(item, dict): + continue + text = self._clean_text(item.get("datetime") or item.get("updated_at_text")) + if text and text > latest_text: + latest_text = text + unix_value = self._safe_int(item.get("updated_at"), 0) + if unix_value > latest_unix: + latest_unix = unix_value + if latest_text: + return latest_text + if latest_unix > 0: + return self._format_unix_time(latest_unix) + return "" + + def _format_update_resource_choice(self, item: Dict[str, Any], source_type: str) -> str: + current = dict(item or {}) + index = self._safe_int(current.get("_index") or current.get("index"), 0) + provider = self._clean_text(current.get("channel") or current.get("pan_type")).upper() + if provider == "QUARK": + provider = "夸克" + elif provider == "115": + provider = "115" + provider_emoji = "🗄" if provider == "夸克" else "📺" if provider == "115" else "🔗" + label = f"{index}. {provider_emoji}" if index > 0 else f"?. {provider_emoji}" + if provider: + label = f"{label} {provider}" + if source_type == "pansou": + brief = self._pansou_item_brief_summary(current) + date_text = self._format_pansou_display_datetime(current.get("datetime")) + title_text = self._clean_text(current.get("note") or current.get("title") or "盘搜资源") + parts = [label] + if date_text: + parts.append(date_text) + if brief: + parts.append(brief) + if title_text and title_text != brief: + parts.append(self._truncate_text(title_text, 80)) + return " · ".join(parts) + progress = self._format_update_progress_label(current.get("_series_progress")) + subtitle = self._resource_subtitle_text(current) + resolution = "/".join(current.get("video_resolution") or []) or "" + date_text = self._clean_text(current.get("updated_at_text")) + parts = [label] + if date_text: + parts.append(self._format_pansou_display_datetime(date_text)) + if resolution: + parts.append(f"✨{resolution}" if "4" in resolution or "2160" in resolution else resolution) + if subtitle: + parts.append(f"字幕:{subtitle}") + if progress and progress != "未识别到集数": + parts.append(f"📌 {progress}") + detail = self._clean_text(current.get("title") or current.get("remark") or current.get("description")) + if detail: + parts.append(self._truncate_text(detail, 70)) + return " · ".join(parts) + + def _format_update_progress_label(self, progress: Optional[Dict[str, Any]] = None) -> str: + current = dict(progress or {}) + max_episode = self._safe_int(current.get("max_episode"), 0) + episode_count = self._safe_int(current.get("episode_count"), 0) + if max_episode <= 0: + return "未识别到集数" + if episode_count >= max_episode >= 2: + return f"E01-E{max_episode:02d}" + return f"更新到 E{max_episode:02d}" + + async def _assistant_update_check( + self, + *, + keyword: str, + session: str, + cache_key: str, + year: str = "", + ) -> Dict[str, Any]: + clean_keyword = self._clean_text(keyword) + if not clean_keyword: + return { + "success": False, + "message": "用法:更新检查 片名", + "data": self._assistant_response_data(session=session, data={"action": "update_check", "ok": False, "error_code": "missing_keyword"}), + } + official = self._tmdb_latest_episode_progress(clean_keyword, year) + official_episode = self._safe_int(official.get("episode"), 0) + + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + + pansou_best: Dict[str, Any] = {} + pansou_latest_items: List[Dict[str, Any]] = [] + pansou_recent_date = "" + pansou_total = 0 + search_ok, payload, _search_message = self._call_pansou_search(clean_keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + pansou_total = self._safe_int(data.get("total"), 0) + raw_items = self._collect_pansou_channel_items(merged, "115", 20) + self._collect_pansou_channel_items(merged, "quark", 20) + scored_items = self._attach_cloud_scores(raw_items, preferences=preferences, source_type="pansou", target_path=self._hdhive_default_path) + pansou_best = self._best_series_progress_item(scored_items) + pansou_latest_items = self._latest_series_progress_items(scored_items, limit=5) + pansou_recent_date = self._latest_resource_date_text(scored_items) + + hdhive_best: Dict[str, Any] = {} + hdhive_latest_items: List[Dict[str, Any]] = [] + hdhive_recent_date = "" + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + candidate_ok, candidate_result, _candidate_message = await service.resolve_candidates_by_keyword( + keyword=clean_keyword, + media_type="tv", + year=self._clean_text(year), + candidate_limit=max(10, self._hdhive_candidate_page_size), + ) + if candidate_ok: + candidates = candidate_result.get("candidates") or [] + chosen = next((item for item in candidates if self._clean_text(item.get("media_type")).lower() in {"tv", "series"}), None) + if chosen is None and candidates: + chosen = candidates[0] + if isinstance(chosen, dict): + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen.get("media_type") or "tv", + tmdb_id=str(chosen.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=self._hdhive_default_path, + ) + hdhive_best = self._best_series_progress_item(preview) + hdhive_latest_items = self._latest_series_progress_items(preview, limit=5) + hdhive_recent_date = self._latest_resource_date_text(preview) + + pansou_progress = dict(pansou_best.get("_series_progress") or {}) + hdhive_progress = dict(hdhive_best.get("_series_progress") or {}) + lines = [f"更新检查:{clean_keyword}"] + if official_episode > 0: + official_title = self._clean_text(official.get("title")) or clean_keyword + lines.append(f"📺 TMDB 进度:{official_title} S{self._safe_int(official.get('season'), 1):02d}E{official_episode:02d}") + else: + lines.append("📺 TMDB 进度:未稳定识别到最新集数") + if pansou_best: + lines.append( + f"\n🟨 盘搜结果:{self._format_update_progress_label(pansou_progress)}" + f" · 最佳 #{self._safe_int(pansou_best.get('_index'), 0)}" + ) + if pansou_latest_items: + for item in pansou_latest_items: + lines.append(self._format_update_resource_choice(item, "pansou")) + elif pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟨 盘搜结果:暂无可识别更新结果") + if pansou_recent_date: + lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") + if hdhive_best: + lines.append( + f"\n🟦 影巢结果:{self._format_update_progress_label(hdhive_progress)}" + f" · 最佳 #{self._safe_int(hdhive_best.get('_index'), 0)}" + ) + if hdhive_latest_items: + for item in hdhive_latest_items: + lines.append(self._format_update_resource_choice(item, "hdhive")) + elif hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + else: + lines.append("\n🟦 影巢结果:暂无可识别更新结果") + if hdhive_recent_date: + lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") + + latest_seen = max( + official_episode, + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + downloadable_latest = max( + self._safe_int(pansou_progress.get("max_episode"), 0), + self._safe_int(hdhive_progress.get("max_episode"), 0), + ) + if latest_seen > 0: + lines.append(f"\n🔎 当前观察到最高更新:E{latest_seen:02d}") + if downloadable_latest > 0: + lines.append(f"✅ 已可下载最新集:E{downloadable_latest:02d}") + else: + lines.append("⚠️ 已可下载最新集:暂未稳定识别") + if official_episode > 0: + pansou_ok = self._safe_int(pansou_progress.get("max_episode"), 0) >= official_episode + hdhive_ok = self._safe_int(hdhive_progress.get("max_episode"), 0) >= official_episode + if pansou_ok and hdhive_ok: + lines.append("✅ 盘搜和影巢都已跟上 TMDB 进度。") + else: + lines.append(f"{'✅' if pansou_ok else '⏳'} 盘搜:{'已跟上' if pansou_ok else '还没稳定跟上'}TMDB 进度。") + lines.append(f"{'✅' if hdhive_ok else '⏳'} 影巢:{'已跟上' if hdhive_ok else '还没稳定跟上'}TMDB 进度。") + pt_search_needed = latest_seen <= 0 and official_episode <= 0 + if pt_search_needed: + lines.append(f"\n下一步:官方和云盘侧都还没看到明确新集;如果要继续扩搜,可以回复:PT搜索 {clean_keyword}。") + else: + lines.append("\n下一步:直接回编号可继续处理;想先确认可发“选择 编号 详情”。") + lines.append(f"也可以发“云盘搜索 {clean_keyword}”查看更多资源,或“影巢搜索 {clean_keyword}”只看影巢。") + update_items: List[Dict[str, Any]] = [] + for item in pansou_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "pansou"}) + for item in hdhive_latest_items: + if isinstance(item, dict): + update_items.append({**item, "_update_source": "hdhive"}) + self._save_session(cache_key, { + "kind": "assistant_update_check", + "stage": "result", + "keyword": clean_keyword, + "year": self._clean_text(year), + "items": update_items, + "pansou_items": pansou_latest_items, + "hdhive_items": hdhive_latest_items, + "target_path": self._hdhive_default_path, + }) + return { + "success": True, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check", + "ok": True, + "keyword": clean_keyword, + "official_progress": official, + "pansou_best": pansou_best, + "pansou_latest_items": pansou_latest_items, + "pansou_recent_date": pansou_recent_date, + "hdhive_best": hdhive_best, + "hdhive_latest_items": hdhive_latest_items, + "hdhive_recent_date": hdhive_recent_date, + "latest_seen_episode": latest_seen, + "downloadable_latest_episode": downloadable_latest, + "decision_summary": { + "stage": "update_check", + "label": "更新检查已完成", + "preferred_command": f"PT搜索 {clean_keyword}" if pt_search_needed else f"云盘搜索 {clean_keyword}", + "fallback_command": f"云盘搜索 {clean_keyword}" if pt_search_needed else f"影巢搜索 {clean_keyword}", + "compact_commands": [f"PT搜索 {clean_keyword}", f"云盘搜索 {clean_keyword}"] if pt_search_needed else [f"云盘搜索 {clean_keyword}", f"影巢搜索 {clean_keyword}"], + "recommended_agent_behavior": "show_only", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + }, + }), + } + + @classmethod + def _read_tmdb_api_key(cls) -> str: + for value in [ + os.environ.get("TMDB_API_KEY", ""), + os.environ.get("TMDB_KEY", ""), + cls._clean_text(getattr(settings, "TMDB_API_KEY", "") if settings is not None else ""), + ]: + if cls._clean_text(value): + return cls._clean_text(value) + 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_text(value.strip().strip("'\"")) + if key: + return key + return "" + + @classmethod + def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: + clean_tmdb_id = cls._clean_text(tmdb_id) + clean_media_type = cls._clean_text(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 [] + endpoint = "movie" if clean_media_type == "movie" else "tv" + url = ( + f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?" + f"{urlencode({'api_key': tmdb_api_key, 'language': 'zh-CN', 'append_to_response': 'credits'})}" + ) + 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_text((member or {}).get("name")) + department = cls._clean_text((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 + + @classmethod + def _tmdb_latest_episode_progress(cls, keyword: str, year: str = "") -> Dict[str, Any]: + title = cls._clean_text(keyword) + if not title: + return {} + tmdb_api_key = cls._read_tmdb_api_key() + if not tmdb_api_key: + return {} + params = { + "api_key": tmdb_api_key, + "language": "zh-CN", + "query": title, + } + try: + search_url = "https://api.themoviedb.org/3/search/tv?" + urlencode(params) + request = UrlRequest(url=search_url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode("utf-8", "ignore")) + results = payload.get("results") or [] + if not isinstance(results, list) or not results: + return {} + target_year = cls._clean_text(year)[:4] + picked = None + for item in results: + first_air = cls._clean_text((item or {}).get("first_air_date"))[:4] + if target_year and first_air and first_air == target_year: + picked = item + break + if picked is None: + picked = results[0] + tv_id = cls._clean_text((picked or {}).get("id")) + if not tv_id: + return {} + detail_url = ( + f"https://api.themoviedb.org/3/tv/{tv_id}?" + + urlencode({"api_key": tmdb_api_key, "language": "zh-CN"}) + ) + detail_request = UrlRequest(url=detail_url, headers={"Accept": "application/json"}) + with urlopen(detail_request, timeout=20) as response: + detail = json.loads(response.read().decode("utf-8", "ignore")) + last_episode = (detail.get("last_episode_to_air") or {}) if isinstance(detail, dict) else {} + season_number = cls._safe_int(last_episode.get("season_number"), 0) + episode_number = cls._safe_int(last_episode.get("episode_number"), 0) + return { + "tmdb_id": tv_id, + "title": cls._clean_text(detail.get("name") or picked.get("name") or title), + "year": cls._clean_text(detail.get("first_air_date") or picked.get("first_air_date"))[:4], + "season": season_number, + "episode": episode_number, + "status": cls._clean_text(detail.get("status")), + } + except Exception: + return {} + + def _maybe_enrich_hdhive_candidate_with_actors(self, candidate: Dict[str, Any]) -> Dict[str, Any]: + enriched = dict(candidate or {}) + if enriched.get("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]]) -> List[Dict[str, Any]]: + indexed = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] + pending = [(idx, item) for idx, item in indexed if not (item.get("actors") or [])] + enriched_map: Dict[int, Dict[str, Any]] = {idx: item for idx, item in indexed} + if pending: + with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as executor: + future_map = { + executor.submit(self._maybe_enrich_hdhive_candidate_with_actors, item): idx + for idx, item 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[idx][1]) + return [enriched_map[idx] for idx, _ in indexed] + + @staticmethod + def _format_candidate_label(item: Dict[str, Any]) -> str: + title = str(item.get("title") or "未命名") + year = str(item.get("year") or "?") + raw_media_type = str(item.get("media_type") or item.get("type") or "?") + media_type = { + "movie": "电影", + "tv": "剧集", + "series": "剧集", + }.get(raw_media_type.lower(), raw_media_type) + actors = item.get("actors") or [] + parts = [year, media_type] + actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) + if actor_text: + parts.append(f"主演:{actor_text}") + return f"{title} ({' | '.join([part for part in parts if part])})" + + @staticmethod + def _format_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "候选影片:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"候选影片:{len(candidates)} 个,请先选择影片:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") + lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @staticmethod + def _format_mp_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 20) -> str: + if not candidates: + return "MP 搜索候选:0 个" + safe_page_size = max(1, int(page_size or 20)) + total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) + safe_page = min(max(1, int(page or 1)), total_pages) + start = (safe_page - 1) * safe_page_size + page_items = candidates[start:start + safe_page_size] + lines = [f"MP 搜索候选:{len(candidates)} 个,请先选择正确的电影/剧集:"] + if total_pages > 1: + lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") + for idx, item in enumerate(page_items, start=start + 1): + lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") + lines.append("下一步:回复编号或“选择 编号”,选定后再搜索 PT 资源。") + lines.append("如需补充当前候选页主演,可回复:详情 或 审查。") + lines.append("如果已经知道年份,也可以直接发:MP搜索 片名 年份。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(lines) + + @classmethod + def _keyword_has_explicit_year(cls, keyword: Any, year: Any = "") -> bool: + return bool(cls._clean_text(year) or re.search(r"(?:19|20)\d{2}", cls._clean_text(keyword))) + + @staticmethod + def _list_text(value: Any, separator: str = "/") -> str: + if value is None: + return "" + if isinstance(value, (list, tuple, set)): + parts = [str(item).strip() for item in value if str(item).strip()] + return separator.join(parts) + return str(value).strip() + + @staticmethod + def _truncate_text(value: Any, limit: int = 140) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if not text: + return "" + if len(text) <= limit: + return text + return f"{text[: max(0, limit - 1)]}…" + + @staticmethod + def _resource_points_text(item: Dict[str, Any]) -> str: + points = item.get("unlock_points") + if points is None: + points = item.get("cost") + if points is None: + points = item.get("points") + if points in (0, "0"): + return "免费" + if points in (None, "", "未知"): + return "免费" if AgentResourceOfficer._resource_has_free_marker(item) else "未标积分" + return f"{points}分" + + @staticmethod + def _resource_subtitle_text(item: Dict[str, Any]) -> str: + language = AgentResourceOfficer._list_text( + item.get("subtitle_language") + or item.get("subtitle_languages") + or item.get("subtitles") + or item.get("subtitle") + ) + subtitle_type = AgentResourceOfficer._list_text(item.get("subtitle_type") or item.get("subtitle_types")) + if language and subtitle_type: + return f"{language} · {subtitle_type}" + return language or subtitle_type + + @staticmethod + def _resource_episode_text(item: Dict[str, Any]) -> str: + explicit_keys = [ + "episode_range", + "episodes_range", + "episode_info", + "episodes", + "episode", + "update_status", + "update_info", + "season_episode", + ] + for key in explicit_keys: + text = AgentResourceOfficer._list_text(item.get(key)) + if text: + return AgentResourceOfficer._truncate_text(text, 40) + + source_text = " ".join( + str(item.get(key) or "") + for key in ["title", "remark", "description", "desc", "detail", "note"] + ) + patterns = [ + r"(全\s*\d+\s*集)", + r"(全集)", + r"(更新至\s*第?\s*\d+\s*集)", + r"(更\s*\d+\s*集)", + r"(第?\s*\d+\s*[-~到至]\s*\d+\s*集)", + r"(\d+\s*-\s*\d+\s*集)", + r"(S\d{1,2}E\d{1,3}(?:\s*[-~]\s*E?\d{1,3})?)", + r"(EP?\s*\d{1,3}(?:\s*[-~]\s*EP?\s*\d{1,3})?)", + ] + for pattern in patterns: + match = re.search(pattern, source_text, flags=re.IGNORECASE) + if match: + return re.sub(r"\s+", "", match.group(1)) + return "" + + @staticmethod + def _resource_remark_text(item: Dict[str, Any]) -> str: + for key in ["remark", "description", "desc", "detail", "details", "summary", "note"]: + text = AgentResourceOfficer._truncate_text(item.get(key), 160) + if text: + return text + return "" + + def _format_resource_lines( + self, + resources: List[Dict[str, Any]], + candidate: Optional[Dict[str, Any]] = None, + *, + page: int = 1, + page_size: int = 20, + total_resources: Optional[int] = None, + ) -> str: + safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(resources), page=page, page_size=page_size) + page_items = resources[start:end] + total_count = max(0, self._safe_int(total_resources if total_resources is not None else len(resources), len(resources))) + lines = [] + if candidate: + candidate_title = str(candidate.get("title") or "未命名") + candidate_year = str(candidate.get("year") or "?") + lines.append(f"已选影片:{candidate_title} ({candidate_year})") + lines.append(f"资源结果:共 {total_count} 条") + if total_pages > 1 and page_items: + first_visible = self._safe_int((page_items[0] or {}).get("pick_index"), start + 1) + last_visible = self._safe_int((page_items[-1] or {}).get("pick_index"), min(len(resources), end)) + lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {first_visible}-{last_visible} / 共 {total_count} 条:") + current_provider = "" + for local_idx, item in enumerate(page_items, start=1): + provider = str(item.get("pan_type") or "?").lower() + if provider != current_provider: + current_provider = provider + if lines and lines[-1] != "": + lines.append("") + if provider == "115": + lines.append("🟦 115 结果") + elif provider == "quark": + lines.append("🟨 夸克结果") + else: + lines.append(f"{provider} 结果") + global_index = self._safe_int(item.get("pick_index"), start + local_idx) + lines.append(self._format_hdhive_resource_summary_line(item, global_index)) + lines.append("") + summary = self._score_summary(page_items, limit=5) + best_index = self._safe_int((((summary or {}).get("best") or {}).get("index")), 0) + detail_hint = f"选择 {best_index} 详情" if best_index > 0 else "选择 1 详情" + suggestion_lines = self._format_hdhive_selection_suggestion(page_items) + if suggestion_lines: + lines.append("") + lines.extend(suggestion_lines) + lines.append(f"下一步:直接回编号即可转存;想先确认可发“{detail_hint}”。") + lines.append("如需保留计划确认链,可再发“计划选择 编号”。") + if safe_page < total_pages: + lines.append("如需继续翻页,可回复:n 下一页") + return "\n".join(line for line in lines if line is not None) + + def _assistant_hdhive_resource_entry_summary(self, resources: List[Dict[str, Any]]) -> Dict[str, Any]: + best = self._best_scored_source_item(resources) + index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) + if index <= 0: + return { + "stage": "hdhive_resource", + "label": "先查看条目再选择编号", + "preferred_command": "", + "fallback_command": "", + "recommended_agent_behavior": "show_only", + } + return { + "stage": "hdhive_resource", + "label": "影巢资源列表已返回", + "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", + "preferred_command": str(index), + "fallback_command": f"选择 {index} 详情", + "compact_commands": [str(index), f"选择 {index} 详情"], + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": False, + "can_auto_run_preferred": False, + "recommended_agent_behavior": "show_only", + } + + @classmethod + def _normalize_hdhive_resource_short_action( + cls, + value: Any, + *, + state: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + current_state = dict(state or {}) + if cls._clean_text(current_state.get("kind")) != "assistant_hdhive" or cls._clean_text(current_state.get("stage")) != "resource": + return {} + raw = cls._clean_text(value) + patterns = [ + (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), + (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), + ] + for pattern, base in patterns: + match = re.match(pattern, raw, flags=re.IGNORECASE) + if match: + return {"index": match.group(1), **base} + return {} + + @staticmethod + def _format_route_result(result: Dict[str, Any]) -> str: + lines = ["已执行资源路由"] + route = result.get("route") or {} + provider = str(route.get("provider") or route.get("pan_type") or "") + if provider: + lines.append(f"网盘:{provider}") + if route.get("message"): + lines.append(f"结果:{route.get('message')}") + if route.get("target_path"): + lines.append(f"目录:{route.get('target_path')}") + return "\n".join(lines) + + async def _unlock_and_route( + self, + slug: str, + target_path: str = "", + resource: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Dict[str, Any], str]: + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return False, disabled.get("data") or {}, disabled.get("message") or "影巢资源入口已关闭" + points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(resource) + if not points_ok: + return False, {"resource_guard": points_data, "resource": resource or {}}, points_message + service = self._ensure_hdhive_service() + unlock_ok, result, unlock_message = service.unlock_resource(slug) + if not unlock_ok: + return False, result, unlock_message + + unlock_data = result.get("data") or {} + share_url = self._clean_text(unlock_data.get("full_url") or unlock_data.get("url")) + access_code = self._clean_text(unlock_data.get("access_code")) + pan_type = self._clean_text(unlock_data.get("pan_type")).lower() + + route_result: Dict[str, Any] = { + "unlock": result, + "route": { + "pan_type": pan_type or "unknown", + "share_url": share_url, + "access_code": access_code, + "executed": False, + "message": "", + }, + } + + if share_url and (pan_type == "quark" or self._is_quark_url(share_url)): + quark_service = self._ensure_quark_service() + transfer_ok, transfer_result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "quark", + "target_path": target_path or self._quark_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, ( + "影巢解锁成功,但" + + self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ) + ) + return True, route_result, "success" + + if share_url and (pan_type == "115" or self._is_115_url(share_url)): + p115_service = self._ensure_p115_service() + transfer_ok, transfer_result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger="Agent影视助手 影巢解锁后自动路由", + ) + route_result["route"].update( + { + "executed": True, + "provider": "115", + "target_path": target_path or self._p115_default_path, + "message": transfer_message, + "result": transfer_result, + } + ) + if not transfer_ok: + return False, route_result, self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + title="影巢解锁成功,但 115 转存失败", + ) + return True, route_result, "success" + + route_result["route"]["message"] = "当前解锁结果未识别到可自动路由的 115 / 夸克链接" + return True, route_result, "success" + + @staticmethod + def _is_quark_url(value: str) -> bool: + return QuarkTransferService.is_quark_share_url(value) + + @staticmethod + def _is_115_url(value: str) -> bool: + host = urlparse(value or "").netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + @staticmethod + def _run_coroutine_sync(coro): + try: + return asyncio.run(coro) + except RuntimeError: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + def feishu_assistant_route(self, text: str, session: str) -> Dict[str, Any]: + return self._run_coroutine_sync( + self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "feishu", + "compact": False, + }, + ) + ) + ) + + def feishu_assistant_pick(self, arg: str, session: str) -> Dict[str, Any]: + index, target_path, action, mode = self._parse_feishu_pick_arg(arg) + return self._run_coroutine_sync( + self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "feishu", + "index": index, + "action": action, + "mode": mode, + "path": target_path, + "compact": False, + }, + ) + ) + ) + + @classmethod + def _parse_feishu_pick_arg(cls, arg: str) -> Tuple[int, str, str, str]: + return cls._parse_pick_text(arg) + + async def tool_hdhive_search_session( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + target_path: str = "", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=self._clean_text(keyword), + media_type=self._clean_text(media_type or "auto").lower(), + year=self._clean_text(year), + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return f"影巢搜索失败:{search_message}" + + candidates = result.get("candidates") or [] + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type or "auto").lower(), + "year": self._clean_text(year), + "target_path": self._clean_text(target_path), + "candidates": candidates, + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return ( + f"{self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size)}\n" + f"session_id: {session_id}\n" + "下一步:调用 agent_resource_officer_hdhive_pick,并传入 session_id 与 choice" + ) + + async def tool_hdhive_pick_session(self, session_id: str, index: int, target_path: str = "", action: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return str(disabled.get("message") or "影巢资源入口已关闭") + session = self._load_session(self._clean_text(session_id)) + if not session: + return "会话不存在或已过期" + + stage = session.get("stage") + service = self._ensure_hdhive_service() + action = self._normalize_pick_action(action) + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(self._clean_text(session_id), {**session, "candidates": enriched}) + return self._format_candidate_lines(enriched, page=current_page, page_size=page_size) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_candidate_lines(candidates, page=next_page, page_size=page_size) + if index <= 0 or index > len(candidates): + return "候选编号超出范围" + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return f"影巢资源查询失败:{resource_message}" + preferences = self._normalize_assistant_preferences( + (self._assistant_preferences or {}).get(self._normalize_preference_key(session=self._clean_text(session.get("keyword")) or "default")) + ) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=target_path or session.get("target_path") or "", + ) + self._save_session( + self._clean_text(session_id), + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": self._clean_text(target_path) or session.get("target_path") or "", + }, + ) + return self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)) + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return "已经是最后一页了,可以直接回复编号继续选择。" + next_page = current_page + 1 + self._save_session(self._clean_text(session_id), {**session, "page": next_page}) + return self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)) + if index <= 0 or index > len(resources): + return "资源编号超出范围" + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=self._clean_text(target_path) or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return f"资源处理失败:{route_message}" + return self._format_route_result(route_result) + + return f"当前会话阶段不支持继续选择:{stage}" + + async def tool_route_share(self, share_url: str, access_code: str = "", target_path: str = "") -> str: + share_url = self._clean_text(share_url) + if not share_url: + return "缺少分享链接" + + if self._is_quark_url(share_url): + service = self._ensure_quark_service() + ok, result, message = service.transfer_share( + share_url, + access_code=self._clean_text(access_code), + target_path=self._clean_text(target_path) or self._quark_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_quark_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._quark_default_path, + ) + return f"夸克转存成功\n目录:{result.get('target_path') or self._quark_default_path}" + + if self._is_115_url(share_url): + ok, result, message = self._ensure_p115_service().transfer_share( + url=share_url, + access_code=self._clean_text(access_code), + path=self._clean_text(target_path) or self._p115_default_path, + trigger="Agent影视助手 Agent Tool", + ) + if not ok: + return self._format_p115_transfer_failure( + detail=message, + target_path=self._clean_text(target_path) or self._p115_default_path, + ) + return f"115 转存成功\n目录:{result.get('path') or self._p115_default_path}" + + return "当前链接不是可识别的 115 / 夸克分享链接" + + async def tool_assistant_route( + self, + text: str = "", + session: str = "default", + session_id: str = "", + target_path: str = "", + mode: str = "", + keyword: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + action: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_route( + _JsonRequestShim( + _RequestContextShim(), + { + "text": self._clean_text(text), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "path": self._clean_text(target_path), + "mode": self._clean_text(mode), + "keyword": self._clean_text(keyword), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "action": self._clean_text(action), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "处理完成") + + async def tool_assistant_pick( + self, + session: str = "default", + session_id: str = "", + index: int = 0, + action: str = "", + mode: str = "", + target_path: str = "", + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_pick( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": index, + "action": self._clean_text(action), + "mode": self._clean_text(mode), + "path": self._clean_text(target_path), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "继续处理完成") + + async def tool_assistant_help(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + return self._format_assistant_help_text(session=session_name) + + async def tool_assistant_capabilities(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_capabilities_compact_data(self._assistant_capabilities_public_data()) + return ( + f"Agent影视助手:{data.get('version')};" + f"工作流 {len(data.get('workflows') or [])} 个;" + f"Tool {len(data.get('agent_tools') or [])} 个" + ) + return self._format_assistant_capabilities_text() + + async def tool_assistant_readiness(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_readiness_compact_data(self._assistant_readiness_public_data()) + services = data.get("services") or {} + return ( + f"就绪:{'是' if data.get('can_start') else '否'};" + f"115:{'可用' if services.get('p115_ready') else '不可用'};" + f"影巢:{'已配' if services.get('hdhive_configured') else '未配'};" + f"夸克:{'已配' if services.get('quark_configured') else '未配'};" + f"待计划:{data.get('saved_plans_pending') or 0}" + ) + return self._format_assistant_readiness_text() + + async def tool_feishu_health(self, compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + channel = self._ensure_feishu_channel() + data = { + "plugin_version": self.plugin_version, + "plugin_enabled": self._enabled, + **channel.health(), + } + if not compact: + return json.dumps(data, ensure_ascii=False, indent=2) + return ( + f"飞书入口:{'已开启' if data.get('enabled') else '未开启'};" + f"长连接:{'运行中' if data.get('running') else '未运行'};" + f"SDK:{'可用' if data.get('sdk_available') else '缺失'};" + f"AppID:{'已填' if data.get('app_id_configured') else '未填'};" + f"AppSecret:{'已填' if data.get('app_secret_configured') else '未填'};" + f"白名单:chat {data.get('allowed_chat_count') or 0} / user {data.get('allowed_user_count') or 0};" + f"其他飞书入口:{'检测到运行中' if data.get('legacy_bridge_running') else '未检测到'}" + ) + + async def tool_assistant_pulse(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_pulse_text() + + async def tool_assistant_startup(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_startup_text() + + async def tool_assistant_maintain(self, execute: bool = False, limit: int = 100) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + return self._format_assistant_maintain_text(data) + + async def tool_assistant_toolbox(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_toolbox_text() + + async def tool_assistant_request_templates( + self, + limit: int = 100, + names: Any = None, + recipe: Any = None, + include_templates: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return self._format_assistant_request_templates_text(data) + + async def tool_assistant_selfcheck(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_assistant_selfcheck_text() + + async def tool_assistant_history( + self, + session: str = "", + session_id: str = "", + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_history_compact_data( + self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + ) + failed = sum(1 for item in (data.get("items") or []) if not item.get("success")) + return f"最近执行历史:{len(data.get('items') or [])} 条;失败 {failed} 条" + return self._format_assistant_history_text(session=session, session_id=session_id, limit=limit) + + async def tool_assistant_execute_action( + self, + name: str, + session: str = "default", + session_id: str = "", + choice: Optional[int] = None, + target_path: str = "", + keyword: str = "", + media_type: str = "", + year: str = "", + share_url: str = "", + access_code: str = "", + client_type: str = "", + source: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = "", + prefer_unexecuted: bool = True, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_action( + _JsonRequestShim( + _RequestContextShim(), + { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "choice": choice, + "path": self._clean_text(target_path), + "keyword": self._clean_text(keyword), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + "plan_id": self._clean_text(plan_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "动作执行完成") + + async def tool_assistant_execute_actions( + self, + actions: List[Dict[str, Any]], + session: str = "default", + session_id: str = "", + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_actions( + _JsonRequestShim( + _RequestContextShim(), + { + "actions": actions or [], + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "批量动作执行完成") + + async def tool_assistant_workflow( + self, + name: str, + session: str = "default", + session_id: str = "", + keyword: str = "", + choice: Optional[int] = None, + candidate_choice: Optional[int] = None, + resource_choice: Optional[int] = None, + target_path: str = "", + share_url: str = "", + access_code: str = "", + media_type: str = "", + year: str = "", + client_type: str = "", + source: str = "", + limit: int = 20, + dry_run: Optional[bool] = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + payload = { + "name": self._clean_text(name), + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "keyword": self._clean_text(keyword), + "choice": choice, + "candidate_choice": candidate_choice, + "resource_choice": resource_choice, + "path": self._clean_text(target_path), + "url": self._clean_text(share_url), + "access_code": self._clean_text(access_code), + "media_type": self._clean_text(media_type), + "year": self._clean_text(year), + "client_type": self._clean_text(client_type), + "source": self._clean_text(source), + "limit": self._safe_int(limit, 20), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + } + if dry_run is not None: + payload["dry_run"] = bool(dry_run) + result = await self.api_assistant_workflow( + _JsonRequestShim(_RequestContextShim(), payload) + ) + return str(result.get("message") or "工作流执行完成") + + async def tool_assistant_preferences( + self, + session: str = "default", + session_id: str = "", + user_key: str = "", + preferences: Optional[Dict[str, Any]] = None, + reset: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + method = "DELETE" if reset else "POST" if preferences else "GET" + result = await self.api_assistant_preferences( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session) or "default", + "session_id": self._clean_text(session_id), + "user_key": self._clean_text(user_key), + "preferences": preferences or {}, + "compact": bool(compact), + }, + method=method, + ) + ) + return str(result.get("message") or "偏好画像处理完成") + + async def tool_assistant_execute_plan( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_plan_execute( + _JsonRequestShim( + _RequestContextShim(), + { + "plan_id": self._clean_text(plan_id), + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + }, + ) + ) + return str(result.get("message") or "计划执行完成") + + async def tool_assistant_plans( + self, + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_plans_compact_data( + self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=False, + limit=limit, + ) + ) + pending = sum(1 for item in (data.get("items") or []) if not item.get("executed")) + return f"已保存计划:{len(data.get('items') or [])} 条;待执行 {pending} 条" + return self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + + async def tool_assistant_plans_clear( + self, + plan_id: str = "", + session: str = "", + session_id: str = "", + executed: Optional[bool] = None, + all_plans: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = self._clear_workflow_plans( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + return str(result.get("message") or "计划清理完成") + + async def tool_assistant_recover( + self, + session: str = "", + session_id: str = "", + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_recover( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "execute": bool(execute), + "prefer_unexecuted": bool(prefer_unexecuted), + "stop_on_error": bool(stop_on_error), + "include_raw_results": bool(include_raw_results), + "compact": bool(compact), + "limit": self._safe_int(limit, 20), + }, + ) + ) + return str(result.get("message") or "恢复检查完成") + + async def tool_assistant_session_state(self, session: str = "default", session_id: str = "", compact: bool = True) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) + if compact: + state = self._assistant_session_compact_data(self._assistant_session_public_data(session=session_name)) + recovery = state.get("recovery") or {} + parts = [ + f"会话:{state.get('session')}", + f"阶段:{state.get('kind') or '-'} / {state.get('stage') or '-'}", + f"恢复:{recovery.get('mode') or '-'}", + ] + if recovery.get("recommended_action"): + parts.append(f"推荐动作:{recovery.get('recommended_action')}") + return "\n".join(parts) + return self._format_assistant_session_summary(session=session_name) + + async def tool_assistant_sessions( + self, + kind: str = "", + has_pending_p115: Optional[bool] = None, + compact: bool = True, + limit: int = 20, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + if compact: + data = self._assistant_sessions_compact_data( + self._assistant_sessions_public_data(kind=kind, has_pending_p115=has_pending_p115, limit=limit) + ) + return f"活跃会话:{data.get('total') or 0} 个;展示 {len(data.get('items') or [])} 个" + return self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + + async def tool_assistant_session_clear(self, session: str = "default", session_id: str = "") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_name, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) + existing = self._load_session(cache_key) + if not existing: + return "当前没有需要清理的会话。" + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return f"已清理会话:{session_name}" + + async def tool_assistant_sessions_clear( + self, + session: str = "", + session_id: str = "", + kind: str = "", + has_pending_p115: Optional[bool] = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + result = await self.api_assistant_sessions_clear( + _JsonRequestShim( + _RequestContextShim(), + { + "session": self._clean_text(session), + "session_id": self._clean_text(session_id), + "kind": self._clean_text(kind), + "has_pending_p115": has_pending_p115, + "stale_only": bool(stale_only), + "all_sessions": bool(all_sessions), + "limit": self._safe_int(limit, 100), + }, + ) + ) + return str(result.get("message") or "会话清理完成") + + async def tool_p115_qrcode_start(self, client_type: str = "alipaymini") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + final_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=final_client_type) + if not qr_ok: + return f"115 扫码二维码生成失败:{qr_message}" + return ( + "115 扫码二维码已生成\n" + f"client_type: {data.get('client_type')}\n" + f"uid: {data.get('uid')}\n" + f"time: {data.get('time')}\n" + f"sign: {data.get('sign')}\n" + f"qrcode: {data.get('qrcode')}\n" + "下一步:调用 agent_resource_officer_p115_qrcode_check,并传入 uid、time、sign 和 client_type" + ) + + async def tool_p115_qrcode_check( + self, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(uid), + time_value=self._clean_text(time_value), + sign=self._clean_text(sign), + client_type=P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type), + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": self._p115_client_type, + }) + data["cookie_saved"] = True + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"status: {status or 'unknown'}", + f"message: {qr_message}", + ] + if data.get("cookie_saved"): + lines.append("cookie_saved: true") + lines.append(self._format_p115_status_summary(title="115 登录完成")) + if data.get("cookie_keys"): + lines.append(f"cookie_keys: {', '.join(data.get('cookie_keys') or [])}") + return "\n".join(lines) + + async def tool_p115_status(self) -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + return self._format_p115_status_summary() + + async def tool_p115_pending(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + return summary or "当前没有待继续的 115 任务。" + + async def tool_p115_resume(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return "当前没有待继续的 115 任务。" + if not self._p115_status_snapshot().get("ready"): + return f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。" + resume_ok, resume_message, _ = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 Agent Tool 手动继续 115 任务", + ) + lines = ["已手动继续 115 任务", resume_message] + if not resume_ok: + lines.append("任务仍未成功,保留待继续状态。") + return "\n".join(line for line in lines if line) + + async def tool_p115_cancel(self, session: str = "default") -> str: + if not self._enabled: + return "Agent影视助手 插件未启用" + session_id = self._session_key_for_tool(session) + summary = self._pending_p115_summary(self._load_session(session_id)) + if not summary: + return "当前没有待取消的 115 任务。" + self._clear_pending_p115_share(session_id) + return f"{summary}\n已取消并清除这次待继续的 115 任务。" + + async def api_p115_health(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + + service = self._ensure_p115_service() + health_ok, result, health_message = service.health() + cookie_state = result.get("cookie_state") or {} + return { + "success": True, + "data": { + "plugin_version": self.plugin_version, + "enabled": self._enabled, + "p115_ready": health_ok, + "p115_direct_ready": bool(result.get("direct_ready")), + "p115_direct_source": result.get("direct_source") or "", + "p115_helper_ready": bool(result.get("helper_ready")), + "default_target_path": self._p115_default_path, + "p115_client_type": self._p115_client_type, + "p115_cookie_configured": bool(cookie_state.get("configured")), + "p115_cookie_valid": bool(cookie_state.get("valid")), + "p115_cookie_mode": cookie_state.get("mode") or "none", + "p115_cookie_keys": cookie_state.get("cookie_keys") or [], + "message": "" if health_ok else health_message, + "raw": result, + }, + } + + async def api_p115_qrcode(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": qr_message} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_qrcode_check(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + uid = self._clean_text(request.query_params.get("uid")) + time_value = self._clean_text(request.query_params.get("time")) + sign = self._clean_text(request.query_params.get("sign")) + if not uid or not time_value or not sign: + return {"success": False, "message": "缺少 uid/time/sign,无法检查扫码状态"} + client_type = P115TransferService.normalize_qrcode_client_type( + request.query_params.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=uid, + time_value=time_value, + sign=sign, + client_type=client_type, + ) + if qr_ok and (data.get("status") == "success"): + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + data["status_summary"] = self._format_p115_status_summary(title="115 登录完成") + if not qr_ok: + return {"success": False, "message": qr_message, "data": data} + return {"success": True, "message": qr_message, "data": data} + + async def api_p115_transfer(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + url = self._clean_text(body.get("url") or body.get("share_url")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") + + service = self._ensure_p115_service() + transfer_ok, result, transfer_message = service.transfer_share( + url=url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": result, + } + return {"success": True, "message": transfer_message, "data": result} + + async def api_p115_pending(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + data = self._pending_p115_public_data(state) + data["session_id"] = session_id + return { + "success": True, + "message": summary or "当前没有待继续的 115 任务。", + "data": data, + } + + async def api_p115_pending_resume(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + if not self._pending_p115_summary(state): + return { + "success": False, + "message": "当前没有待继续的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。", + "data": {"session_id": session_id, **self._pending_p115_public_data(state)}, + } + resume_ok, resume_message, resume_data = self._execute_pending_p115_share( + session_id=session_id, + state=state, + trigger="Agent影视助手 API 手动继续 115 任务", + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": { + "session_id": session_id, + "result": resume_data, + "pending": self._pending_p115_public_data(self._load_session(session_id) or {}), + }, + } + + async def api_p115_pending_cancel(self, request: Request): + body = await self._request_payload(request) + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text( + body.get("session") + or body.get("session_id") + or request.query_params.get("session") + or request.query_params.get("session_id") + or "default" + ) + session_id = self._session_key_for_tool(session) + state = self._load_session(session_id) or {} + summary = self._pending_p115_summary(state) + if not summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": {"session_id": session_id, "has_pending": False}, + } + pending_data = self._pending_p115_public_data(state) + self._clear_pending_p115_share(session_id) + return { + "success": True, + "message": f"{summary}\n已取消并清除这次待继续的 115 任务。", + "data": {"session_id": session_id, "cancelled": True, "pending": pending_data}, + } + + async def api_hdhive_unlock_and_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + slug = self._clean_text(body.get("slug")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + route_ok, route_result, route_message = await self._unlock_and_route(slug, target_path=target_path, resource=body) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return {"success": True, "message": route_message, "data": route_result} + + async def api_share_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + share_url = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) + access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + trigger = self._clean_text(body.get("trigger") or "Agent影视助手 自动路由") + + if self._is_quark_url(share_url): + quark_service = self._ensure_quark_service() + transfer_ok, result, transfer_message = quark_service.transfer_share( + share_url, + access_code=access_code, + target_path=target_path or self._quark_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_quark_transfer_failure( + detail=transfer_message, + target_path=target_path or self._quark_default_path, + ), + "data": { + "provider": "quark", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "quark", + "result": result, + }, + } + + if self._is_115_url(share_url): + p115_service = self._ensure_p115_service() + transfer_ok, result, transfer_message = p115_service.transfer_share( + url=share_url, + access_code=access_code, + path=target_path or self._p115_default_path, + trigger=trigger, + ) + if not transfer_ok: + return { + "success": False, + "message": self._format_p115_transfer_failure( + detail=transfer_message, + target_path=target_path or self._p115_default_path, + ), + "data": { + "provider": "115", + "result": result, + }, + } + return { + "success": True, + "message": transfer_message, + "data": { + "provider": "115", + "result": result, + }, + } + + return { + "success": False, + "message": "当前链接不是可识别的 115 / 夸克分享链接", + "data": {"provider": "unknown", "url": share_url}, + } + + async def api_assistant_preferences(self, request: Request): + body: Dict[str, Any] = {} + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session") or "default") + user_key = self._clean_text(body.get("user_key") or request.query_params.get("user_key")) + if request.method.upper() == "DELETE" or self._parse_bool_value(body.get("reset"), False): + preferences_data = self._reset_assistant_preferences(session=session, user_key=user_key) + return { + "success": True, + "message": "智能体片源偏好已重置,下一次启动会重新进入偏好询问。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_reset", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + if request.method.upper() == "POST": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else { + key: value + for key, value in body.items() + if key not in {"apikey", "session", "session_id", "user_key", "compact", "reset"} + } + preferences_data = self._save_assistant_preferences(session=session, user_key=user_key, preferences=preferences) + return { + "success": True, + "message": "智能体片源偏好已保存。", + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": True, + "preferences": preferences_data, + "write_effect": "state", + }), + } + + preferences_data = self._assistant_preferences_public_data(session=session, user_key=user_key) + message_text = "智能体片源偏好未初始化,请先询问用户偏好。" if preferences_data.get("needs_onboarding") else "智能体片源偏好已初始化。" + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "preferences", + "ok": True, + "preferences": preferences_data, + }), + } + + async def api_assistant_route(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + text = self._clean_text(body.get("text") or body.get("query") or body.get("message") or "") + state = self._load_session(cache_key) or {} + compact = self._parse_bool_value(body.get("compact"), False) + saved_plan = self._session_workflow_plan_public_data(session=session, session_id=cache_key) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + pending_plan_group = self._clean_text(state.get("pending_plan_group")) + numeric_confirmation = self._parse_pending_plan_numeric_confirmation(text) + pending_multi_plan = None + if pending_plan_group and (numeric_confirmation > 0 or self._is_pending_plan_confirmation_text(text)): + pending_multi_plan = self._find_pending_multi_plan( + session=session, + session_id=cache_key, + rank=numeric_confirmation if numeric_confirmation > 0 else 1, + group_id=pending_plan_group, + ) + pending_plan = pending_multi_plan or self._find_workflow_plan(session=session, session_id=cache_key, executed=False) + if ( + pending_plan + and ( + self._is_pending_plan_confirmation_text(text) + or self._is_pending_plan_numeric_confirmation(text, pending_plan) + ) + and not self._clean_text(body.get("plan_id")) + ): + if isinstance(state.get("recommend_handoff"), dict) and state.get("recommend_handoff"): + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(pending_plan.get("plan_id")) if pending_multi_plan else "", + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_direct_intent = self._normalize_mp_recommend_direct_intent(text) + if recommend_direct_intent: + source_name, inferred_media_type = self._normalize_mp_recommend_request( + recommend_direct_intent.get("keyword") or "tmdb_trending" + ) + recommend_result = await self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + ) + if not recommend_result.get("success"): + return finish(recommend_result) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_direct_intent.get("index") or 1, + "action": recommend_direct_intent.get("action"), + "mode": recommend_direct_intent.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_short_direct = self._normalize_mp_recommend_short_action(text, state=state) + if recommend_short_direct: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": recommend_short_direct.get("index"), + "action": recommend_short_direct.get("action"), + "mode": recommend_short_direct.get("mode"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + recommend_source_compound = self._normalize_recommend_source_compound_action(text, state=state) + if recommend_source_compound: + return finish(await self._assistant_run_recommend_source_compound( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(recommend_source_compound.get("mode")), + followup_action=self._clean_text(recommend_source_compound.get("followup_action")), + compact=compact, + target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + )) + + hdhive_resource_short = self._normalize_hdhive_resource_short_action(text, state=state) + if hdhive_resource_short: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "index": hdhive_resource_short.get("index"), + "action": hdhive_resource_short.get("action"), + "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + parsed = self._merge_assistant_structured_input(body, self._parse_assistant_text(text)) + target_path = parsed.get("path") or "" + + recommend_followup = self._normalize_mp_recommend_followup(text, state=state) + has_recommend_followup = bool(recommend_followup) + if self._clean_text((recommend_followup or {}).get("action")) == "mp_recommendations": + return finish(await self._assistant_mp_recommendations( + source=self._clean_text(recommend_followup.get("keyword") or state.get("requested_source") or state.get("source") or "tmdb_trending"), + media_type=self._clean_text(recommend_followup.get("type") or state.get("media_type") or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if recommend_followup: + for key, value in recommend_followup.items(): + if value not in {None, ""}: + parsed[key] = str(value) + preparsed_action = self._clean_text(parsed.get("action")) + recommend_handoff_short = self._normalize_recommend_handoff_short_action(text, state=state) + recommend_short = self._normalize_mp_recommend_short_action(text, state=state) + if preparsed_action not in {"ai_replay_failed_sample"} and recommend_short: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": recommend_short.get("index"), + "action": recommend_short.get("action"), + "mode": recommend_short.get("mode"), + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + pick_index, pick_path, pick_action, pick_mode = self._parse_pick_text(text) + handoff_route_action = self._clean_text(recommend_handoff_short.get("route_action")) + if not pick_index and handoff_route_action: + pick_action = handoff_route_action + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"} and (pick_index > 0 or pick_action): + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": pick_index, + "action": pick_action, + "mode": pick_mode, + "path": target_path or pick_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"}: + route_action = self._normalize_pick_action(text) + smart_route_action = self._normalize_smart_search_short_action(text, state_kind=state.get("kind")) + if smart_route_action: + route_action = smart_route_action + if handoff_route_action: + route_action = handoff_route_action + if route_action: + pick_result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "index": 0, + "action": route_action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + return pick_result + + if not text and not any(parsed.get(key) for key in ["mode", "keyword", "url", "action"]): + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + + assistant_action = preparsed_action + ai_short_action = self._normalize_ai_reingest_short_action(text, state=state) + if not assistant_action and ai_short_action: + for key, value in ai_short_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + recommend_handoff_action = self._normalize_recommend_handoff_action(text, state=state) + if not assistant_action and recommend_handoff_action: + for key, value in recommend_handoff_action.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + if not assistant_action and self._clean_text(recommend_handoff_short.get("action")): + for key, value in recommend_handoff_short.items(): + if value not in {None, ""}: + parsed[key] = value + assistant_action = self._clean_text(parsed.get("action")) + keyword = self._clean_text(parsed.get("keyword")) + if assistant_action == "assistant_help": + summary = self._format_assistant_help_text(session=session) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "assistant_help", + "ok": True, + "status_summary": summary, + }), + }) + if assistant_action == "execute_plan": + return finish(await self.api_assistant_plan_execute( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "plan_id": self._clean_text(parsed.get("plan_id")), + "prefer_unexecuted": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + if assistant_action == "plans_list": + include_actions = self._parse_bool_value(body.get("include_actions"), False) + executed = self._parse_optional_bool(body.get("executed")) + limit = self._safe_int(body.get("limit"), 10) + plans_data = self._assistant_plans_public_data( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + return finish({ + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=cache_key, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_list", + "ok": True, + **plans_data, + }), + }) + if assistant_action == "plans_clear": + plan_id = self._clean_text(parsed.get("plan_id")) + if not plan_id: + return finish({ + "success": False, + "message": "取消或清理计划需要指定 plan_id,例如:取消计划 plan-xxxx。", + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": False, + "error_code": "missing_plan_id", + }), + }) + clear_result = self._clear_workflow_plans(plan_id=plan_id, limit=1) + return finish({ + "success": bool(clear_result.get("ok")), + "message": str(clear_result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=session, data={ + "action": "plans_clear", + "ok": bool(clear_result.get("ok")), + **clear_result, + }), + }) + if assistant_action in {"preferences_get", "preferences_save", "preferences_reset"}: + if assistant_action == "preferences_save": + preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else self._parse_assistant_preferences_text(text) + if not preferences: + return finish({ + "success": False, + "message": ( + "保存偏好缺少可识别内容。示例:保存偏好 4K 杜比 HDR 中字 全集 " + "做种>=3 影巢积分20 不自动入库" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "preferences_save", + "ok": False, + "error_code": "missing_preferences", + }), + }) + payload = { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "preferences": preferences, + "compact": compact, + "apikey": self._extract_apikey(request, body), + } + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, payload, method="POST"))) + if assistant_action == "preferences_reset": + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="DELETE"))) + return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { + "session": session, + "user_key": self._clean_text(body.get("user_key")), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, method="GET"))) + if assistant_action == "scoring_policy": + return finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if assistant_action == "hdhive_checkin": + is_gambler = self._parse_bool_value(parsed.get("is_gambler"), self._hdhive_checkin_gambler_mode) + result = self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 智能入口") + data = result.get("data") if isinstance(result.get("data"), dict) else {} + status = data.get("status") or ("签到成功" if result.get("success") else "签到失败") + mode_text = "赌狗签到" if is_gambler else "普通签到" + summary = f"影巢{mode_text}:{status}\n{result.get('message') or ''}".strip() + return finish({ + "success": bool(result.get("success")), + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin", + "ok": bool(result.get("success")), + "is_gambler": is_gambler, + "status_summary": summary, + "result": data, + }), + }) + if assistant_action == "hdhive_checkin_history": + summary = self._format_hdhive_checkin_history_text(limit=10) + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_checkin_history", + "ok": True, + "status_summary": summary, + "history": self._hdhive_checkin_history_public_data(limit=10), + }), + }) + if assistant_action == "mp_media_detail": + if not keyword: + return finish({ + "success": False, + "message": "媒体识别失败:缺少片名。用法:识别 蜘蛛侠", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_detail", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_mp_media_detail( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "auto"), + year=self._clean_text(body.get("year") or parsed.get("year")), + )) + if assistant_action == "mp_downloaders": + return finish(await self._assistant_mp_downloaders(session=session, cache_key=cache_key)) + if assistant_action == "mp_sites": + return finish(await self._assistant_mp_sites( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "active"), + name=keyword, + limit=self._safe_int(body.get("limit"), 30), + )) + if assistant_action == "mp_subscribes": + return finish(await self._assistant_mp_subscribes( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "all"), + name=keyword, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "mp_transfer_history": + return finish(await self._assistant_mp_transfer_history( + session=session, + cache_key=cache_key, + title=keyword, + status=self._clean_text(body.get("status") or parsed.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_ingest_failures": + return finish(await self._assistant_mp_ingest_failures( + session=session, + cache_key=cache_key, + title=keyword, + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "ai_failed_samples": + return finish(await self._assistant_ai_failed_samples( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_worklist": + return finish(await self._assistant_ai_sample_worklist( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "ai_sample_insights": + return finish(await self._assistant_ai_sample_insights( + session=session, + cache_key=cache_key, + keyword=keyword, + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if assistant_action == "ai_replay_failed_sample": + sample_index = self._safe_int(parsed.get("sample_index") or body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(parsed.get("remove_if_resolved") or body.get("remove_if_resolved"), True) + return finish(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if assistant_action == "mp_subscribe_control": + control = self._clean_text(parsed.get("subscribe_control") or body.get("subscribe_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "搜索": "search", + "刷新": "search", + "search": "search", + "run": "search", + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + target = self._clean_text(keyword or body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if control not in {"search", "pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“订阅列表”,再发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”或“删除订阅 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "invalid_subscribe_control_args", + }), + }) + if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id): + return finish({ + "success": False, + "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_subscribe_control", + "ok": False, + "error_code": "subscribe_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return finish(await self._assistant_mp_subscribe_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if assistant_action == "mp_download_tasks": + return finish(await self._assistant_mp_download_tasks( + session=session, + cache_key=cache_key, + status=self._clean_text(body.get("status") or parsed.get("status") or "downloading"), + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if assistant_action == "mp_download_history": + return finish(await self._assistant_mp_download_history( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if assistant_action == "mp_lifecycle_status": + return finish(await self._assistant_mp_lifecycle_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_ingest_status": + return finish(await self._assistant_mp_ingest_status( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_recent_activity": + return finish(await self._assistant_mp_recent_activity( + session=session, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only") or parsed.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only") or parsed.get("transfer_only"), False), + )) + if assistant_action == "smart_followup": + return finish(await self._assistant_smart_followup( + request, + session=session, + session_id=cache_key, + keyword=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "smart_decision_adjust": + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=self._clean_text(parsed.get("decision_adjust") or body.get("decision_adjust") or "decision_continue"), + )) + if assistant_action == "execution_followup": + return finish(await self._assistant_execution_followup( + request, + session=session, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id") or parsed.get("plan_id")), + )) + if assistant_action == "mp_local_diagnose": + return finish(await self._assistant_mp_local_diagnose( + session=session, + cache_key=cache_key, + title=keyword, + hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), + limit=self._safe_int(body.get("limit"), 5), + )) + if assistant_action == "mp_download_control": + control = self._clean_text(parsed.get("download_control") or body.get("download_control") or body.get("control") or body.get("operation")).lower() + control_aliases = { + "暂停": "pause", + "停止": "pause", + "pause": "pause", + "stop": "pause", + "恢复": "resume", + "继续": "resume", + "开始": "resume", + "resume": "resume", + "start": "resume", + "删除": "delete", + "移除": "delete", + "delete": "delete", + "remove": "delete", + } + control = control_aliases.get(control, control) + target = self._clean_text(keyword or body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if control not in {"pause", "resume", "delete"} or not target: + return finish({ + "success": False, + "message": "用法:先发“下载任务”,再发“暂停下载 1”“恢复下载 1”或“删除下载 1”。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "invalid_download_control_args", + }), + }) + if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): + return finish({ + "success": False, + "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_control", + "ok": False, + "error_code": "download_task_target_not_found", + "target": target, + }), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=cache_key, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ))) + return finish(await self._assistant_mp_download_control( + session=session, + cache_key=cache_key, + control=control, + target=target, + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + )) + if assistant_action == "mp_download_best": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + return finish(await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action == "mp_download": + choice = self._safe_int(parsed.get("keyword") or body.get("choice") or body.get("index"), 0) + if choice <= 0: + return finish({ + "success": False, + "message": "用法:下载资源 1", + "data": self._assistant_response_data(session=session, data={"action": "mp_download", "ok": False}), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + ))) + return finish(await self._assistant_mp_download( + choice=choice, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if assistant_action in {"mp_subscribe", "mp_subscribe_search"}: + if not keyword: + return finish({ + "success": False, + "message": "用法:订阅媒体 片名 或 订阅并搜索 片名", + "data": self._assistant_response_data(session=session, data={"action": assistant_action, "ok": False}), + }) + if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): + return finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session, + cache_key=cache_key, + immediate_search=assistant_action == "mp_subscribe_search", + ))) + return finish(await self._assistant_mp_subscribe( + keyword=keyword, + session=session, + immediate_search=assistant_action == "mp_subscribe_search", + )) + if assistant_action == "mp_recommendations": + source, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or parsed.get("keyword") or text or "tmdb_trending" + ) + return finish(await self._assistant_mp_recommendations( + source=source, + media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or inferred_media_type or "all"), + limit=self._safe_int(body.get("limit"), 20), + session=session, + cache_key=cache_key, + )) + if assistant_action == "return_to_recommend": + return finish(await self._assistant_restore_mp_recommendation_handoff( + session=session, + cache_key=cache_key, + state=state, + limit=self._safe_int(body.get("limit"), 20), + )) + if assistant_action == "switch_recommend_handoff_source": + return finish(await self._assistant_switch_recommend_handoff_source( + request, + session=session, + cache_key=cache_key, + state=state, + mode=self._clean_text(parsed.get("mode")), + )) + if assistant_action == "return_to_smart_decision": + return finish(await self._assistant_route_recommend_handoff_to_smart_decision( + request, + session=session, + cache_key=cache_key, + state=state, + )) + if assistant_action == "confirm_recommend_handoff": + return finish(await self._assistant_confirm_recommend_handoff( + request, + session=session, + cache_key=cache_key, + state=state, + compact=compact, + target_path=target_path, + )) + if assistant_action == "p115_help": + summary = self._format_p115_help_text() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_help", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + } + if assistant_action == "p115_status": + summary = self._format_p115_status_summary() + pending_summary = self._pending_p115_summary(state) + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return finish({ + "success": True, + "message": summary, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_status", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }) + if assistant_action == "p115_pending": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + return { + "success": True, + "message": pending_summary, + "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), + } + if assistant_action == "quark_clear_default_dir": + bridge_result = self._assistant_quarkdisk_clear_directory(self._quark_default_path) + if bridge_result is None: + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + else: + clear_ok, clear_result, clear_message = bridge_result + if not clear_ok and isinstance(clear_result, dict) and clear_result.get("fallback_to_direct"): + quark_service = self._ensure_quark_service() + clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) + target_path = self._clean_text((clear_result or {}).get("target_path")) or self._quark_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + failed_items = (clear_result or {}).get("failed_items") or [] + lines = [ + f"清空夸克默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if removed_count: + lines.append(f"已删除:{removed_count} 项") + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + if isinstance(failed_items, list) and failed_items: + names = [ + self._clean_text(item.get("name") if isinstance(item, dict) else item) + for item in failed_items[:5] + ] + names = [name for name in names if name] + if names: + lines.append("失败项:" + "、".join(names)) + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"夸克默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"夸克默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "quark_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_clear_default_dir": + p115_service = self._ensure_p115_service() + clear_ok, clear_result, clear_message = p115_service.clear_directory(self._p115_default_path) + target_path = self._clean_text((clear_result or {}).get("path")) or self._p115_default_path + if not clear_ok: + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + lines = [ + f"清空115默认目录失败:{clear_message or '未知错误'}", + f"目录:{target_path}", + ] + if file_count or folder_count: + lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") + return finish({ + "success": False, + "message": "\n".join(lines), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": False, + "target_path": target_path, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) + file_count = self._safe_int((clear_result or {}).get("file_count"), 0) + folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) + if removed_count <= 0: + message_text = f"115 默认目录当前层已是空目录\n目录:{target_path}" + else: + message_text = ( + f"115 默认目录已清空当前层\n" + f"目录:{target_path}\n" + f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" + ) + return finish({ + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={ + "action": "p115_clear_default_dir", + "ok": True, + "target_path": target_path, + "removed_count": removed_count, + "file_count": file_count, + "folder_count": folder_count, + "result": clear_result, + "write_effect": "cloud_drive", + }), + }) + if assistant_action == "p115_resume": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + summary = self._format_p115_status_summary() + return { + "success": False, + "message": f"当前没有待继续的 115 任务。\n{summary}", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + if not self._p115_status_snapshot().get("ready"): + return { + "success": False, + "message": f"{pending_summary}\n当前 115 还不可用,请先回复:115登录", + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), + } + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "已手动继续 115 任务" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": resume_ok, "result": resume_data}), + } + if assistant_action == "p115_cancel": + pending_summary = self._pending_p115_summary(state) + if not pending_summary: + return { + "success": True, + "message": "当前没有待取消的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + self._clear_pending_p115_share(cache_key) + return { + "success": True, + "message": f"{pending_summary}\n已取消并清除这次待继续的 115 任务。", + "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), + } + if assistant_action == "p115_qrcode_start": + previous_state = state + client_type = P115TransferService.normalize_qrcode_client_type( + parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) + if not qr_ok: + return {"success": False, "message": f"115 扫码二维码生成失败:{qr_message}"} + self._save_session( + cache_key, + { + **previous_state, + "kind": "assistant_p115_login", + "stage": "qrcode", + "client_type": client_type, + "uid": self._clean_text(data.get("uid")), + "time": self._clean_text(data.get("time")), + "sign": self._clean_text(data.get("sign")), + }, + ) + pending_text = "" + if (previous_state.get("pending_p115") or {}).get("share_url"): + pending_text = "\n检测到有待继续的 115 任务,登录成功后我会自动继续执行。" + return { + "success": True, + "message": ( + "115 扫码二维码已生成\n" + f"客户端:{client_type}\n" + "请使用 115 App 扫码确认后,再回复:检查115登录" + f"{pending_text}" + ), + "data": self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_start", + "ok": True, + "qrcode": data.get("qrcode"), + "uid": data.get("uid"), + "time": data.get("time"), + "sign": data.get("sign"), + "client_type": client_type, + }), + } + if assistant_action == "p115_qrcode_check": + if not state or str(state.get("kind") or "").strip() != "assistant_p115_login": + pending_summary = self._pending_p115_summary(state) + if pending_summary and self._p115_status_snapshot().get("ready"): + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + message_text = "没有待检查的扫码会话,但检测到待继续的 115 任务。" + if resume_message: + message_text = f"{message_text}\n{resume_message}" + if not resume_ok: + message_text = f"{message_text}\n任务仍未成功,继续保留待处理状态。" + return { + "success": resume_ok, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": resume_ok, "result": resume_data}), + } + summary = self._format_p115_status_summary() + if pending_summary: + summary = f"{summary}\n{pending_summary}" + return { + "success": True, + "message": ( + "没有待检查的 115 登录会话。\n" + f"{summary}\n" + "如需重新扫码登录,请回复:115登录" + ), + "data": { + **self._assistant_response_data(session=session, data={ + "action": "p115_qrcode_check", + "ok": True, + "status_summary": summary, + "status": self._p115_status_snapshot(), + }), + }, + } + client_type = P115TransferService.normalize_qrcode_client_type( + state.get("client_type") or parsed.get("client_type") or self._p115_client_type + ) + qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( + uid=self._clean_text(state.get("uid")), + time_value=self._clean_text(state.get("time")), + sign=self._clean_text(state.get("sign")), + client_type=client_type, + ) + if qr_ok and data.get("status") == "success": + cookie = self._clean_text(data.pop("cookie")) + if cookie: + self._p115_cookie = cookie + self._p115_client_type = client_type + self._apply_runtime_config({ + "p115_cookie": cookie, + "p115_client_type": client_type, + }) + data["cookie_saved"] = True + data["cookie_mode"] = "client_cookie" + self._save_session(cache_key, {**state, "stage": "success", "client_type": client_type}) + if not qr_ok: + return { + "success": False, + "message": f"115 扫码状态:{qr_message}", + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": False, **data}), + } + status = self._clean_text(data.get("status")) + lines = [ + "115 扫码状态", + f"状态:{status or 'unknown'}", + f"结果:{qr_message}", + ] + if data.get("cookie_saved"): + lines.append(self._format_p115_status_summary(title="115 登录完成")) + resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( + request, + body, + session_id=cache_key, + state=state, + ) + if resume_message: + lines.append("已自动继续刚才未完成的 115 任务") + lines.append(resume_message) + data["resume_ok"] = resume_ok + data["resume_result"] = resume_data + if not resume_ok: + lines.append("任务仍未成功,已继续保留待处理状态。") + elif status in {"waiting", "scanned"}: + lines.append("如果还没确认登录,请在 115 App 里点确认后再次回复:检查115登录") + message_text = "\n".join(line for line in lines if line).strip() + return { + "success": True, + "message": message_text, + "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": True, **data}), + } + + if parsed.get("url"): + provider = "quark" if self._is_quark_url(parsed["url"]) else "115" if self._is_115_url(parsed["url"]) else "unknown" + result = await self.api_share_route( + _JsonRequestShim(request, { + "url": parsed["url"], + "access_code": parsed.get("access_code") or "", + "path": target_path, + "trigger": "Agent影视助手 智能入口", + "apikey": self._extract_apikey(request, body), + }) + ) + if provider == "115": + if result.get("success"): + self._clear_pending_p115_share(cache_key) + else: + self._save_pending_p115_share( + cache_key, + share_url=parsed["url"], + access_code=parsed.get("access_code") or "", + target_path=target_path or self._p115_default_path, + source="assistant_link", + last_error=str(result.get("message") or ""), + ) + return finish({ + "success": bool(result.get("success")), + "message": ( + f"{'夸克' if provider == 'quark' else '115' if provider == '115' else '分享'}转存已完成\n目录:" + f"{((result.get('data') or {}).get('result') or {}).get('target_path') or ((result.get('data') or {}).get('result') or {}).get('path') or target_path or '-'}" + if result.get("success") + else ( + f"{str(result.get('message') or '处理失败')}\n{self._format_p115_resume_hint()}" + if provider == "115" + else str(result.get("message") or "处理失败") + ) + ), + "data": self._assistant_response_data(session=session, data={ + "action": "share_route", + "ok": bool(result.get("success")), + "provider": provider, + "result": result.get("data") or {}, + }), + }) + + mode = parsed.get("mode") or "hdhive" + media_type = self._clean_text(parsed.get("type") or "auto").lower() or "auto" + year = self._clean_text(parsed.get("year")) + result_filter = self._clean_text(parsed.get("result_filter")).lower() + decision_intent = self._clean_text(parsed.get("decision_intent")).lower() + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else None + if not source_order: + parsed_source_order_text = self._clean_text(parsed.get("source_order_text")) + if parsed_source_order_text: + source_order = [ + self._clean_text(item).lower() + for item in parsed_source_order_text.split(",") + if self._clean_text(item) + ] + origin = self._clean_text(body.get("origin")) + recommend_handoff = body.get("recommend_handoff") if isinstance(body.get("recommend_handoff"), dict) else {} + apikey = self._extract_apikey(request, body) + session_preference_overrides: Dict[str, Any] = {} + cloud_provider = self._clean_text(parsed.get("cloud_provider") or body.get("cloud_provider") or body.get("provider")).lower() + if cloud_provider == "quark": + session_preference_overrides.update({ + "has_quark": True, + "has_115": False, + "prefer_cloud_provider": "quark", + }) + elif cloud_provider == "115": + session_preference_overrides.update({ + "has_quark": False, + "has_115": True, + "prefer_cloud_provider": "115", + }) + + if mode == "update": + return finish(await self._assistant_update_check( + keyword=keyword, + session=session, + cache_key=cache_key, + year=year, + )) + + if mode == "mp_download_title": + if not keyword: + return finish({ + "success": False, + "message": "用法:下载 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "mp_download_title", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "mp_download_title", + "label": "生成待确认下载计划", + "result_filter": result_filter, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + + if mode == "smart": + if not keyword: + return finish({ + "success": False, + "message": "用法:智能搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_search", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_search( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin, + )) + + if mode == "cloud_transfer_execute": + if not keyword: + return finish({ + "success": False, + "message": "用法:转存 片名;也支持:夸克转存 片名 / 115转存 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_transfer_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if not self._keyword_has_explicit_year(keyword, year): + pending_label = "云盘转存" + if cloud_provider == "quark": + pending_label = "夸克转存" + elif cloud_provider == "115": + pending_label = "115转存" + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={ + "mode": "cloud_transfer_execute", + "cloud_provider": cloud_provider, + "label": pending_label, + }, + target_path=target_path, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" + year = candidate_year or year + return finish(await self._assistant_cloud_transfer_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order or ["pansou", "hdhive"], + target_path=target_path, + session_preference_overrides=session_preference_overrides, + origin=origin or cloud_provider or "cloud_transfer", + )) + + if mode == "smart_decision": + if not keyword: + return finish({ + "success": False, + "message": "用法:资源决策 片名;也支持:智能决策 片名。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_decision", + "ok": False, + "error_code": "missing_keyword", + }), + }) + if decision_intent == "make_plan": + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + if decision_intent == "show_detail": + return finish(await self._assistant_smart_decision_followup_detail( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + compact=compact, + apikey=apikey, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + )) + if decision_intent == "execute_now": + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + return finish(await self._assistant_smart_resource_decision( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), + origin=origin, + )) + + if mode == "smart_plan": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能计划 片名;也可以先做“智能搜索 片名”,再回复“计划最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_plan", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_plan( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "smart_execute": + if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": + return finish({ + "success": False, + "message": "用法:智能执行 片名;也可以先做“智能搜索 片名”,再回复“执行最佳”。", + "data": self._assistant_response_data(session=session, data={ + "action": "smart_resource_execute", + "ok": False, + "error_code": "missing_keyword", + }), + }) + return finish(await self._assistant_smart_resource_execute( + request, + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + source_order=source_order, + target_path=target_path, + origin=origin, + )) + + if mode == "mp": + if not keyword: + return finish({ + "success": False, + "message": "用法:MP搜索 片名", + "data": self._assistant_response_data(session=session, data={ + "action": "media_search", + "ok": False, + }), + }) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + if not self._keyword_has_explicit_year(keyword, year): + candidate_result = await self._assistant_mp_candidate_search( + keyword=keyword, + session=session, + cache_key=cache_key, + media_type=media_type, + year=year, + pending_action={"mode": "mp", "result_filter": result_filter} if result_filter else None, + ) + candidates = (candidate_result.get("data") or {}).get("candidates") or [] + if len(candidates) > 1: + return finish(candidate_result) + if len(candidates) == 1: + candidate = dict(candidates[0] or {}) + candidate_title = self._clean_text(candidate.get("title")) or keyword + candidate_year = self._clean_text(candidate.get("year")) + if candidate_year and candidate_year not in candidate_title: + keyword = f"{candidate_title} {candidate_year}" + else: + keyword = candidate_title + result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=result_filter, + ) + mp_items = (result.get("data") or {}).get("items") or [] + if (not result.get("success") or not mp_items) and keyword: + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="mp_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("MP/PT 当前暂无可用结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + search_ok, hdhive_result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if search_ok: + candidates = hdhive_result.get("candidates") or [] + if candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="MP/PT 当前暂无可用结果,已自动补查影巢。", + )) + if result.get("success") and recommend_handoff: + current_state = self._load_session(cache_key) or {} + self._save_session(cache_key, {**current_state, "recommend_handoff": dict(recommend_handoff)}) + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(self._load_session(cache_key) or {})) + result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(self._load_session(cache_key) or {}) + result["data"] = self._assistant_response_data(session=session, data=result_data) + return finish(result) + + if mode == "pansou" or mode == "cloud": + search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if not search_ok: + if mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜搜索失败:{keyword}\n错误:{search_message}"} + search_ok = False + payload = {} + search_message = search_message + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + items.append({**item, "index": len(items) + 1}) + if mode == "cloud" and not items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = local_index + hdhive_candidate = dict(chosen_candidate) + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=[], + pansou_total=int(data.get("total") or 0), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if not items and mode == "pansou": + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + candidates = (hdhive_result or {}).get("candidates") or [] + if hdhive_ok and candidates: + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + lead_note="盘搜当前暂无结果,已自动补查影巢。", + )) + mp_preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + mp_result = await self._assistant_mp_media_search( + keyword=keyword, + session=session, + cache_key=cache_key, + preferences=mp_preferences, + ) + mp_items = (mp_result.get("data") or {}).get("items") or [] + if mp_result.get("success") and mp_items: + mp_result["message"] = self._prepend_search_note(mp_result.get("message") or "", "盘搜当前暂无结果,已自动补查 MP/PT。") + return finish(mp_result) + return {"success": False, "message": f"盘搜暂无结果:{keyword}"} + if items and mode == "cloud": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_cloud_result_page_size) + + hdhive_resources: List[Dict[str, Any]] = [] + hdhive_candidate: Dict[str, Any] = {} + hdhive_candidates: List[Dict[str, Any]] = [] + allowed, _disabled = self._ensure_hdhive_resource_enabled() + if allowed: + service = self._ensure_hdhive_service() + hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + hdhive_candidates = (hdhive_result or {}).get("candidates") or [] + chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) + if hdhive_ok and chosen_candidate: + resource_ok, resource_result, _resource_message = service.search_resources( + media_type=chosen_candidate.get("media_type") or media_type or "auto", + tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), + ) + if resource_ok: + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=6), + preferences=preferences, + source_type="hdhive", + target_path=target_path or self._hdhive_default_path, + ) + hdhive_resources = [dict(item or {}) for item in preview] + for local_index, resource in enumerate(hdhive_resources, start=1): + resource["index"] = local_index + resource["cloud_index"] = len(items) + local_index + hdhive_candidate = dict(chosen_candidate) + + return finish(self._assistant_finalize_cloud_result( + session=session, + cache_key=cache_key, + keyword=keyword, + pansou_items=items, + pansou_total=int(data.get("total") or len(items)), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + target_path=target_path or self._hdhive_default_path, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + if items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + items = self._attach_cloud_scores( + items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + items = self._rank_pansou_items(items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=items, + total=int(data.get("total") or len(items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search" if mode == "pansou" else "cloud_search", + search_scope="pansou" if mode == "pansou" else "pansou_then_hdhive", + recommend_handoff=recommend_handoff, + lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), + )) + + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + if mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return {"success": False, "message": f"影巢搜索失败:{search_message}", "data": result} + candidates = result.get("candidates") or [] + if not candidates and mode == "hdhive": + search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) + if search_ok: + data = payload.get("data") or {} + merged = data.get("merged_by_type") or {} + channel_115 = self._collect_pansou_channel_items(merged, "115", 20) + channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) + fallback_items: List[Dict[str, Any]] = [] + for item in channel_115 + channel_quark: + fallback_items.append({**item, "index": len(fallback_items) + 1}) + if fallback_items: + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + fallback_items = self._attach_cloud_scores( + fallback_items, + preferences=preferences, + source_type="pansou", + target_path=target_path or self._hdhive_default_path, + ) + fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) + return finish(self._assistant_finalize_pansou_result( + session=session, + cache_key=cache_key, + keyword=keyword, + items=fallback_items, + total=int(data.get("total") or len(fallback_items)), + target_path=target_path or self._hdhive_default_path, + action_name="pansou_search", + search_scope="hdhive_then_pansou", + recommend_handoff=recommend_handoff, + lead_note=("影巢当前暂无结果,已自动补查盘搜。" + + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), + )) + return finish(self._assistant_finalize_hdhive_candidates( + session=session, + cache_key=cache_key, + keyword=keyword, + candidates=candidates, + media_type=media_type, + year=year, + target_path=target_path or self._hdhive_default_path, + recommend_handoff=recommend_handoff, + )) + + async def api_assistant_action(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + name = self._clean_text(body.get("name") or body.get("action_name")) + if not name: + return {"success": False, "message": "缺少动作名 name"} + compact = self._parse_bool_value(body.get("compact"), False) + + async def finish(awaitable): + result = await awaitable + return self._assistant_single_action_compact_response(name, result) if compact else result + + async def immediate(result: Dict[str, Any]) -> Dict[str, Any]: + return result + + route_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + pick_payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "path": body.get("path") or body.get("target_path"), + "apikey": self._extract_apikey(request, body), + } + + if name == "start_pansou_search": + route_payload.update({ + "mode": "pansou", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_search": + route_payload.update({ + "mode": "smart", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_decision": + route_payload.update({ + "mode": "smart_decision", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + "decision_profile": body.get("decision_profile"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_plan": + route_payload.update({ + "mode": "smart_plan", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_smart_resource_execute": + route_payload.update({ + "mode": "smart_execute", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_hdhive_search": + route_payload.update({ + "mode": "hdhive", + "keyword": body.get("keyword"), + "media_type": body.get("media_type") or "auto", + "year": body.get("year"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "start_mp_media_search": + route_payload.update({ + "mode": "mp", + "keyword": body.get("keyword"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "query_mp_media_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_media_detail( + keyword=self._clean_text(body.get("keyword") or body.get("title")), + session=session_name, + cache_key=cache_key, + media_type=self._clean_text(body.get("media_type") or body.get("type") or "auto"), + year=self._clean_text(body.get("year")), + )) + if name == "query_mp_search_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_result_detail( + choice=self._safe_int(body.get("choice") or body.get("index"), 0), + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_best_result_detail": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_result_detail( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_best_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + return await finish(self._assistant_mp_best_download_plan( + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "pick_mp_download": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + choice = self._safe_int(body.get("choice") or body.get("index"), 0) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_plan_response( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + ))) + return await finish(self._assistant_mp_download( + choice=choice, + session=session_name, + cache_key=cache_key, + preferences=prefs, + )) + if name == "query_mp_download_tasks": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_tasks( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "downloading"), + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + downloader=self._clean_text(body.get("downloader")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_mp_download_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_download_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_lifecycle_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_lifecycle_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_ingest_status": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_status( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_mp_downloaders": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_downloaders(session=session_name, cache_key=cache_key)) + if name == "query_mp_sites": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_sites( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "active"), + name=self._clean_text(body.get("site_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 30), + )) + if name == "query_mp_subscribes": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_subscribes( + session=session_name, + cache_key=cache_key, + status=self._clean_text(body.get("status") or "all"), + media_type=self._clean_text(body.get("media_type") or body.get("type") or "all"), + name=self._clean_text(body.get("subscribe_name") or body.get("keyword") or body.get("title")), + limit=self._safe_int(body.get("limit"), 20), + )) + if name == "query_mp_ingest_failures": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_ingest_failures( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_ai_failed_samples": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_failed_samples( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_worklist": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_worklist( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 10), + )) + if name == "query_ai_sample_insights": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_ai_sample_insights( + session=session_name, + cache_key=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + limit=self._safe_int(body.get("limit"), 20), + top=self._safe_int(body.get("top"), 5), + )) + if name == "replay_ai_failed_sample": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + remove_if_resolved = self._parse_bool_value(body.get("remove_if_resolved"), True) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_ai_replay_sample_plan_response( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + ))) + return await finish(self._assistant_ai_replay_failed_sample( + sample_index=sample_index, + session=session_name, + cache_key=cache_key, + remove_if_resolved=remove_if_resolved, + )) + if name == "query_mp_transfer_history": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_transfer_history( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + status=self._clean_text(body.get("status") or "all"), + limit=self._safe_int(body.get("limit"), 10), + page=self._safe_int(body.get("page"), 1), + )) + if name == "query_mp_recent_activity": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_recent_activity( + session=session_name, + cache_key=cache_key, + limit=self._safe_int(body.get("limit"), 10), + download_only=self._parse_bool_value(body.get("download_only"), False), + transfer_only=self._parse_bool_value(body.get("transfer_only"), False), + )) + if name == "query_mp_local_diagnose": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_mp_local_diagnose( + session=session_name, + cache_key=cache_key, + title=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "query_execution_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_execution_followup( + request, + session=session_name, + session_id=cache_key, + plan_id=self._clean_text(body.get("plan_id")), + )) + if name == "query_smart_followup": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + return await finish(self._assistant_smart_followup( + request, + session=session_name, + session_id=cache_key, + keyword=self._clean_text(body.get("title") or body.get("keyword")), + hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), + limit=self._safe_int(body.get("limit"), 5), + )) + if name == "mp_subscribe_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_subscribe_id = body.get("subscribe_id") is not None + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + allow_raw_id=allow_raw_subscribe_id, + ))) + return await finish(self._assistant_mp_subscribe_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + allow_raw_id=allow_raw_subscribe_id, + )) + if name == "mp_download_control": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + downloader = self._clean_text(body.get("downloader")) + delete_files = self._parse_bool_value(body.get("delete_files"), False) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session_name, + cache_key=cache_key, + downloader=downloader, + delete_files=delete_files, + ))) + return await finish(self._assistant_mp_download_control( + session=session_name, + cache_key=cache_key, + control=control, + target=target, + downloader=downloader, + delete_files=delete_files, + )) + if name in {"start_mp_subscribe", "start_mp_subscribe_search"}: + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + if not keyword: + state = self._load_session(cache_key) or {} + keyword = self._clean_text(state.get("keyword")) + execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) + if not execute_requested: + return await finish(immediate(self._assistant_mp_subscribe_plan_response( + keyword=keyword, + session=session_name, + cache_key=cache_key, + immediate_search=name == "start_mp_subscribe_search", + ))) + return await finish(self._assistant_mp_subscribe( + keyword=keyword, + session=session_name, + immediate_search=name == "start_mp_subscribe_search", + )) + if name == "start_mp_recommendations": + recommend_session, recommend_cache_key = self._normalize_assistant_session_ref( + session=self._clean_text(body.get("session")) or "default", + session_id=body.get("session_id"), + ) + source_name, inferred_media_type = self._normalize_mp_recommend_request( + body.get("source") or "tmdb_trending" + ) + return await finish(self._assistant_mp_recommendations( + source=source_name, + media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type) or "all", + limit=self._safe_int(body.get("limit"), 20), + session=recommend_session, + cache_key=recommend_cache_key, + )) + if name == "pick_recommend_search": + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "mode": body.get("mode") or body.get("search_mode") or "mp", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"preferences_get", "preferences_save", "preferences_reset"}: + method = "GET" if name == "preferences_get" else "DELETE" if name == "preferences_reset" else "POST" + payload = { + "session": body.get("session"), + "session_id": body.get("session_id"), + "user_key": body.get("user_key"), + "preferences": body.get("preferences") or {}, + "compact": body.get("compact", True), + "apikey": self._extract_apikey(request, body), + } + return await finish(self.api_assistant_preferences(_JsonRequestShim(request, payload, method=method))) + if name in {"scoring_policy", "query_scoring_policy"}: + session_name = self._clean_text(body.get("session")) or "default" + return await finish({ + "success": True, + "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", + "data": self._assistant_response_data(session=session_name, data={ + "action": "scoring_policy", + "ok": True, + "scoring_policy": self._assistant_scoring_policy_public_data(), + }), + }) + if name == "start_115_login": + route_payload.update({ + "action": "p115_qrcode_start", + "client_type": body.get("client_type"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "route_share": + route_payload.update({ + "url": body.get("url") or body.get("share_url"), + "access_code": body.get("access_code"), + }) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "unlock_hdhive_resource": + session_name, cache_key = self._normalize_assistant_session_ref( + session=body.get("session") or "default", + session_id=body.get("session_id"), + ) + resource = body.get("resource") if isinstance(body.get("resource"), dict) else {} + slug = self._clean_text(body.get("slug") or resource.get("slug")) + final_path = self._resolve_pan_path_value( + self._clean_text(body.get("path") or body.get("target_path")) + ) or self._hdhive_default_path + if not slug: + return await finish(immediate({ + "success": False, + "message": "影巢解锁动作缺少 slug", + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "error_code": "missing_slug", + }), + })) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_plan", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return await finish(immediate({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": False, + "selected_resource": resource, + "result": route_result, + }), + })) + return await finish(immediate({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session_name, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + })) + if name == "inspect_session_state": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"execute_latest_plan", "execute_plan", "execute_session_latest_plan"}: + return await finish(self.api_assistant_plan_execute(_JsonRequestShim(request, { + "plan_id": body.get("plan_id"), + "session": body.get("session"), + "session_id": body.get("session_id"), + "prefer_unexecuted": body.get("prefer_unexecuted", True), + "stop_on_error": body.get("stop_on_error", True), + "include_raw_results": body.get("include_raw_results", False), + "apikey": self._extract_apikey(request, body), + }))) + if name in {"pick_pansou_result", "pick_hdhive_candidate", "pick_hdhive_resource"}: + pick_payload.update({"choice": body.get("choice") or body.get("index")}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name in {"plan_pansou_result", "plan_hdhive_resource", "plan_pick_result"}: + pick_payload.update({ + "choice": body.get("choice") or body.get("index"), + "action": "plan", + }) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_detail": + pick_payload.update({"action": "detail"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "candidate_next_page": + pick_payload.update({"action": "next_page"}) + return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) + if name == "check_115_login": + route_payload.update({"action": "p115_qrcode_check"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "show_115_status": + route_payload.update({"action": "p115_status"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "resume_pending_115": + route_payload.update({"action": "p115_resume"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "cancel_pending_115": + route_payload.update({"action": "p115_cancel"}) + return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) + if name == "clear_current_session": + return await finish(self.api_assistant_session_clear(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "inspect_session": + return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { + "session": body.get("session"), + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_session_by_id": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "session_id": body.get("session_id"), + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_stale_sessions": + return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { + "stale_only": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + if name == "clear_executed_plans": + return await finish(self.api_assistant_plans_clear(_JsonRequestShim(request, { + "executed": True, + "limit": body.get("limit") or 100, + "apikey": self._extract_apikey(request, body), + }))) + + return {"success": False, "message": f"不支持的动作模板:{name}"} + + @staticmethod + def _assistant_result_message_head(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text.splitlines()[0][:200] + + def _assistant_action_result_summary( + self, + *, + index: int, + name: str, + result: Dict[str, Any], + ) -> Dict[str, Any]: + data = dict((result or {}).get("data") or {}) + session_state = dict(data.get("session_state") or {}) + if not session_state and ( + "has_session" in data + or "kind" in data + or "stage" in data + or "suggested_actions" in data + ): + session_state = dict(data) + summary = { + "index": index, + "name": self._clean_text(name), + "success": bool((result or {}).get("success")), + "action": self._clean_text(data.get("action")) or self._clean_text(name), + "ok": bool(data.get("ok")) if "ok" in data else bool((result or {}).get("success")), + "message_head": self._assistant_result_message_head((result or {}).get("message")), + "session": self._clean_text(data.get("session") or session_state.get("session")), + "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), + "kind": self._clean_text(session_state.get("kind")), + "stage": self._clean_text(session_state.get("stage")), + "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], + "has_pending_p115": bool(((session_state.get("pending_p115") or {}).get("has_pending"))), + } + if isinstance(data.get("score_summary"), dict): + summary["score_summary"] = data.get("score_summary") + if isinstance(data.get("diagnosis_summary"), dict): + summary["diagnosis_summary"] = data.get("diagnosis_summary") + return summary + + async def api_assistant_actions(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + actions = body.get("actions") or [] + if not isinstance(actions, list) or not actions: + return {"success": False, "message": "缺少 actions 数组"} + + apikey = self._extract_apikey(request, body) + requested_count = min(len(actions), 20) + stop_on_error = self._parse_bool_value(body.get("stop_on_error"), True) + include_raw_results = self._parse_bool_value(body.get("include_raw_results"), False) + compact = self._parse_bool_value(body.get("compact"), False) + batch_session = self._clean_text(body.get("session")) or "default" + batch_session_id = self._clean_text(body.get("session_id")) + + summaries: List[Dict[str, Any]] = [] + raw_results: List[Dict[str, Any]] = [] + halted = False + halted_at = 0 + + for idx, item in enumerate(actions[:requested_count], 1): + payload = dict(item or {}) if isinstance(item, dict) else {"name": self._clean_text(item)} + if not payload.get("session") and batch_session: + payload["session"] = batch_session + if not payload.get("session_id") and batch_session_id: + payload["session_id"] = batch_session_id + if "execute" not in payload and "execute" in body: + payload["execute"] = body.get("execute") + if apikey and not payload.get("apikey") and not payload.get("api_key"): + payload["apikey"] = apikey + action_name = self._clean_text(payload.get("name") or payload.get("action_name")) + result = await self.api_assistant_action(_JsonRequestShim(request, payload)) + summaries.append(self._assistant_action_result_summary(index=idx, name=action_name, result=result)) + if include_raw_results: + raw_results.append(result) + if not result.get("success") and stop_on_error: + halted = True + halted_at = idx + break + + final_session = batch_session + final_session_id = batch_session_id + if summaries: + final_session = self._clean_text(summaries[-1].get("session")) or final_session + final_session_id = self._clean_text(summaries[-1].get("session_id")) or final_session_id + session_name, _ = self._normalize_assistant_session_ref(session=final_session, session_id=final_session_id) + + success = bool(summaries) and all(item.get("success") for item in summaries) + if halted: + success = False + + message_lines = [ + f"批量动作执行完成:{len(summaries)}/{requested_count} 步", + f"成功:{len([item for item in summaries if item.get('success')])} 步", + ] + if halted: + message_lines.append(f"已在第 {halted_at} 步停止") + else: + message_lines.append("已按顺序执行完毕") + if summaries: + last_head = self._clean_text(summaries[-1].get("message_head")) + if last_head: + message_lines.append(f"最后结果:{last_head}") + + data = { + "action": "execute_actions", + "ok": success, + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + } + if include_raw_results: + data["raw_results"] = raw_results + self._record_assistant_execution( + action=self._clean_text(body.get("workflow")) or "execute_actions", + session=session_name, + success=success, + message="\n".join(message_lines), + summary={ + "executed_count": len(summaries), + "requested_count": requested_count, + "stopped_on_error": halted, + "halted_at": halted_at, + "results": summaries, + }, + ) + full_data = self._assistant_response_data(session=session_name, data=data) + return { + "success": success, + "message": "\n".join(message_lines), + "data": self._assistant_actions_compact_data(full_data) if compact else full_data, + } + + @staticmethod + def _assistant_workflow_catalog() -> Dict[str, Any]: + return { + "workflows": [ + { + "name": "pansou_search", + "description": "按关键词执行盘搜,只返回候选结果并保留会话", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "smart_resource_search", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只读返回推荐结果", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_decision", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并返回查看详情、生成计划或直接执行的统一建议。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "decision_profile", "path", "compact"], + }, + { + "name": "smart_resource_plan", + "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并为当前首选生成待确认 plan_id", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "smart_resource_execute", + "description": "按偏好自动搜索当前首选,并立即执行对应写入动作;仅适用于用户已明确要求直接执行的场景。", + "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], + }, + { + "name": "pansou_transfer", + "description": "按关键词盘搜并直接选择指定编号转存,choice 默认 1", + "fields": ["session", "keyword", "choice", "path", "compact"], + }, + { + "name": "hdhive_candidates", + "description": "按关键词搜索影巢候选影片,等待下一步选片", + "fields": ["session", "keyword", "media_type", "year", "path", "compact"], + }, + { + "name": "hdhive_unlock", + "description": "按关键词搜索影巢,选择候选影片,再选择资源解锁落盘", + "fields": ["session", "keyword", "candidate_choice", "resource_choice", "media_type", "year", "path", "compact"], + }, + { + "name": "mp_search", + "description": "执行 MP 原生搜索,返回 PT 候选结果和评分摘要", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_media_detail", + "description": "使用 MoviePilot 原生识别确认媒体详情、年份、类型和 TMDB/Douban/IMDB ID", + "fields": ["session", "keyword", "media_type", "year", "compact"], + }, + { + "name": "mp_search_detail", + "description": "执行 MP 原生搜索并按编号查看 PT 详情与评分理由,只读", + "fields": ["session", "keyword", "choice", "compact"], + }, + { + "name": "mp_search_best", + "description": "执行 MP 原生搜索并查看当前评分最高的 PT 候选详情,只读", + "fields": ["session", "keyword", "compact"], + }, + { + "name": "mp_search_download", + "description": "执行 MP 原生搜索并按编号下载,默认先生成 plan_id", + "fields": ["session", "keyword", "choice", "compact", "dry_run"], + }, + { + "name": "mp_download_tasks", + "description": "查询 MP 下载任务状态,可按 status/title/hash/downloader 过滤", + "fields": ["session", "status", "title", "hash", "downloader", "limit", "compact"], + }, + { + "name": "mp_download_history", + "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态,只读", + "fields": ["session", "keyword", "hash", "limit", "page", "compact"], + }, + { + "name": "mp_lifecycle_status", + "description": "聚合查询 MP 下载任务、下载历史和整理/入库历史,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_ingest_status", + "description": "按片名或 hash 判断当前处于下载中、已下载、整理中、已入库还是失败阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_downloaders", + "description": "查询 MP 下载器配置摘要,不返回敏感字段", + "fields": ["session", "compact"], + }, + { + "name": "mp_sites", + "description": "查询 MP 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文", + "fields": ["session", "status", "keyword", "limit", "compact"], + }, + { + "name": "mp_download_control", + "description": "暂停、恢复或删除 MP 下载任务,默认先生成 plan_id", + "fields": ["session", "control", "target", "downloader", "delete_files", "compact", "dry_run"], + }, + { + "name": "mp_subscribe", + "description": "按关键词创建 MP 订阅,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribe_and_search", + "description": "创建订阅并立即触发搜索,默认先生成 plan_id", + "fields": ["session", "keyword", "compact", "dry_run"], + }, + { + "name": "mp_subscribes", + "description": "查询 MP 订阅列表,可按 status/media_type/keyword 过滤", + "fields": ["session", "status", "media_type", "keyword", "limit", "compact"], + }, + { + "name": "mp_subscribe_control", + "description": "触发订阅搜索、暂停、恢复或删除订阅,默认先生成 plan_id", + "fields": ["session", "control", "target", "compact", "dry_run"], + }, + { + "name": "mp_transfer_history", + "description": "查询 MP 最近整理/入库历史,可按标题和成功/失败状态过滤,只读", + "fields": ["session", "keyword", "status", "limit", "page", "compact"], + }, + { + "name": "mp_ingest_failures", + "description": "聚合查看最近整理/入库失败记录,只读", + "fields": ["session", "keyword", "limit", "page", "compact"], + }, + { + "name": "ai_failed_samples", + "description": "读取 AI 识别增强插件保存的失败样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_worklist", + "description": "读取 AI 失败样本工作清单,适合先挑需要复查的样本,只读", + "fields": ["session", "keyword", "limit", "compact"], + }, + { + "name": "ai_sample_insights", + "description": "读取 AI 失败样本洞察,查看主要失败原因与优先处理项,只读", + "fields": ["session", "keyword", "limit", "top", "compact"], + }, + { + "name": "ai_replay_failed_sample", + "description": "对指定 AI 失败样本执行二次识别重放;默认只生成 plan_id,确认后执行", + "fields": ["session", "sample_index", "remove_if_resolved", "compact", "confirmed"], + }, + { + "name": "mp_recent_activity", + "description": "查看最近下载和最近整理/入库活动,只读", + "fields": ["session", "limit", "download_only", "transfer_only", "compact"], + }, + { + "name": "mp_local_diagnose", + "description": "一站式诊断为什么还没入库或当前卡在什么阶段,只读", + "fields": ["session", "keyword", "hash", "limit", "compact"], + }, + { + "name": "execution_followup", + "description": "按最近已执行计划自动查询对应的下载、订阅或入库后续状态,只读", + "fields": ["session", "session_id", "plan_id", "compact"], + }, + { + "name": "smart_followup", + "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动,只读", + "fields": ["session", "session_id", "keyword", "hash", "limit", "compact"], + }, + { + "name": "mp_recommend", + "description": "读取 MP 原生推荐,例如 TMDB、豆瓣、Bangumi", + "fields": ["session", "source", "media_type", "limit", "compact"], + }, + { + "name": "mp_recommend_search", + "description": "读取 MP 推荐并按编号继续搜索;mode 可选 mp / hdhive / pansou,传 keyword 时直接 MP 搜索", + "fields": ["session", "source", "keyword", "choice", "mode", "media_type", "limit", "compact"], + }, + { + "name": "share_transfer", + "description": "识别 115 或夸克分享链接并直接转存", + "fields": ["session", "url", "access_code", "path", "compact"], + }, + { + "name": "p115_login_start", + "description": "发起 115 扫码登录", + "fields": ["session", "client_type", "compact"], + }, + { + "name": "p115_status", + "description": "查看 115 当前可用状态", + "fields": ["session", "compact"], + }, + ] + } + + def _assistant_workflow_actions(self, name: str, body: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]: + workflow_name = self._clean_text(name).lower() + session = self._clean_text(body.get("session")) or "default" + session_id = self._clean_text(body.get("session_id")) + path = self._clean_text(body.get("path") or body.get("target_path")) + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or "auto") + year = self._clean_text(body.get("year")) + source = self._clean_text(body.get("source")) or "tmdb_trending" + limit = self._safe_int(body.get("limit"), 20) + + def base(payload: Dict[str, Any]) -> Dict[str, Any]: + current = dict(payload) + current.setdefault("session", session) + if session_id: + current.setdefault("session_id", session_id) + if path and "path" not in current: + current["path"] = path + return current + + if workflow_name == "pansou_search": + if not keyword: + return [], "pansou_search 缺少 keyword" + return [base({"name": "start_pansou_search", "keyword": keyword})], "" + + if workflow_name == "smart_resource_search": + if not keyword: + return [], "smart_resource_search 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_decision": + if not keyword: + return [], "smart_resource_decision 缺少 keyword" + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_decision", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + "decision_profile": body.get("decision_profile"), + })], "" + + if workflow_name == "smart_resource_plan": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_plan", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "smart_resource_execute": + source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] + return [base({ + "name": "start_smart_resource_execute", + "keyword": keyword, + "media_type": media_type, + "year": year, + "source_order": source_order, + })], "" + + if workflow_name == "pansou_transfer": + if not keyword: + return [], "pansou_transfer 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_pansou_search", "keyword": keyword}), + base({"name": "pick_pansou_result", "choice": max(1, choice)}), + ], "" + + if workflow_name == "hdhive_candidates": + if not keyword: + return [], "hdhive_candidates 缺少 keyword" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }) + ], "" + + if workflow_name == "hdhive_unlock": + if not keyword: + return [], "hdhive_unlock 缺少 keyword" + candidate_choice = self._safe_int(body.get("candidate_choice") or body.get("choice"), 0) + resource_choice = self._safe_int(body.get("resource_choice"), 0) + if candidate_choice <= 0 or resource_choice <= 0: + return [], "hdhive_unlock 需要 candidate_choice 和 resource_choice" + return [ + base({ + "name": "start_hdhive_search", + "keyword": keyword, + "media_type": media_type, + "year": year, + }), + base({"name": "pick_hdhive_candidate", "choice": candidate_choice}), + base({"name": "pick_hdhive_resource", "choice": resource_choice}), + ], "" + + if workflow_name == "mp_search": + if not keyword: + return [], "mp_search 缺少 keyword" + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + + if workflow_name == "mp_media_detail": + if not keyword: + return [], "mp_media_detail 缺少 keyword" + return [base({ + "name": "query_mp_media_detail", + "keyword": keyword, + "media_type": media_type, + "year": year, + })], "" + + if workflow_name == "mp_search_detail": + if not keyword: + return [], "mp_search_detail 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_search_result_detail", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_search_best": + if not keyword: + return [], "mp_search_best 缺少 keyword" + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "query_mp_best_result_detail"}), + ], "" + + if workflow_name == "mp_search_download": + if not keyword: + return [], "mp_search_download 缺少 keyword" + choice = self._safe_int(body.get("choice"), 1) + return [ + base({"name": "start_mp_media_search", "keyword": keyword}), + base({"name": "pick_mp_download", "choice": max(1, choice)}), + ], "" + + if workflow_name == "mp_download_tasks": + return [base({ + "name": "query_mp_download_tasks", + "status": self._clean_text(body.get("status")) or "downloading", + "title": self._clean_text(body.get("title") or body.get("keyword")), + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "downloader": self._clean_text(body.get("downloader")), + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "mp_download_history": + return [base({ + "name": "query_mp_download_history", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_lifecycle_status": + return [base({ + "name": "query_mp_lifecycle_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_ingest_status": + return [base({ + "name": "query_mp_ingest_status", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_downloaders": + return [base({"name": "query_mp_downloaders"})], "" + + if workflow_name == "mp_sites": + return [base({ + "name": "query_mp_sites", + "status": self._clean_text(body.get("status")) or "active", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 30), + })], "" + + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_download_control 需要 control 和 target" + return [base({ + "name": "mp_download_control", + "control": control, + "target": target, + "downloader": self._clean_text(body.get("downloader")), + "delete_files": self._parse_bool_value(body.get("delete_files"), False), + })], "" + + if workflow_name == "mp_subscribe": + if not keyword: + return [], "mp_subscribe 缺少 keyword" + return [base({"name": "start_mp_subscribe", "keyword": keyword})], "" + + if workflow_name == "mp_subscribe_and_search": + if not keyword: + return [], "mp_subscribe_and_search 缺少 keyword" + return [base({"name": "start_mp_subscribe_search", "keyword": keyword})], "" + + if workflow_name == "mp_subscribes": + return [base({ + "name": "query_mp_subscribes", + "status": self._clean_text(body.get("status")) or "all", + "media_type": self._clean_text(body.get("media_type") or body.get("type")) or "all", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + })], "" + + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + if not control or not target: + return [], "mp_subscribe_control 需要 control 和 target" + return [base({ + "name": "mp_subscribe_control", + "control": control, + "target": target, + })], "" + + if workflow_name == "mp_transfer_history": + return [base({ + "name": "query_mp_transfer_history", + "keyword": keyword, + "status": self._clean_text(body.get("status")) or "all", + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "mp_ingest_failures": + return [base({ + "name": "query_mp_ingest_failures", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + "page": self._safe_int(body.get("page"), 1), + })], "" + + if workflow_name == "ai_failed_samples": + return [base({ + "name": "query_ai_failed_samples", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_worklist": + return [base({ + "name": "query_ai_sample_worklist", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 10), + })], "" + + if workflow_name == "ai_sample_insights": + return [base({ + "name": "query_ai_sample_insights", + "keyword": keyword, + "limit": self._safe_int(body.get("limit"), 20), + "top": self._safe_int(body.get("top"), 5), + })], "" + + if workflow_name == "ai_replay_failed_sample": + sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) + if sample_index <= 0: + return [], "ai_replay_failed_sample 需要 sample_index" + return [base({ + "name": "replay_ai_failed_sample", + "sample_index": sample_index, + "remove_if_resolved": self._parse_bool_value(body.get("remove_if_resolved"), True), + })], "" + + if workflow_name == "mp_recent_activity": + return [base({ + "name": "query_mp_recent_activity", + "limit": self._safe_int(body.get("limit"), 10), + "download_only": self._parse_bool_value(body.get("download_only"), False), + "transfer_only": self._parse_bool_value(body.get("transfer_only"), False), + })], "" + + if workflow_name == "mp_local_diagnose": + return [base({ + "name": "query_mp_local_diagnose", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "execution_followup": + return [base({ + "name": "query_execution_followup", + "plan_id": self._clean_text(body.get("plan_id")), + })], "" + + if workflow_name == "smart_followup": + return [base({ + "name": "query_smart_followup", + "keyword": keyword, + "hash": self._clean_text(body.get("hash") or body.get("hash_value")), + "limit": self._safe_int(body.get("limit"), 5), + })], "" + + if workflow_name == "mp_recommend": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "smart_discovery": + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + return [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ], "" + + if workflow_name == "mp_recommend_search": + if keyword: + return [base({"name": "start_mp_media_search", "keyword": keyword})], "" + source_name, inferred_media_type = self._normalize_mp_recommend_request(source) + actions = [ + base({ + "name": "start_mp_recommendations", + "source": source_name, + "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", + "limit": max(1, min(50, limit)), + }) + ] + choice = self._safe_int(body.get("choice"), 0) + if choice > 0: + actions.append(base({ + "name": "pick_recommend_search", + "choice": choice, + "mode": self._clean_text(body.get("mode")) or "mp", + })) + return actions, "" + + if workflow_name == "share_transfer": + share_url = self._clean_text(body.get("url") or body.get("share_url")) + if not share_url: + return [], "share_transfer 缺少 url" + return [ + base({ + "name": "route_share", + "url": share_url, + "access_code": self._clean_text(body.get("access_code")), + }) + ], "" + + if workflow_name == "p115_login_start": + return [ + base({ + "name": "start_115_login", + "client_type": self._clean_text(body.get("client_type")), + }) + ], "" + + if workflow_name == "p115_status": + return [base({"name": "show_115_status"})], "" + + return [], f"不支持的工作流:{name}" + + async def api_assistant_workflow(self, request: Request): + if request.method.upper() == "GET": + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + return { + "success": True, + "message": "Agent影视助手 预设工作流目录", + "data": self._assistant_response_data(session="default", data={ + "action": "workflow_catalog", + "ok": True, + **self._assistant_workflow_catalog(), + }), + } + + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + workflow_name = self._clean_text(body.get("name") or body.get("workflow")) + compact = self._parse_bool_value(body.get("compact"), False) + if not workflow_name: + return {"success": False, "message": "缺少工作流名 name"} + actions, build_error = self._assistant_workflow_actions(workflow_name, body) + if build_error: + return {"success": False, "message": build_error} + + session = self._clean_text(body.get("session")) or "default" + write_workflows = { + "pansou_transfer", + "hdhive_unlock", + "share_transfer", + "ai_replay_failed_sample", + "mp_search_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_and_search", + } + dry_run = self._parse_bool_value( + body.get("dry_run"), + self._clean_text(workflow_name).lower() in write_workflows, + ) + if dry_run: + if workflow_name == "mp_download_control": + control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) + result = self._assistant_mp_download_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + downloader=self._clean_text(body.get("downloader")), + delete_files=self._parse_bool_value(body.get("delete_files"), False), + ) + if not result.get("success"): + return result + if workflow_name == "mp_subscribe_control": + control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) + target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) + allow_raw_id = self._parse_bool_value(body.get("allow_raw_id"), False) + result = self._assistant_mp_subscribe_control_plan_response( + control=control, + target=target, + session=session, + cache_key=self._clean_text(body.get("session_id")), + allow_raw_id=allow_raw_id, + ) + if not result.get("success"): + return result + if workflow_name == "ai_replay_failed_sample": + result = self._assistant_ai_replay_sample_plan_response( + sample_index=self._safe_int(body.get("sample_index") or body.get("index"), 0), + session=session, + cache_key=self._clean_text(body.get("session_id")), + remove_if_resolved=self._parse_bool_value(body.get("remove_if_resolved"), True), + ) + if not result.get("success"): + return result + return self._assistant_workflow_plan_response_compact(result) if compact else result + execute_body = { + **{key: value for key, value in body.items() if key not in {"apikey", "dry_run"}}, + "dry_run": False, + } + plan = self._save_workflow_plan( + workflow=workflow_name, + session=session, + session_id=self._clean_text(body.get("session_id")), + actions=actions, + execute_body=execute_body, + ) + full_data = self._assistant_response_data(session=session, data={ + "action": "workflow_plan", + "ok": True, + "plan_id": plan.get("plan_id"), + "workflow": workflow_name, + "dry_run": True, + "workflow_actions": actions, + "estimated_steps": len(actions), + "ready_to_execute": True, + "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + "execute_plan_body": {"plan_id": plan.get("plan_id")}, + "execute_body": execute_body, + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + }) + return { + "success": True, + "message": f"工作流 {workflow_name} 计划已生成:{plan.get('plan_id')},共 {len(actions)} 步,未实际执行。", + "data": self._assistant_workflow_plan_compact_data(full_data) if compact else full_data, + } + + result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": self._clean_text(body.get("session_id")), + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": compact, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + data = dict(result.get("data") or {}) + data["workflow"] = workflow_name + if not compact: + data["workflow_actions"] = actions + return { + "success": bool(result.get("success")), + "message": f"工作流 {workflow_name} 执行完成\n{result.get('message') or ''}".strip(), + "data": data, + } + + async def api_assistant_plan_execute(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + plan_id = self._clean_text(body.get("plan_id")) + session = self._clean_text(body.get("session")) + session_id = self._clean_text(body.get("session_id")) + prefer_unexecuted = self._parse_bool_value(body.get("prefer_unexecuted"), True) + compact = self._parse_bool_value(body.get("compact"), False) + plan = self._find_workflow_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=False if prefer_unexecuted and not plan_id else None, + ) + if not plan and not plan_id and (session or session_id) and prefer_unexecuted: + plan = self._find_workflow_plan( + session=session, + session_id=session_id, + executed=None, + ) + if not plan: + result = { + "success": False, + "message": f"计划不存在或已过期:{plan_id}" if plan_id else "没有匹配到可执行计划,请先生成 dry_run 计划或改传 plan_id。", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_not_found", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + plan_id = self._clean_text(plan.get("plan_id")) + + actions = plan.get("actions") or [] + if not isinstance(actions, list) or not actions: + result = { + "success": False, + "message": f"计划没有可执行动作:{plan_id}", + "data": self._assistant_response_data(session=session or "default", data={ + "action": "execute_plan", + "ok": False, + "plan_id": plan_id, + "error_code": "plan_has_no_actions", + }), + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + workflow_name = self._clean_text(plan.get("workflow")) or "saved_plan" + session = self._clean_text(plan.get("session")) or "default" + session_id = self._clean_text(plan.get("session_id")) + action_result = await self.api_assistant_actions( + _JsonRequestShim( + request, + { + "actions": actions, + "workflow": workflow_name, + "session": session, + "session_id": session_id, + "execute": True, + "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), + "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), + "compact": False, + "apikey": self._extract_apikey(request, body), + }, + ) + ) + + executed_at = int(time.time()) + plan.update({ + "executed": True, + "executed_at": executed_at, + "executed_at_text": self._format_unix_time(executed_at), + "last_success": bool(action_result.get("success")), + "last_message": self._assistant_result_message_head(action_result.get("message")), + }) + self._workflow_plans[plan_id] = plan + self._persist_workflow_plans() + + data = dict(action_result.get("data") or {}) + data.update({ + "action": "execute_plan", + "plan_id": plan_id, + "workflow": workflow_name, + "plan_auto_selected": not bool(self._clean_text(body.get("plan_id"))), + "plan_created_at": plan.get("created_at"), + "plan_created_at_text": plan.get("created_at_text"), + "plan_executed_at": executed_at, + "plan_executed_at_text": plan.get("executed_at_text"), + }) + followup_state = dict(data.get("session_state") or {}) if isinstance(data.get("session_state"), dict) else {} + if isinstance(plan.get("execute_body"), dict): + followup_state["plan_execute_body"] = dict(plan.get("execute_body") or {}) + followup = self._assistant_plan_execute_followup( + workflow=workflow_name, + session=session, + session_id=session_id, + session_state=followup_state, + ok=bool(action_result.get("success")), + plan_id=plan_id, + ) + data["recommended_action"] = self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")) + data["follow_up_hint"] = self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")) + data["followup_summary"] = followup.get("followup_summary") or {} + data["next_actions"] = self._assistant_compact_next_actions( + followup.get("next_actions"), + data.get("next_actions") or [], + ) + data["action_templates"] = self._assistant_compact_action_templates( + templates=[ + *(followup.get("action_templates") or []), + *(data.get("action_templates") or []), + ], + limit=6, + ) + if not compact: + data["workflow_actions"] = actions + message_lines = [ + f"计划 {plan_id} 执行完成", + self._clean_text(action_result.get("message")), + ] + if data.get("recommended_action"): + message_lines.append(f"推荐动作:{data.get('recommended_action')}") + if data.get("follow_up_hint"): + message_lines.append(f"下一步:{data.get('follow_up_hint')}") + message_lines.extend(self._format_followup_summary_lines(data.get("followup_summary"))) + result = { + "success": bool(action_result.get("success")), + "message": "\n".join(line for line in message_lines if line).strip(), + "data": data, + } + return self._assistant_plan_execute_compact_response(result) if compact else result + + async def api_assistant_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + raw_action_text = self._clean_text(body.get("action") or body.get("pick_action")) + action = self._normalize_pick_action(raw_action_text) + target_path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) + compact = self._parse_bool_value(body.get("compact"), False) + + def finish(result: Dict[str, Any]) -> Dict[str, Any]: + return self._assistant_interaction_compact_response(result) if compact else result + + state = self._load_session(cache_key) + if not state: + return {"success": False, "message": "没有可继续的缓存,请先发起搜索或发送分享链接。"} + if index <= 0 and not action: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + + kind = str(state.get("kind") or "").strip() + smart_short_action = self._normalize_smart_search_short_action(raw_action_text, state_kind=kind) + if smart_short_action: + action = smart_short_action + if kind == "assistant_cloud": + pansou_items = state.get("pansou_items") or [] + hdhive_resources = state.get("hdhive_resources") or [] + hdhive_candidate = dict(state.get("hdhive_candidate") or {}) + hdhive_candidates = state.get("hdhive_candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + pansou_count = len(pansou_items) + total_count = pansou_count + len(hdhive_resources) + if action == "next_page": + total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_cloud_text( + keyword=self._clean_text(state.get("keyword")), + pansou_items=pansou_items, + pansou_total=self._safe_int(state.get("pansou_total"), pansou_count), + hdhive_resources=hdhive_resources, + hdhive_candidate=hdhive_candidate, + hdhive_candidates=hdhive_candidates, + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "cloud_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best" and pansou_items: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + "total": self._safe_int(state.get("pansou_total"), len(pansou_items)), + "page": max(1, self._safe_int(state.get("page"), 1)), + "page_size": page_size, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + if index <= 0: + if action: + return {"success": False, "message": "云盘搜索结果需要编号,例如:选择 1 或 选择 12 详情。"} + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > total_count: + return {"success": False, "message": f"序号超出范围,请输入 1 到 {total_count} 之间的数字。"} + if index <= pansou_count: + delegate_state = { + "kind": "assistant_pansou", + "stage": "result", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "items": pansou_items, + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + local_index = index - pansou_count + delegate_state = { + "kind": "assistant_hdhive", + "stage": "resource", + "keyword": state.get("keyword"), + "target_path": target_path or state.get("target_path") or self._hdhive_default_path, + "selected_candidate": hdhive_candidate, + "resources": [ + {**dict(item or {}), "index": idx} + for idx, item in enumerate(hdhive_resources, start=1) + ], + } + self._save_session(cache_key, delegate_state) + try: + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": local_index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + finally: + self._save_session(cache_key, state) + + if kind == "assistant_update_check": + items = [dict(item or {}) for item in (state.get("items") or []) if isinstance(item, dict)] + if action == "next_page": + return {"success": False, "message": "更新检查结果当前不分页,可以直接回复编号或“选择 编号 详情”。"} + if action in {"best", "best_execute", "best_plan"}: + best = self._best_scored_source_item(items) + if not best: + return {"success": False, "message": "当前更新检查结果没有可评分条目,请直接回复编号。"} + index = self._safe_int(best.get("_index") or best.get("index"), 0) + action = "plan" if action == "best_plan" else "" if action == "best_execute" else "detail" + if index <= 0: + return {"success": False, "message": "更新检查结果需要编号,例如:选择 25 详情。"} + matched_items = [ + item for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) == index + ] + if not matched_items and 1 <= index <= len(items): + matched_items = [items[index - 1]] + if not matched_items: + available = "、".join( + f"#{self._safe_int(item.get('_index') or item.get('index'), 0)}" + for item in items + if self._safe_int(item.get("_index") or item.get("index"), 0) > 0 + ) + return { + "success": False, + "message": f"序号不在当前更新检查结果里。可选编号:{available or '暂无'}", + } + selected = dict(matched_items[0]) + source_type = self._clean_text(selected.get("_update_source")).lower() + choice_index = self._safe_int(selected.get("_index") or selected.get("index"), index) + selected["index"] = choice_index + selected["_index"] = choice_index + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if action == "detail": + title = "盘搜资源详情" if source_type == "pansou" else "影巢资源详情" + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title=title), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_resource_detail", + "ok": True, + "choice": choice_index, + "source_type": source_type, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + }) + if source_type == "pansou": + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法继续处理;可先发“选择 编号 详情”查看。"} + access_code = self._clean_text(selected.get("password")) + route_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_pansou_transfer", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": route_path, + }], + execute_body={ + "workflow": "update_check_pansou_transfer", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": route_path, + }, + message="更新检查盘搜资源转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": route_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "target_path": route_path, + "apikey": self._extract_apikey(request, body), + }) + ) + return finish(route_result) + if source_type == "hdhive": + slug = self._clean_text(selected.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法继续处理;可先发“选择 编号 详情”查看。"} + if action == "plan": + return finish(self._save_assistant_pick_plan_response( + workflow="update_check_hdhive_unlock", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": selected, + }], + execute_body={ + "workflow": "update_check_hdhive_unlock", + "session": session, + "session_id": cache_key, + "choice": choice_index, + "path": final_path, + }, + message="更新检查影巢资源解锁/转存计划已生成", + score_items=[selected], + extra_data={ + "choice": choice_index, + "source_type": source_type, + "target_path": final_path, + "selected_resource": selected, + }, + )) + if action: + return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=final_path, + resource=selected, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_update_check_hdhive", + title=selected.get("title") or selected.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(selected.get('title') or selected.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "update_check_hdhive_unlock", + "ok": True, + "selected_resource": selected, + "result": route_result, + }), + }) + return {"success": False, "message": "当前更新检查条目缺少来源信息,请重新执行更新检查。"} + if kind == "assistant_pansou": + items = state.get("items") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total_items = len(items) + if action == "next_page": + total_pages = max(1, (total_items + page_size - 1) // page_size) if total_items else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_pansou_text( + self._clean_text(state.get("keyword")), + items, + self._safe_int(state.get("total"), total_items), + page=next_page, + page_size=page_size, + ), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_search_next_page", + "ok": True, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + if action == "best": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法执行最佳。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + plan_result = self._save_assistant_pick_plan_response( + workflow="pansou_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }], + execute_body={ + "workflow": "pansou_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择盘搜最佳候选并执行:#{index}", + extra_data={"choice": index, "source_type": "pansou"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(items) + if not best: + return finish({ + "success": False, + "message": "当前盘搜结果没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_best_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + selected = dict(best) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "当前盘搜首选缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + result = self._save_assistant_pick_plan_response( + workflow="pansou_best_plan", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜最佳候选计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail": + if index <= 0: + return {"success": False, "message": "盘搜详情需要编号,例如:选择 1 详情。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + }), + } if not state.get("recommend_handoff") else { + "success": True, + "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "pansou_result_detail", + "ok": True, + "choice": index, + "item": selected, + "score_summary": self._score_summary([selected], limit=1), + **self._assistant_recommend_handoff_short_metadata(state), + "decision_summary": self._assistant_recommend_handoff_detail_summary(state), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "盘搜计划需要编号,例如:计划选择 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + if not share_url: + return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法生成计划。"} + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + actions = [{ + "name": "route_share", + "session": session, + "session_id": cache_key, + "url": share_url, + "access_code": access_code, + "path": final_path, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="pansou_transfer_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "pansou_transfer_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="盘搜转存计划已生成", + score_items=[selected], + extra_data={ + "choice": index, + "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), + "target_path": final_path, + "selected_item": selected, + }, + )) + if action: + return {"success": False, "message": "盘搜结果当前只支持:选择 编号、计划选择 编号、选择 编号 详情、最佳片源、计划最佳、执行最佳。"} + if index <= 0: + return {"success": False, "message": "请选择有效序号,例如:选择 1"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + share_url = self._clean_text(selected.get("url")) + access_code = self._clean_text(selected.get("password")) + final_path = target_path or ( + self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path + ) + route_result = await self.api_share_route( + _JsonRequestShim(request, { + "url": share_url, + "access_code": access_code, + "path": final_path, + "trigger": "Agent影视助手 智能入口盘搜选择", + "apikey": self._extract_apikey(request, body), + }) + ) + if not route_result.get("success"): + if self._is_115_url(share_url): + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=access_code, + target_path=final_path, + source="assistant_pansou_pick", + title=selected.get("note") or "", + last_error=str(route_result.get("message") or ""), + ) + return finish({ + "success": False, + "message": ( + f"{str(route_result.get('message') or '转存失败')}\n" + f"{self._format_p115_resume_hint(selected.get('note') or '')}" + ), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_quark_url(share_url) and self._is_quark_share_restricted_message( + str(route_result.get("message") or "") + ): + recovered = await self._assistant_retry_pansou_quark_candidates( + request, + items=items, + selected_index=index, + selected_url=share_url, + session=session, + target_path=final_path, + apikey=self._extract_apikey(request, body), + ) + if recovered: + return finish(recovered) + return finish({ + "success": False, + "message": str(route_result.get('message') or '转存失败'), + "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), + }) + if self._is_115_url(share_url): + self._clear_pending_p115_share(cache_key) + provider = ((route_result.get("data") or {}).get("provider") or "").lower() + result_payload = (route_result.get("data") or {}).get("result") or {} + directory = (result_payload.get("result") or {}).get("target_path") or (result_payload.get("result") or {}).get("path") or final_path + text_message = "\n".join([ + "盘搜结果已执行转存", + f"资源:{selected.get('note') or '未命名资源'}", + f"类型:{provider or selected.get('channel') or '-'}", + f"目录:{directory or '-'}", + ]) + return finish({ + "success": True, + "message": text_message, + "data": self._assistant_response_data(session=session, data={"action": "share_route", "ok": True}), + }) + + if kind == "assistant_mp_recommend": + items = state.get("items") or [] + selected_index = self._safe_int(state.get("selected_index"), 0) + pick_action = self._normalize_pick_action(body.get("action")) + next_mode = self._clean_text( + body.get("mode") + or body.get("search_mode") + or body.get("target") + or "" + ).lower() + if index <= 0: + index = selected_index + if index <= 0 and ( + pick_action in {"detail", "best", "plan", "best_execute"} + or next_mode + ): + index = 1 + if index <= 0: + return {"success": False, "message": "推荐结果需要先指定编号,例如:选择 1 决策,或先发:详情 1。"} + if index > len(items): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} + selected = dict(items[index - 1]) + title = self._clean_text(selected.get("title")) + if not title: + return {"success": False, "message": "选中的推荐条目缺少标题,无法继续搜索。"} + if pick_action == "detail": + self._save_session(cache_key, { + **state, + "selected_index": index, + "selected_item": selected, + }) + return finish({ + "success": True, + "message": self._format_mp_recommend_item_detail_text(selected), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_recommendation_detail", + "ok": True, + "choice": index, + "selected_index": index, + "item": selected, + "detail_short_command": "详情", + "decision_short_command": "决策", + "plan_short_command": "计划", + "confirm_short_command": "确认", + }), + }) + mode_aliases = { + "原生": "mp", + "mp搜索": "mp", + "影巢": "hdhive", + "yc": "hdhive", + "盘搜": "pansou", + "ps": "pansou", + "计划": "smart_plan", + "smart_plan": "smart_plan", + "确认": "smart_execute", + "执行": "smart_execute", + "直接执行": "smart_execute", + "smart_execute": "smart_execute", + "决策": "smart_decision", + "资源决策": "smart_decision", + "智能决策": "smart_decision", + "smart": "smart_decision", + "smart_decision": "smart_decision", + } + if not next_mode: + if pick_action == "plan": + next_mode = "smart_plan" + elif pick_action == "best_execute": + next_mode = "smart_execute" + elif pick_action in {"best", "detail"}: + next_mode = "smart_decision" + next_mode = mode_aliases.get(next_mode, next_mode) + if not next_mode: + next_mode = "mp" + if next_mode not in {"mp", "hdhive", "pansou", "smart_decision", "smart_plan", "smart_execute"}: + return {"success": False, "message": "推荐选择只支持 mode=smart_decision、mode=smart_plan、mode=smart_execute、mode=mp、mode=hdhive 或 mode=pansou。"} + selected_media_type = self._clean_text(selected.get("type") or state.get("media_type") or "auto").lower() + media_type_aliases = { + "电影": "movie", + "movie": "movie", + "movies": "movie", + "电视剧": "tv", + "剧集": "tv", + "番剧": "tv", + "tv": "tv", + "series": "tv", + "all": "auto", + "全部": "auto", + } + selected_media_type = media_type_aliases.get(selected_media_type, "auto") + next_keyword = title + if next_mode == "smart_decision" and pick_action in {"best", "detail"}: + next_keyword = f"{title} 详情" + routed_body = { + "session": session, + "session_id": cache_key, + "mode": next_mode, + "keyword": next_keyword, + "media_type": selected_media_type, + "path": target_path, + "apikey": self._extract_apikey(request, body), + } + if next_mode in {"smart_decision", "smart_plan", "smart_execute"}: + routed_body["origin"] = "mp_recommend" + else: + routed_body["recommend_handoff"] = self._assistant_recommend_handoff_state( + source=self._clean_text(state.get("source")), + requested_source=self._clean_text(state.get("requested_source") or state.get("source")), + media_type=selected_media_type, + selected_index=index, + selected_item=selected, + ) + return finish(await self.api_assistant_route( + _JsonRequestShim(request, routed_body) + )) + + if kind == "assistant_smart_search": + if action in { + "decision_continue", + "decision_hdhive", + "decision_pansou", + "decision_mp_pt", + "decision_conservative", + "decision_aggressive", + "decision_only_quark", + "decision_only_115", + "decision_cloud_both", + "decision_only_mp_pt", + "decision_only_pansou", + "decision_only_hdhive", + "decision_disable_pansou", + "decision_disable_hdhive", + "decision_disable_mp_pt", + "decision_reset_preferences", + }: + return finish(await self._assistant_smart_resource_decision_adjust( + request, + session=session, + cache_key=cache_key, + state=state, + adjust_action=action, + )) + if action == "best_execute": + return finish(await self._assistant_smart_best_execute_response( + request, + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best_plan": + return finish(self._assistant_smart_best_plan_response( + session=session, + cache_key=cache_key, + state=state, + target_path=target_path, + )) + if action == "best": + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + active_source = self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + try: + result = await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": 0, + "action": "best", + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + ) + finally: + self._save_session(cache_key, state) + return finish(result) + source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} + requested_mode = self._clean_text(body.get("mode") or body.get("search_mode")).lower() + mode_aliases = { + "盘搜": "pansou", + "ps": "pansou", + "影巢": "hdhive", + "yc": "hdhive", + "原生": "mp_pt", + "mp": "mp_pt", + "pt": "mp_pt", + } + requested_mode = mode_aliases.get(requested_mode, requested_mode) + active_source = requested_mode or self._clean_text(state.get("active_source")).lower() + delegate_state = source_states.get(active_source) if active_source else None + if not isinstance(delegate_state, dict) or not delegate_state: + delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} + if not isinstance(delegate_state, dict) or not delegate_state: + return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} + self._save_session(cache_key, delegate_state) + return finish(await self.api_assistant_pick( + _JsonRequestShim(request, { + "session": session, + "session_id": cache_key, + "choice": index, + "action": action, + "path": target_path, + "compact": compact, + "apikey": self._extract_apikey(request, body), + }) + )) + + if kind == "assistant_mp_candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page}) + return finish({ + "success": True, + "message": self._format_mp_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action in {"best", "best_execute", "best_plan", "plan"}: + return {"success": False, "message": "MP 候选阶段还没有 PT 资源评分,请先回复编号选定电影/剧集。"} + if action: + return {"success": False, "message": "MP 候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效候选编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1] or {}) + candidate_title = self._clean_text(candidate.get("title")) or self._clean_text(state.get("keyword")) + candidate_year = self._clean_text(candidate.get("year")) + search_keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title + pending_action = dict(state.get("pending_action") or {}) if isinstance(state.get("pending_action"), dict) else {} + pending_mode = self._clean_text(pending_action.get("mode")) + if pending_mode == "mp_download_title": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(await self._assistant_attach_download_plan_choices( + result, + session=session, + cache_key=cache_key, + preferences=preferences, + )) + if pending_mode == "cloud_transfer_execute": + cloud_provider = self._clean_text(pending_action.get("cloud_provider")).lower() + overrides: Dict[str, Any] = {} + if cloud_provider == "quark": + overrides = {"has_quark": True, "has_115": False, "prefer_cloud_provider": "quark"} + elif cloud_provider == "115": + overrides = {"has_quark": False, "has_115": True, "prefer_cloud_provider": "115"} + result = await self._assistant_cloud_transfer_execute( + request, + keyword=search_keyword, + session=session, + cache_key=cache_key, + media_type=self._clean_text(candidate.get("media_type") or state.get("media_type") or "auto").lower() or "auto", + year=candidate_year or self._clean_text(state.get("year")), + source_order=["pansou", "hdhive"], + target_path=self._clean_text(state.get("target_path")), + session_preference_overrides=overrides, + origin=cloud_provider or "cloud_transfer", + ) + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + return finish(result) + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + result = await self._assistant_mp_media_search( + keyword=search_keyword, + session=session, + cache_key=cache_key, + preferences=preferences, + result_filter=self._clean_text(pending_action.get("result_filter")).lower(), + ) + result_data = dict(result.get("data") or {}) + result_data["selected_candidate"] = candidate + result_data["original_keyword"] = self._clean_text(state.get("keyword")) + result["data"] = result_data + if result.get("success"): + result["message"] = ( + f"已选择:{self._format_candidate_label(candidate)}\n" + f"{result.get('message') or ''}" + ).strip() + return finish(result) + + if kind == "assistant_mp": + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + total = self._safe_int(state.get("total"), 0) + if action == "next_page": + total_pages = max(1, (max(0, total) + page_size - 1) // page_size) if total else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号查看详情或下载。"} + next_page = current_page + 1 + all_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] + if all_items: + preview = self._slice_mp_preview_items(all_items, page=next_page, page_size=page_size) + else: + preview = self._mp_search_cache_preview( + cache_key, + preferences=preferences, + page=next_page, + page_size=page_size, + ) + all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) + updated_state = { + **state, + "items": preview, + "all_items": all_items, + "page": next_page, + "page_size": page_size, + "total": total, + } + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_mp_search_text( + self._clean_text(state.get("keyword")), + f"{self._clean_text(state.get('keyword'))} — MP搜索", + preview, + total=total, + page=next_page, + page_size=page_size, + result_filter=self._clean_text(state.get("result_filter")), + latest_episode=self._safe_int(state.get("latest_episode"), 0), + episode_filter=self._safe_int(state.get("episode_filter"), 0), + ), + "data": self._assistant_response_data(session=session, data={ + "action": "mp_media_search_next_page", + "ok": True, + "keyword": self._clean_text(state.get("keyword")), + "items": preview, + "page": next_page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }), + }) + if action == "best": + result = await self._assistant_mp_best_result_detail( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + if action == "best_execute": + plan_result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix="已自动选择当前评分最高的 PT 候选并执行计划", + extra_data={"source_type": "mp_pt"}, + )) + if action == "best_plan": + result = await self._assistant_mp_best_download_plan( + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + if action == "detail" and index <= 0: + return {"success": False, "message": "MP 搜索结果详情需要编号,例如:选择 1。"} + if action == "plan" and index <= 0: + return {"success": False, "message": "生成 PT 下载计划需要编号,例如:下载1。"} + if action == "plan" or (not action and index > 0): + result = self._assistant_mp_download_plan_response( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + workflow="mp_download", + message="PT 下载计划已生成", + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) + result["data"] = result_data + return finish(result) + result = await self._assistant_mp_result_detail( + choice=index, + session=session, + cache_key=cache_key, + preferences=preferences, + ) + if state.get("recommend_handoff"): + result_data = dict(result.get("data") or {}) + result_data.update(self._assistant_recommend_handoff_short_metadata(state)) + result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) + result["data"] = result_data + return finish(result) + + if kind == "assistant_hdhive": + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return finish({ + "success": False, + "message": disabled.get("message") or "影巢资源入口已关闭", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_pick", + "ok": False, + "error_code": "hdhive_resource_disabled", + "resource_enabled": False, + }), + }) + stage = str(state.get("stage") or "").strip() + service = self._ensure_hdhive_service() + final_path = target_path or state.get("target_path") or self._hdhive_default_path + if stage == "candidate": + candidates = state.get("candidates") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(cache_key, {**state, "candidates": enriched, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_detail", + "ok": True, + "page": current_page, + "candidates": enriched, + }), + }) + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(cache_key, {**state, "page": next_page, "target_path": final_path}) + return finish({ + "success": True, + "message": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_candidates_next_page", + "ok": True, + "page": next_page, + "total_pages": total_pages, + }), + }) + if action == "best": + return {"success": False, "message": "影巢候选影片阶段没有评分,不能用“最佳片源”;请先回复编号选择影片,进入资源列表后再用“最佳片源”。"} + if action == "best_execute": + return {"success": False, "message": "影巢候选影片阶段还不能直接执行最佳;请先进入资源列表,或直接发送:智能执行 片名。"} + if action == "best_plan": + return {"success": False, "message": "影巢候选影片阶段还不能直接生成最佳计划;请先进入资源列表,或直接发送:智能计划 片名。"} + if action == "plan": + return {"success": False, "message": "影巢候选影片阶段不能生成资源计划;请先回复编号选择影片,进入资源列表后再发:计划选择 1。"} + if action: + return {"success": False, "message": "影巢候选阶段只支持:选择 编号、详情/审查、下一页。"} + if index <= 0: + return {"success": False, "message": "请选择有效影片编号,例如:选择 1"} + if index > len(candidates): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or state.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": f"影巢资源查询失败:{resource_message}", "data": resource_result} + preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) + preview = self._attach_cloud_scores( + self._group_resource_preview(resource_result.get("data") or [], per_group=None), + preferences=preferences, + source_type="hdhive", + target_path=final_path, + ) + self._save_session( + cache_key, + { + **state, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": final_path, + }, + ) + return finish({ + "success": True, + "message": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_search", + "ok": True, + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "total_pages": max(1, (len(preview) + self._assistant_result_page_size - 1) // self._assistant_result_page_size) if preview else 1, + "score_summary": self._score_summary(preview, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(preview), + }), + }) + resources = state.get("resources") or [] + page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(state.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return finish({ + "success": False, + "message": "已经是最后一页了,可以直接回复编号继续选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": False, + "error_code": "already_last_page", + "page": current_page, + "page_size": page_size, + "total_pages": total_pages, + }), + }) + next_page = current_page + 1 + updated_state = {**state, "page": next_page, "page_size": page_size} + self._save_session(cache_key, updated_state) + return finish({ + "success": True, + "message": self._format_resource_lines(resources, dict(state.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resources_next_page", + "ok": True, + "selected_candidate": dict(state.get("selected_candidate") or {}), + "resources": resources, + "page": next_page, + "page_size": page_size, + "total_pages": total_pages, + "score_summary": self._score_summary(resources, limit=5), + "decision_summary": self._assistant_hdhive_resource_entry_summary(resources), + }), + }) + if action == "best": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请直接回复编号选择。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(best, title="影巢当前最佳资源"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_detail", + "ok": True, + "item": best, + "score_summary": self._score_summary([best], limit=1), + }), + }) + if action == "best_execute": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再执行最佳。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_execute", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法执行最佳。"} + plan_result = self._save_assistant_pick_plan_response( + workflow="hdhive_best_execute", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_execute", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + ) + return finish(await self._assistant_execute_prepared_plan_result( + request, + session=session, + cache_key=cache_key, + plan_result=plan_result, + message_prefix=f"已自动选择影巢最佳资源并执行:#{index}", + extra_data={"choice": index, "source_type": "hdhive"}, + )) + if action == "best_plan": + best = self._best_scored_source_item(resources) + if not best: + return finish({ + "success": False, + "message": "当前影巢资源没有可评分条目,请先查看列表后再生成计划。", + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_best_resource_plan", + "ok": False, + "error_code": "best_item_not_found", + }), + }) + index = self._safe_int(best.get("index"), 0) + slug = self._clean_text(best.get("slug")) + if not slug: + return {"success": False, "message": "当前影巢首选缺少 slug,无法生成计划。"} + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_best_plan", + session=session, + session_id=cache_key, + actions=[{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": best, + }], + execute_body={ + "workflow": "hdhive_best_plan", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢最佳资源计划已生成", + score_items=[best], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": best, + }, + )) + if action == "detail": + if index <= 0: + return {"success": False, "message": "影巢资源详情需要编号,例如:选择 1 详情。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + return finish({ + "success": True, + "message": self._format_cloud_item_detail_text(resource, title="影巢资源详情"), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_resource_detail", + "ok": True, + "choice": index, + "item": resource, + "score_summary": self._score_summary([resource], limit=1), + }), + }) + if action == "plan": + if index <= 0: + return {"success": False, "message": "影巢资源计划需要编号,例如:计划选择 1。"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + if not slug: + return {"success": False, "message": "选中的影巢资源缺少 slug,无法生成计划。"} + actions = [{ + "name": "unlock_hdhive_resource", + "session": session, + "session_id": cache_key, + "slug": slug, + "path": final_path, + "resource": resource, + }] + return finish(self._save_assistant_pick_plan_response( + workflow="hdhive_unlock_selected", + session=session, + session_id=cache_key, + actions=actions, + execute_body={ + "workflow": "hdhive_unlock_selected", + "session": session, + "session_id": cache_key, + "choice": index, + "path": final_path, + }, + message="影巢解锁/转存计划已生成", + score_items=[resource], + extra_data={ + "choice": index, + "target_path": final_path, + "selected_resource": resource, + }, + )) + if action: + return {"success": False, "message": "影巢资源阶段只支持:直接回编号、计划选择 编号、选择 编号 详情;也支持短命令“详情 3”“计划 3”。"} + if index <= 0: + return {"success": False, "message": "请选择有效资源编号,例如:选择 1"} + if index > len(resources): + return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} + resource = dict(resources[index - 1]) + route_ok, route_result, route_message = await self._unlock_and_route( + self._clean_text(resource.get("slug")), + target_path=final_path, + resource=resource, + ) + if not route_ok: + route = dict((route_result or {}).get("route") or {}) + share_url = self._clean_text(route.get("share_url")) + if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": + self._save_pending_p115_share( + cache_key, + share_url=share_url, + access_code=route.get("access_code") or "", + target_path=route.get("target_path") or final_path, + source="assistant_hdhive_unlock", + title=resource.get("title") or resource.get("matched_title") or "", + last_error=route_message, + ) + return finish({ + "success": False, + "message": f"{route_message}\n{self._format_p115_resume_hint(resource.get('title') or resource.get('matched_title') or '')}", + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": False, + "message": route_message, + "data": self._assistant_response_data(session=session, data=route_result), + }) + return finish({ + "success": True, + "message": self._format_route_result(route_result), + "data": self._assistant_response_data(session=session, data={ + "action": "hdhive_unlock", + "ok": True, + "selected_resource": resource, + "result": route_result, + }), + }) + + return {"success": False, "message": f"当前会话阶段不支持继续选择:{kind or 'unknown'}"} + + async def api_assistant_capabilities(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_capabilities_public_data() + return { + "success": True, + "message": self._format_assistant_capabilities_text(), + "data": self._assistant_capabilities_compact_data(data) if compact else self._assistant_response_data( + session="default", + data=data, + ), + } + + async def api_assistant_readiness(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + data = self._assistant_readiness_public_data() + response_data = { + "action": "readiness", + "ok": bool(data.get("can_start")), + **data, + } + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_readiness_text(), + "data": self._assistant_readiness_compact_data(response_data) if compact else self._assistant_response_data( + session="default", + data=response_data, + ), + } + + async def api_assistant_pulse(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_pulse_public_data() + return { + "success": bool(data.get("can_start")), + "message": self._format_assistant_pulse_text(), + "data": data, + } + + async def api_assistant_startup(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_startup_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_startup_text(), + "data": data, + } + + async def api_assistant_maintain(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + requested_execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + execute = requested_execute and request.method.upper() != "GET" + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + data = self._assistant_maintain_public_data(execute=execute, limit=limit) + if requested_execute and request.method.upper() == "GET": + data["execute_ignored"] = True + data["warning"] = "GET 请求只返回 dry-run 维护建议;如需执行维护请使用 POST execute=true。" + if execute: + executed_actions = data.get("executed_actions") or [] + self._record_assistant_execution( + action="maintain", + session=self._clean_text(body.get("session")) or "default", + session_id=self._clean_text(body.get("session_id")), + success=bool(data.get("ok")), + message=self._format_assistant_maintain_text(data), + summary={ + "executed": bool(data.get("executed")), + "executed_actions": [ + { + "name": self._clean_text(item.get("name")), + "removed": self._safe_int(item.get("removed"), 0), + } + for item in executed_actions + if isinstance(item, dict) + ], + "after": { + "stale_sessions": (data.get("after") or {}).get("stale_sessions"), + "saved_plans_executed": (data.get("after") or {}).get("saved_plans_executed"), + "saved_plans_pending": (data.get("after") or {}).get("saved_plans_pending"), + }, + }, + ) + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_maintain_text(data), + "data": data, + } + + async def api_assistant_toolbox(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_toolbox_public_data() + return { + "success": True, + "message": self._format_assistant_toolbox_text(), + "data": data, + } + + async def api_assistant_request_templates(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) + names = ( + body.get("names") + or body.get("name") + or body.get("template") + or request.query_params.get("names") + or request.query_params.get("name") + or request.query_params.get("template") + ) + recipe = ( + body.get("recipe") + or body.get("recommended_recipe") + or request.query_params.get("recipe") + or request.query_params.get("recommended_recipe") + ) + include_templates = self._parse_bool_value( + body.get("include_templates") if "include_templates" in body else request.query_params.get("include_templates"), + True, + ) + data = self._assistant_request_templates_response_data( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + return { + "success": True, + "message": self._format_assistant_request_templates_text(data), + "data": data, + } + + async def api_assistant_selfcheck(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + data = self._assistant_selfcheck_public_data() + return { + "success": bool(data.get("ok")), + "message": self._format_assistant_selfcheck_text(), + "data": data, + } + + async def api_assistant_history(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) + response_data = self._assistant_history_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "history", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_history_text(session=session, session_id=session_id, limit=limit), + "data": response_data, + } + + async def api_assistant_plans(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session = self._clean_text(request.query_params.get("session")) + session_id = self._clean_text(request.query_params.get("session_id")) + executed = self._parse_optional_bool(request.query_params.get("executed")) + include_actions = bool(self._parse_optional_bool(request.query_params.get("include_actions")) or False) + compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_plans_public_data( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ) + response_data = self._assistant_plans_compact_data(data) if compact else self._assistant_response_data( + session=session or "default", + data={ + "action": "plans", + "ok": True, + **data, + }, + ) + return { + "success": True, + "message": self._format_assistant_plans_text( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + limit=limit, + ), + "data": response_data, + } + + async def api_assistant_plans_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_workflow_plans( + plan_id=body.get("plan_id"), + session=body.get("session"), + session_id=body.get("session_id"), + executed=self._parse_optional_bool(body.get("executed")), + all_plans=self._parse_bool_value(body.get("all_plans"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + if not result.get("ok"): + return { + "success": False, + "message": str(result.get("message") or "计划清理参数无效"), + "data": result, + } + return { + "success": True, + "message": str(result.get("message") or "计划清理完成"), + "data": self._assistant_response_data(session=body.get("session") or "default", data={ + "action": "plans_clear", + "ok": True, + **result, + }), + } + + async def api_assistant_recover(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + + session = self._clean_text(body.get("session") or request.query_params.get("session")) + session_id = self._clean_text(body.get("session_id") or request.query_params.get("session_id")) + execute = self._parse_bool_value( + body.get("execute") if "execute" in body else request.query_params.get("execute"), + False, + ) + prefer_unexecuted = self._parse_bool_value( + body.get("prefer_unexecuted") if "prefer_unexecuted" in body else None, + True, + ) + stop_on_error = self._parse_bool_value( + body.get("stop_on_error") if "stop_on_error" in body else None, + True, + ) + include_raw_results = self._parse_bool_value( + body.get("include_raw_results") if "include_raw_results" in body else None, + False, + ) + compact = bool( + self._parse_optional_bool(body.get("compact")) + if "compact" in body + else self._parse_optional_bool(request.query_params.get("compact")) + ) + limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 20) + data = self._assistant_recover_public_data( + session=session, + session_id=session_id, + limit=limit, + ) + data.update({ + "action": "recover", + "ok": True, + "execute_requested": execute, + "executed": False, + }) + + if not execute: + return { + "success": True, + "message": self._format_assistant_recover_text(data), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + recovery = dict(data.get("recovery") or {}) + if not recovery.get("can_resume"): + data["ok"] = False + data["execute_error"] = recovery.get("reason") or "当前没有可直接恢复的动作" + return { + "success": False, + "message": str(data["execute_error"]), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + template = dict(recovery.get("action_template") or {}) + action_body = dict(template.get("action_body") or {}) + if not action_body and template.get("name"): + action_body = {"name": template.get("name"), **dict(template.get("body") or {})} + if not self._clean_text(action_body.get("name")): + data["ok"] = False + data["execute_error"] = "恢复模板缺少可执行动作名" + return { + "success": False, + "message": data["execute_error"], + "data": self._assistant_recover_response_data(data, compact=compact), + } + + action_body.setdefault("session", data.get("session") or "default") + if data.get("session_id"): + action_body.setdefault("session_id", data.get("session_id")) + action_body.setdefault("prefer_unexecuted", prefer_unexecuted) + action_body.setdefault("stop_on_error", stop_on_error) + action_body.setdefault("include_raw_results", include_raw_results) + action_body["apikey"] = self._extract_apikey(request, body) + result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) + result_data = dict(result.get("data") or {}) + data.update({ + "ok": bool(result.get("success")), + "executed": True, + "execute_success": bool(result.get("success")), + "execute_action": action_body.get("name"), + "execute_message": result.get("message") or "", + "execute_result": result if include_raw_results else { + "success": bool(result.get("success")), + "message": result.get("message") or "", + "data": { + "action": result_data.get("action"), + "ok": result_data.get("ok"), + "session": result_data.get("session"), + "session_id": result_data.get("session_id"), + "plan_id": result_data.get("plan_id"), + "workflow": result_data.get("workflow"), + }, + }, + }) + return { + "success": bool(result.get("success")), + "message": f"恢复动作 {action_body.get('name')} 执行完成\n{result.get('message') or ''}".strip(), + "data": self._assistant_recover_response_data(data, compact=compact), + } + + async def api_assistant_session_state(self, request: Request): + body: Dict[str, Any] = {} + if request.method.upper() != "GET": + try: + body = await request.json() + except Exception: + body = {} + ok, message = self._check_api_access(request, body or None) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, _ = self._normalize_assistant_session_ref( + session=( + (body or {}).get("session") + or request.query_params.get("session") + or request.query_params.get("chat_id") + or request.query_params.get("user_id") + or "default" + ), + session_id=(body or {}).get("session_id") or request.query_params.get("session_id"), + ) + compact = bool( + self._parse_optional_bool((body or {}).get("compact")) + if "compact" in (body or {}) + else self._parse_optional_bool(request.query_params.get("compact")) + ) + summary = self._format_assistant_session_summary(session=session) + session_state = self._assistant_session_public_data(session=session) + if compact: + return { + "success": True, + "message": summary, + "data": self._assistant_session_compact_data(session_state), + } + data = self._assistant_response_data(session=session, data={ + "action": "session_state", + "ok": True, + "session_snapshot": session_state, + **session_state, + }) + return {"success": True, "message": summary, "data": data} + + async def api_assistant_session_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + session, cache_key = self._normalize_assistant_session_ref( + session=( + body.get("session") + or body.get("chat_id") + or body.get("user_id") + or body.get("conversation_id") + or "default" + ), + session_id=body.get("session_id"), + ) + existing = self._load_session(cache_key) + if not existing: + return { + "success": True, + "message": "当前没有需要清理的会话。", + "data": self._assistant_response_data(session=session, data={"cleared": False}), + } + self._session_cache.pop(cache_key, None) + self._persist_relevant_sessions() + return { + "success": True, + "message": f"已清理会话:{session}", + "data": self._assistant_response_data(session=session, data={"cleared": True}), + } + + async def api_assistant_sessions(self, request: Request): + ok, message = self._check_api_access(request) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + kind = self._clean_text(request.query_params.get("kind")) + has_pending_p115_raw = request.query_params.get("has_pending_p115") + has_pending_p115: Optional[bool] = None + if has_pending_p115_raw is not None: + has_pending_p115 = str(has_pending_p115_raw).strip().lower() in {"1", "true", "yes", "y"} + compact = bool(self._parse_optional_bool(request.query_params.get("compact"))) + limit = self._safe_int(request.query_params.get("limit"), 20) + data = self._assistant_sessions_public_data( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ) + if compact: + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_sessions_compact_data(data), + } + return { + "success": True, + "message": self._format_assistant_sessions_text( + kind=kind, + has_pending_p115=has_pending_p115, + limit=limit, + ), + "data": self._assistant_response_data(session="default", data={ + "action": "sessions", + "ok": True, + **data, + }), + } + + async def api_assistant_sessions_clear(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + result = self._clear_assistant_sessions( + session=body.get("session"), + session_id=body.get("session_id"), + kind=body.get("kind"), + has_pending_p115=body.get("has_pending_p115"), + stale_only=self._parse_bool_value(body.get("stale_only"), False), + all_sessions=self._parse_bool_value(body.get("all_sessions"), False), + limit=self._safe_int(body.get("limit"), 100), + ) + cleared_count = self._safe_int(result.get("cleared_count"), 0) + if cleared_count <= 0: + return { + "success": True, + "message": "没有匹配到需要清理的 assistant 会话。", + "data": result, + } + return { + "success": True, + "message": f"已清理 {cleared_count} 个 assistant 会话。", + "data": result, + } + + async def api_session_hdhive_search(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + keyword = self._clean_text(body.get("keyword") or body.get("title")) + media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() + year = self._clean_text(body.get("year")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + service = self._ensure_hdhive_service() + search_ok, result, search_message = await service.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=max(30, self._hdhive_candidate_page_size), + ) + if not search_ok: + return {"success": False, "message": search_message, "data": result} + + session_id = self._new_session_id("hdhive") + self._save_session( + session_id, + { + "kind": "hdhive", + "stage": "candidate", + "keyword": keyword, + "media_type": media_type, + "year": year, + "target_path": target_path, + "candidates": result.get("candidates") or [], + "page": 1, + "page_size": self._hdhive_candidate_page_size, + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(result.get("candidates") or [], page=1, page_size=self._hdhive_candidate_page_size), + "session_id": session_id, + "stage": "candidate", + "keyword": keyword, + "candidates": result.get("candidates") or [], + "candidate_count": len(result.get("candidates") or []), + "meta": result.get("meta") or {}, + }, + } + + async def api_session_hdhive_pick(self, request: Request): + body = await request.json() + ok, message = self._check_api_access(request, body) + if not ok: + return {"success": False, "message": message} + if not self._enabled: + return {"success": False, "message": "插件未启用"} + allowed, disabled = self._ensure_hdhive_resource_enabled() + if not allowed: + return disabled + + session_id = self._clean_text(body.get("session_id")) + index = self._safe_int( + body.get("index") + or body.get("choice") + or body.get("selection") + or body.get("number"), + 0, + ) + action = self._normalize_pick_action(body.get("action") or body.get("pick_action")) + target_path = self._clean_text(body.get("path") or body.get("target_path")) + if not session_id or (index <= 0 and not action): + return {"success": False, "message": "session_id 和选择编号必填;详情/翻页动作可传 action"} + + session = self._load_session(session_id) + if not session: + return {"success": False, "message": "会话不存在或已过期"} + + stage = session.get("stage") + service = self._ensure_hdhive_service() + + if stage == "candidate": + candidates = session.get("candidates") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "detail": + start = (current_page - 1) * page_size + end = start + page_size + enriched = [dict(item or {}) for item in candidates] + enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) + self._save_session(session_id, {**session, "candidates": enriched, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": current_page, + "candidates": enriched, + }, + } + if action == "next_page": + total_pages = max(1, (len(candidates) + page_size - 1) // page_size) + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), + "session_id": session_id, + "stage": "candidate", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(candidates): + return {"success": False, "message": "候选编号超出范围"} + candidate = dict(candidates[index - 1]) + resource_ok, resource_result, resource_message = service.search_resources( + media_type=candidate.get("media_type") or session.get("media_type") or "movie", + tmdb_id=str(candidate.get("tmdb_id") or ""), + ) + if not resource_ok: + return {"success": False, "message": resource_message, "data": resource_result} + preview = self._group_resource_preview(resource_result.get("data") or [], per_group=None) + self._save_session( + session_id, + { + **session, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "target_path": target_path or session.get("target_path") or "", + }, + ) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), + "session_id": session_id, + "stage": "resource", + "selected_candidate": candidate, + "resources": preview, + "page": 1, + "page_size": self._assistant_result_page_size, + "meta": { + "total": len(preview), + "count_115": len([x for x in preview if str(x.get("pan_type") or "").lower() == "115"]), + "count_quark": len([x for x in preview if str(x.get("pan_type") or "").lower() == "quark"]), + }, + }, + } + + if stage == "resource": + resources = session.get("resources") or [] + page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) + current_page = max(1, self._safe_int(session.get("page"), 1)) + if action == "next_page": + total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 + if current_page >= total_pages: + return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} + next_page = current_page + 1 + self._save_session(session_id, {**session, "page": next_page, "page_size": page_size, "target_path": target_path or session.get("target_path") or ""}) + return { + "success": True, + "message": "success", + "data": { + "text": self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), + "session_id": session_id, + "stage": "resource", + "page": next_page, + "total_pages": total_pages, + }, + } + if index > len(resources): + return {"success": False, "message": "资源编号超出范围"} + resource = dict(resources[index - 1]) + slug = self._clean_text(resource.get("slug")) + route_ok, route_result, route_message = await self._unlock_and_route( + slug, + target_path=target_path or session.get("target_path") or "", + resource=resource, + ) + if not route_ok: + return {"success": False, "message": route_message, "data": route_result} + return { + "success": True, + "message": route_message, + "data": { + "text": self._format_route_result(route_result), + "session_id": session_id, + "selected_resource": resource, + "result": route_result, + }, + } + + return {"success": False, "message": f"当前会话阶段不支持继续选择: {stage}"} diff --git a/plugins/agentresourceofficer/agenttool.py b/plugins/agentresourceofficer/agenttool.py new file mode 100644 index 0000000..4af8355 --- /dev/null +++ b/plugins/agentresourceofficer/agenttool.py @@ -0,0 +1,870 @@ +from typing import Optional, Type + +from pydantic import BaseModel + +from app.agent.tools.base import MoviePilotTool +from app.core.plugin import PluginManager + +from .schemas import ( + AssistantCapabilitiesToolInput, + AssistantExecuteActionToolInput, + AssistantExecuteActionsToolInput, + AssistantExecutePlanToolInput, + AssistantHistoryToolInput, + AssistantHelpToolInput, + AssistantMaintainToolInput, + AssistantPickToolInput, + AssistantPreferencesToolInput, + AssistantPlansClearToolInput, + AssistantPlansToolInput, + AssistantPulseToolInput, + AssistantReadinessToolInput, + AssistantRecoverToolInput, + AssistantRequestTemplatesToolInput, + AssistantRouteToolInput, + AssistantSessionClearToolInput, + AssistantSessionsClearToolInput, + AssistantSessionsToolInput, + AssistantSessionStateToolInput, + AssistantSelfcheckToolInput, + AssistantStartupToolInput, + AssistantToolboxToolInput, + AssistantWorkflowToolInput, + FeishuChannelHealthToolInput, + HDHiveSearchSessionToolInput, + HDHiveSessionPickToolInput, + P115CancelPendingToolInput, + P115PendingToolInput, + P115QRCodeCheckToolInput, + P115QRCodeStartToolInput, + P115ResumePendingToolInput, + P115StatusToolInput, + ShareRouteToolInput, +) + + +def _get_plugin(): + return PluginManager().running_plugins.get("AgentResourceOfficer") + + +class HDHiveSearchSessionTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_search" + description: str = "Search HDHive by title, return candidate titles and a reusable session_id for the next selection step." + args_schema: Type[BaseModel] = HDHiveSearchSessionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + keyword = kwargs.get("keyword", "") + return f"正在通过 Agent影视助手搜索影巢候选:{keyword}" + + async def run(self, keyword: str, media_type: str = "auto", year: str = None, path: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_search_session( + keyword=keyword, + media_type=media_type, + year=year, + target_path=path, + ) + + +class HDHiveSessionPickTool(MoviePilotTool): + name: str = "agent_resource_officer_hdhive_pick" + description: str = "Continue a previous HDHive session by selecting either a candidate title or a resource item." + args_schema: Type[BaseModel] = HDHiveSessionPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session_id = kwargs.get("session_id", "") + choice = kwargs.get("choice", "") + return f"正在继续 Agent影视助手 会话:{session_id},选择 {choice}" + + async def run(self, session_id: str, choice: int = 0, path: str = None, action: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_hdhive_pick_session( + session_id=session_id, + index=choice, + target_path=path, + action=action, + ) + + +class ShareRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_route_share" + description: str = "Route a 115 or Quark share link into the configured transfer pipeline and save it into the target path." + args_schema: Type[BaseModel] = ShareRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 路由分享链接" + + async def run(self, url: str, path: str = None, access_code: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_route_share( + share_url=url, + access_code=access_code, + target_path=path, + ) + + +class AssistantRouteTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_entry" + description: str = "Use the unified Agent影视助手 smart entry for HDHive search, PanSou search, 115 login, or direct 115/Quark share links." + args_schema: Type[BaseModel] = AssistantRouteToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + text = kwargs.get("text") or kwargs.get("keyword") or kwargs.get("url") or kwargs.get("action") or "" + return f"正在通过 Agent影视助手 统一入口处理:{text}" + + async def run( + self, + text: str = None, + session: str = "default", + session_id: str = None, + path: str = None, + mode: str = None, + keyword: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + action: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_route( + text=text, + session=session, + session_id=session_id, + target_path=path, + mode=mode, + keyword=keyword, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + action=action, + compact=compact, + ) + + +class AssistantPickTool(MoviePilotTool): + name: str = "agent_resource_officer_smart_pick" + description: str = "Continue the unified Agent影视助手 smart-entry session by choosing an item, requesting details, or moving to the next page." + args_schema: Type[BaseModel] = AssistantPickToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + choice = kwargs.get("choice", 0) + action = kwargs.get("action", "") + tail = f"动作 {action}" if action else f"选择 {choice}" + return f"正在继续 Agent影视助手 统一会话:{session},{tail}" + + async def run( + self, + session: str = "default", + session_id: str = None, + choice: int = 0, + action: str = None, + mode: str = None, + path: str = None, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pick( + session=session, + session_id=session_id, + index=choice, + action=action, + mode=mode, + target_path=path, + compact=compact, + ) + + +class AssistantHelpTool(MoviePilotTool): + name: str = "agent_resource_officer_help" + description: str = "Show the recommended Agent影视助手 workflow for MoviePilot Agent, including smart-entry examples, pick examples, and 115 login guidance." + args_schema: Type[BaseModel] = AssistantHelpToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 使用帮助" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_help(session=session, session_id=session_id) + + +class AssistantCapabilitiesTool(MoviePilotTool): + name: str = "agent_resource_officer_capabilities" + description: str = "Show the current Agent影视助手 execution capabilities, supported structured smart-entry fields, defaults, and recommended call patterns for external agents." + args_schema: Type[BaseModel] = AssistantCapabilitiesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 能力说明" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_capabilities(compact=compact) + + +class AssistantReadinessTool(MoviePilotTool): + name: str = "agent_resource_officer_readiness" + description: str = "Check whether Agent影视助手 is ready for external agents, including version, services, suggested entrypoints, and startup warnings." + args_schema: Type[BaseModel] = AssistantReadinessToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 启动就绪状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_readiness(compact=compact) + + +class FeishuChannelHealthTool(MoviePilotTool): + name: str = "agent_resource_officer_feishu_health" + description: str = "Check Agent影视助手 built-in Feishu Channel status, including whether it is enabled, running, and configured." + args_schema: Type[BaseModel] = FeishuChannelHealthToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 内置飞书入口状态" + + async def run(self, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_feishu_health(compact=compact) + + +class AssistantPulseTool(MoviePilotTool): + name: str = "agent_resource_officer_pulse" + description: str = "Return a compact Agent影视助手 startup pulse: version, service readiness, warnings, and best recovery hint for external agents." + args_schema: Type[BaseModel] = AssistantPulseToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 轻量启动状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_pulse() + + +class AssistantStartupTool(MoviePilotTool): + name: str = "agent_resource_officer_startup" + description: str = "Return one compact startup bundle for external agents: pulse, self-check result, key tools, endpoints, defaults, and recovery hint." + args_schema: Type[BaseModel] = AssistantStartupToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 启动聚合信息" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_startup() + + +class AssistantMaintainTool(MoviePilotTool): + name: str = "agent_resource_officer_maintain" + description: str = "Inspect or execute low-risk Agent影视助手 maintenance: clear stale assistant sessions and executed saved plans." + args_schema: Type[BaseModel] = AssistantMaintainToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在检查 Agent影视助手 维护建议" + + async def run(self, execute: bool = False, limit: int = 100, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_maintain(execute=execute, limit=limit) + + +class AssistantToolboxTool(MoviePilotTool): + name: str = "agent_resource_officer_toolbox" + description: str = "Return a compact Agent影视助手 toolbox manifest: recommended tools, endpoints, workflows, actions, defaults, and command examples." + args_schema: Type[BaseModel] = AssistantToolboxToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 轻量工具清单" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_toolbox() + + +class AssistantRequestTemplatesTool(MoviePilotTool): + name: str = "agent_resource_officer_request_templates" + description: str = "Return compact HTTP request templates for external agents to call Agent影视助手 assistant endpoints without guessing request bodies." + args_schema: Type[BaseModel] = AssistantRequestTemplatesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在读取 Agent影视助手 请求模板" + + async def run(self, limit: int = 100, names: str = None, recipe: str = None, include_templates: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_request_templates( + limit=limit, + names=names, + recipe=recipe, + include_templates=include_templates, + ) + + +class AssistantSelfcheckTool(MoviePilotTool): + name: str = "agent_resource_officer_selfcheck" + description: str = "Run a compact Agent影视助手 protocol self-check for compact templates, boolean parsing, and basic assistant protocol health." + args_schema: Type[BaseModel] = AssistantSelfcheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在执行 Agent影视助手 协议自检" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_selfcheck() + + +class AssistantHistoryTool(MoviePilotTool): + name: str = "agent_resource_officer_history" + description: str = "Show recent Agent影视助手 assistant executions so external agents can debug progress, retries, and the last completed action." + args_schema: Type[BaseModel] = AssistantHistoryToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 最近执行历史" + + async def run( + self, + session: str = None, + session_id: str = None, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_history( + session=session, + session_id=session_id, + compact=compact, + limit=limit, + ) + + +class AssistantExecuteActionTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_action" + description: str = "Execute a named Agent影视助手 action template directly, so external agents can reuse action_templates without manually mapping each next step." + args_schema: Type[BaseModel] = AssistantExecuteActionToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 动作模板:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + choice: int = None, + path: str = None, + keyword: str = None, + media_type: str = None, + year: str = None, + url: str = None, + access_code: str = None, + client_type: str = None, + source: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + plan_id: str = None, + prefer_unexecuted: bool = True, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_action( + name=name, + session=session, + session_id=session_id, + choice=choice, + target_path=path, + keyword=keyword, + media_type=media_type, + year=year, + share_url=url, + access_code=access_code, + client_type=client_type, + source=source, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + plan_id=plan_id, + prefer_unexecuted=prefer_unexecuted, + compact=compact, + ) + + +class AssistantExecuteActionsTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_actions" + description: str = "Execute a sequence of Agent影视助手 action templates in one request, so external agents can reduce round trips and reuse action_templates directly." + args_schema: Type[BaseModel] = AssistantExecuteActionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + actions = kwargs.get("actions") or [] + return f"正在批量执行 Agent影视助手 动作模板:{len(actions)} 步" + + async def run( + self, + actions: list, + session: str = "default", + session_id: str = None, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_actions( + actions=actions, + session=session, + session_id=session_id, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantWorkflowTool(MoviePilotTool): + name: str = "agent_resource_officer_run_workflow" + description: str = "Run a preset Agent影视助手 workflow such as pansou_transfer, hdhive_unlock, mp_search_best, mp_search_detail, mp_search_download, mp_subscribe, mp_recommend, share_transfer, or p115_status with compact inputs." + args_schema: Type[BaseModel] = AssistantWorkflowToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在运行 Agent影视助手 预设工作流:{kwargs.get('name', '')}" + + async def run( + self, + name: str, + session: str = "default", + session_id: str = None, + keyword: str = None, + choice: int = None, + candidate_choice: int = None, + resource_choice: int = None, + path: str = None, + url: str = None, + access_code: str = None, + media_type: str = None, + year: str = None, + client_type: str = None, + source: str = None, + limit: int = 20, + dry_run: bool = False, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_workflow( + name=name, + session=session, + session_id=session_id, + keyword=keyword, + choice=choice, + candidate_choice=candidate_choice, + resource_choice=resource_choice, + target_path=path, + share_url=url, + access_code=access_code, + media_type=media_type, + year=year, + client_type=client_type, + source=source, + limit=limit, + dry_run=dry_run, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPreferencesTool(MoviePilotTool): + name: str = "agent_resource_officer_preferences" + description: str = "Read, save, or reset Agent影视助手 source preferences for scoring cloud-drive and PT results before automated actions." + args_schema: Type[BaseModel] = AssistantPreferencesToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + if kwargs.get("reset"): + return "正在重置 Agent影视助手 智能体偏好画像" + if kwargs.get("preferences"): + return "正在保存 Agent影视助手 智能体偏好画像" + return "正在读取 Agent影视助手 智能体偏好画像" + + async def run( + self, + session: str = "default", + session_id: str = None, + user_key: str = None, + preferences: dict = None, + reset: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_preferences( + session=session, + session_id=session_id, + user_key=user_key, + preferences=preferences, + reset=reset, + compact=compact, + ) + + +class AssistantExecutePlanTool(MoviePilotTool): + name: str = "agent_resource_officer_execute_plan" + description: str = "Execute a saved Agent影视助手 dry-run workflow plan by plan_id, or recover the latest plan by session/session_id." + args_schema: Type[BaseModel] = AssistantExecutePlanToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return f"正在执行 Agent影视助手 已保存计划:{kwargs.get('plan_id', '') or kwargs.get('session_id', '') or kwargs.get('session', '')}" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_execute_plan( + plan_id=plan_id, + session=session, + session_id=session_id, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + ) + + +class AssistantPlansTool(MoviePilotTool): + name: str = "agent_resource_officer_plans" + description: str = "List saved Agent影视助手 dry-run workflow plans so agents can recover and execute the right plan_id." + args_schema: Type[BaseModel] = AssistantPlansToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 已保存计划" + + async def run( + self, + session: str = None, + session_id: str = None, + executed: bool = None, + include_actions: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans( + session=session, + session_id=session_id, + executed=executed, + include_actions=include_actions, + compact=compact, + limit=limit, + ) + + +class AssistantPlansClearTool(MoviePilotTool): + name: str = "agent_resource_officer_plans_clear" + description: str = "Clear saved Agent影视助手 workflow plans by plan_id, session, executed state, or all_plans." + args_schema: Type[BaseModel] = AssistantPlansClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 已保存计划" + + async def run( + self, + plan_id: str = None, + session: str = None, + session_id: str = None, + executed: bool = None, + all_plans: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_plans_clear( + plan_id=plan_id, + session=session, + session_id=session_id, + executed=executed, + all_plans=all_plans, + limit=limit, + ) + + +class AssistantRecoverTool(MoviePilotTool): + name: str = "agent_resource_officer_recover" + description: str = "Inspect the best Agent影视助手 recovery action, or execute it directly, so external agents can resume work through one stable entrypoint." + args_schema: Type[BaseModel] = AssistantRecoverToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + target = kwargs.get("session_id") or kwargs.get("session") or "全局" + action = "并直接恢复" if kwargs.get("execute") else "恢复建议" + return f"正在查看 Agent影视助手 {target} 的{action}" + + async def run( + self, + session: str = None, + session_id: str = None, + execute: bool = False, + prefer_unexecuted: bool = True, + stop_on_error: bool = True, + include_raw_results: bool = False, + compact: bool = True, + limit: int = 20, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_recover( + session=session, + session_id=session_id, + execute=execute, + prefer_unexecuted=prefer_unexecuted, + stop_on_error=stop_on_error, + include_raw_results=include_raw_results, + compact=compact, + limit=limit, + ) + + +class AssistantSessionStateTool(MoviePilotTool): + name: str = "agent_resource_officer_session_state" + description: str = "Inspect the current Agent影视助手 assistant session, including stage, current page, selected candidate, and pending 115 task." + args_schema: Type[BaseModel] = AssistantSessionStateToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在查看 Agent影视助手 会话状态:{session}" + + async def run(self, session: str = "default", session_id: str = None, compact: bool = True, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_state(session=session, session_id=session_id, compact=compact) + + +class AssistantSessionClearTool(MoviePilotTool): + name: str = "agent_resource_officer_session_clear" + description: str = "Clear the current Agent影视助手 assistant session cache." + args_schema: Type[BaseModel] = AssistantSessionClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + session = kwargs.get("session", "default") + return f"正在清理 Agent影视助手 会话:{session}" + + async def run(self, session: str = "default", session_id: str = None, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_session_clear(session=session, session_id=session_id) + + +class AssistantSessionsTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions" + description: str = "List active Agent影视助手 assistant sessions so external agents can recover, inspect, and resume the right workflow." + args_schema: Type[BaseModel] = AssistantSessionsToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在查看 Agent影视助手 活跃会话列表" + + async def run(self, kind: str = None, has_pending_p115: bool = None, compact: bool = True, limit: int = 20, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions( + kind=kind, + has_pending_p115=has_pending_p115, + compact=compact, + limit=limit, + ) + + +class AssistantSessionsClearTool(MoviePilotTool): + name: str = "agent_resource_officer_sessions_clear" + description: str = "Clear one or more Agent影视助手 assistant sessions by session_id, session name, filters, or full reset." + args_schema: Type[BaseModel] = AssistantSessionsClearToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在清理 Agent影视助手 活跃会话" + + async def run( + self, + session: str = None, + session_id: str = None, + kind: str = None, + has_pending_p115: bool = None, + stale_only: bool = False, + all_sessions: bool = False, + limit: int = 100, + **kwargs, + ) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_assistant_sessions_clear( + session=session, + session_id=session_id, + kind=kind, + has_pending_p115=has_pending_p115, + stale_only=stale_only, + all_sessions=all_sessions, + limit=limit, + ) + + +class P115QRCodeStartTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_start" + description: str = "Generate a 115 login QR code using the p115client-compatible client session flow." + args_schema: Type[BaseModel] = P115QRCodeStartToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + client_type = kwargs.get("client_type", "alipaymini") + return f"正在通过 Agent影视助手 生成 115 扫码二维码:{client_type}" + + async def run(self, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_start(client_type=client_type) + + +class P115QRCodeCheckTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_qrcode_check" + description: str = "Check the status of a previous 115 QR-code login and save the client session when login succeeds." + args_schema: Type[BaseModel] = P115QRCodeCheckToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 检查 115 扫码状态" + + async def run(self, uid: str, time: str, sign: str, client_type: str = "alipaymini", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_qrcode_check( + uid=uid, + time_value=time, + sign=sign, + client_type=client_type, + ) + + +class P115StatusTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_status" + description: str = "Show the current 115 transfer readiness, default target path, and current session source." + args_schema: Type[BaseModel] = P115StatusToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看 115 当前状态" + + async def run(self, **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_status() + + +class P115PendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_pending" + description: str = "Show the pending 115 transfer task for an assistant session, including target path, retry count, and last error." + args_schema: Type[BaseModel] = P115PendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 查看待继续的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_pending(session=session) + + +class P115ResumePendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_resume_pending" + description: str = "Retry the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115ResumePendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 继续待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_resume(session=session) + + +class P115CancelPendingTool(MoviePilotTool): + name: str = "agent_resource_officer_p115_cancel_pending" + description: str = "Cancel and clear the pending 115 transfer task for an assistant session." + args_schema: Type[BaseModel] = P115CancelPendingToolInput + + def get_tool_message(self, **kwargs) -> Optional[str]: + return "正在通过 Agent影视助手 取消待处理的 115 任务" + + async def run(self, session: str = "default", **kwargs) -> str: + plugin = _get_plugin() + if not plugin: + return "Agent影视助手 插件未运行" + return await plugin.tool_p115_cancel(session=session) diff --git a/plugins/agentresourceofficer/feishu_channel.py b/plugins/agentresourceofficer/feishu_channel.py new file mode 100644 index 0000000..44a1c32 --- /dev/null +++ b/plugins/agentresourceofficer/feishu_channel.py @@ -0,0 +1,1885 @@ +import asyncio +import copy +import fcntl +import importlib +import json +import re +import sqlite3 +import threading +import time +import traceback +from base64 import b64decode +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import jieba +except Exception: + jieba = None + +try: + import lark_oapi as lark +except Exception: + lark = None + +_LARK_IMPORT_LOCK = threading.Lock() + +try: + 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.core.event import eventmanager + from app.core.metainfo import MetaInfo + from app.db.downloadhistory_oper import DownloadHistoryOper + from app.db.models.downloadhistory import DownloadHistory + from app.db.models.transferhistory import TransferHistory + from app.db.site_oper import SiteOper + from app.db.subscribe_oper import SubscribeOper + from app.db.systemconfig_oper import SystemConfigOper + from app.helper.subscribe import SubscribeHelper + from app.core.plugin import PluginManager + from app.log import logger + from app.scheduler import Scheduler + from app.schemas.types import EventType, SystemConfigKey, TorrentStatus, media_type_to_agent + from app.utils.http import RequestUtils + from app.utils.string import StringUtils +except Exception: + DownloadChain = None + DownloadHistoryOper = None + DownloadHistory = None + TransferHistory = None + MediaChain = None + SearchChain = None + SiteOper = None + SubscribeChain = None + SubscribeHelper = None + SubscribeOper = None + SystemConfigOper = None + eventmanager = None + MetaInfo = None + PluginManager = None + Scheduler = None + EventType = None + SystemConfigKey = None + TorrentStatus = None + media_type_to_agent = None + RequestUtils = None + StringUtils = None + + class _FallbackLogger: + @staticmethod + def info(message: str) -> None: + print(message) + + @staticmethod + def warning(message: str) -> None: + print(message) + + @staticmethod + def error(message: str) -> None: + print(message) + + logger = _FallbackLogger() + + +_EVENT_CACHE_FILE = Path(__file__).resolve().parent / ".feishu_event_cache.json" + + +def ensure_lark_sdk(auto_install: bool = False) -> tuple[bool, str]: + global lark + + if lark is not None: + return True, "" + + with _LARK_IMPORT_LOCK: + if lark is not None: + return True, "" + + try: + import lark_oapi as runtime_lark + + lark = runtime_lark + return True, "" + except Exception as exc: + first_error = str(exc) + + return False, f"缺少依赖 lark-oapi:{first_error}。请通过插件 requirements.txt 安装依赖后重启 MoviePilot。" + + +class _FeishuLongConnectionRuntime: + def __init__(self) -> None: + self._thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + self._fingerprint = "" + self._channel: Optional["FeishuChannel"] = None + + def start(self, channel: "FeishuChannel") -> None: + ok, message = ensure_lark_sdk(auto_install=False) + if not ok: + logger.error(f"[AgentResourceOfficer][Feishu] {message}") + return + + if not channel.enabled or not channel.app_id or not channel.app_secret: + return + + fingerprint = channel.connection_fingerprint() + with self._lock: + self._channel = channel + if self._thread and self._thread.is_alive(): + if fingerprint != self._fingerprint: + logger.warning("[AgentResourceOfficer][Feishu] 长连接已在运行,飞书凭证变更需重启 MoviePilot 后生效") + return + self._fingerprint = fingerprint + self._thread = threading.Thread( + target=self._run, + name="agent-resource-officer-feishu", + daemon=True, + ) + self._thread.start() + + def _run(self) -> None: + channel = self._channel + if channel is None or lark is None: + return + + def _on_message(data) -> None: + current = self._channel + if current is not None: + current.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( + channel.app_id, + channel.app_secret, + log_level=lark.LogLevel.DEBUG if channel.debug else lark.LogLevel.INFO, + event_handler=event_handler, + ) + logger.info("[AgentResourceOfficer][Feishu] 正在启动飞书长连接") + ws_client.start() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 长连接退出:{exc}\n{traceback.format_exc()}") + + def is_running(self) -> bool: + with self._lock: + return bool(self._thread and self._thread.is_alive()) + + def stop(self) -> None: + with self._lock: + self._channel = None + + +class FeishuChannel: + _LEGACY_DEFAULT_COMMANDS = { + "/p115_manual_transfer", + "/p115_inc_sync", + "/p115_full_sync", + "/p115_strm", + "/quark_save", + "/media_search", + "/media_download", + "/media_subscribe", + "/media_subscribe_search", + } + _LEGACY_DEFAULT_ALIAS_KEYS = { + "刮削", + "搜索", + "MP搜索", + "原生搜索", + "下载", + "订阅", + "订阅搜索", + "生成STRM", + "全量STRM", + "指定路径STRM", + "夸克转存", + "夸克", + "搜索资源", + "下载资源", + "订阅媒体", + "订阅并搜索", + } + + def __init__(self, plugin: Any) -> None: + self.plugin = plugin + self.runtime = _FeishuLongConnectionRuntime() + self.enabled = False + self.allow_all = False + self.reply_enabled = True + self.reply_receive_id_type = "chat_id" + self.app_id = "" + self.app_secret = "" + self.verification_token = "" + self.allowed_chat_ids: List[str] = [] + self.allowed_user_ids: List[str] = [] + self.command_whitelist: List[str] = [] + self.command_aliases = "" + self.command_mode = "resource_officer" + self.debug = False + self._token_cache: Dict[str, Any] = {} + self._token_lock = threading.Lock() + self._event_cache: Dict[str, float] = {} + self._event_lock = threading.Lock() + self._search_cache: Dict[str, Dict[str, Any]] = {} + self._search_cache_lock = threading.Lock() + self._search_cache_limit = 200 + + @classmethod + def default_command_whitelist(cls) -> List[str]: + return [ + "/pansou_search", + "/smart_entry", + "/smart_pick", + "/media_search", + "/version", + ] + + @classmethod + def default_command_aliases(cls) -> str: + return ( + "搜索=/smart_entry\n" + "找=/smart_entry\n" + "云盘搜索=/smart_entry\n" + "MP搜索=/smart_entry\n" + "PT搜索=/smart_entry\n" + "原生搜索=/smart_entry\n" + "盘搜搜索=/pansou_search\n" + "盘搜=/pansou_search\n" + "ps=/pansou_search\n" + "1=/pansou_search\n" + "影巢搜索=/smart_entry\n" + "影巢=/smart_entry\n" + "yc=/smart_entry\n" + "2=/smart_entry\n" + "转存=/smart_entry\n" + "115转存=/smart_entry\n" + "夸克转存=/smart_entry\n" + "夸克=/smart_entry\n" + "下载=/smart_entry\n" + "订阅=/smart_entry\n" + "订阅搜索=/smart_entry\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_entry\n" + "影巢普通签到=/smart_entry\n" + "普通签到=/smart_entry\n" + "签到=/smart_entry\n" + "赌狗签到=/smart_entry\n" + "签到日志=/smart_entry\n" + "影巢签到日志=/smart_entry\n" + "选择=/smart_pick\n" + "详情=/smart_pick\n" + "审查=/smart_pick\n" + "选=/smart_pick\n" + "继续=/smart_pick\n" + "搜索资源=/smart_entry\n" + "下载资源=/smart_entry\n" + "订阅媒体=/smart_entry\n" + "订阅并搜索=/smart_entry\n" + "版本=/version" + ) + + @staticmethod + def clean(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() + + @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()] + + @classmethod + def parse_alias_text(cls, 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 + + @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(): + if key in cls._LEGACY_DEFAULT_ALIAS_KEYS and value in cls._LEGACY_DEFAULT_COMMANDS: + continue + merged[key] = value + return "\n".join(f"{key}={value}" for key, value in merged.items()) + + @classmethod + def merge_command_whitelist(cls, configured: List[str]) -> List[str]: + merged: List[str] = [] + seen = set() + for cmd in configured or []: + if cmd in cls._LEGACY_DEFAULT_COMMANDS: + continue + 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 + + def configure(self, config: Dict[str, Any]) -> None: + self.enabled = bool(config.get("feishu_enabled", False)) + self.allow_all = bool(config.get("feishu_allow_all", False)) + self.reply_enabled = bool(config.get("feishu_reply_enabled", True)) + self.reply_receive_id_type = self.clean(config.get("feishu_reply_receive_id_type") or "chat_id") + self.app_id = self.clean(config.get("feishu_app_id")) + self.app_secret = self.clean(config.get("feishu_app_secret")) + self.verification_token = self.clean(config.get("feishu_verification_token")) + self.allowed_chat_ids = self.split_lines(config.get("feishu_allowed_chat_ids")) + self.allowed_user_ids = self.split_lines(config.get("feishu_allowed_user_ids")) + self.command_whitelist = self.merge_command_whitelist(self.split_commands(config.get("feishu_command_whitelist"))) + self.command_aliases = self.merge_command_aliases(self.clean(config.get("feishu_command_aliases"))) + self.command_mode = self.clean(config.get("feishu_command_mode") or "resource_officer") + self.debug = bool(config.get("debug", False)) + + def start(self) -> None: + if self.enabled: + self.runtime.start(self) + + def stop(self) -> None: + self.runtime.stop() + + def is_running(self) -> bool: + return self.runtime.is_running() + + @staticmethod + def is_legacy_bridge_running() -> bool: + if PluginManager is None: + return False + try: + running_plugins = PluginManager().running_plugins or {} + plugin = ( + running_plugins.get("FeishuCommandBridgeLong") + or running_plugins.get("feishucommandbridgelong") + ) + if not plugin: + return False + config_db = Path("/config/user.db") + if config_db.exists(): + try: + with sqlite3.connect(str(config_db)) as conn: + row = conn.execute( + "select value from systemconfig where key=?", + ("plugin.FeishuCommandBridgeLong",), + ).fetchone() + if row and row[0]: + config = json.loads(row[0]) + if not bool(config.get("enabled")): + return False + except Exception: + pass + # MoviePilot may keep disabled plugins in running_plugins after loading. + # Treat the legacy bridge as a conflict only when it is actually enabled. + if hasattr(plugin, "health"): + try: + health = plugin.health() + if isinstance(health, dict): + return bool(health.get("enabled") and health.get("running")) + except Exception: + pass + if hasattr(plugin, "_enabled"): + return bool(getattr(plugin, "_enabled", False)) + if hasattr(plugin, "get_state"): + try: + return bool(plugin.get_state()) + except Exception: + return False + return False + except Exception: + return False + + def connection_fingerprint(self) -> str: + return "|".join([self.app_id, self.app_secret, self.verification_token]) + + def health(self) -> Dict[str, Any]: + sdk_available, sdk_message = ensure_lark_sdk(auto_install=False) + legacy_bridge_running = self.is_legacy_bridge_running() + app_id_configured = bool(self.app_id) + app_secret_configured = bool(self.app_secret) + verification_token_configured = bool(self.verification_token) + missing_requirements = [] + if not sdk_available: + missing_requirements.append("lark-oapi") + if not app_id_configured: + missing_requirements.append("feishu_app_id") + if not app_secret_configured: + missing_requirements.append("feishu_app_secret") + conflict_warning = bool(self.enabled and legacy_bridge_running) + ready_to_start = bool(self.enabled and sdk_available and app_id_configured and app_secret_configured and not conflict_warning) + safe_to_enable = bool((not legacy_bridge_running) and sdk_available and app_id_configured and app_secret_configured) + if conflict_warning: + recommended_action = "disable_legacy_bridge_or_use_different_app" + migration_hint = "内置飞书入口和旧飞书桥接同时运行,建议关闭旧桥接或使用不同飞书 App。" + elif not self.enabled and legacy_bridge_running: + recommended_action = "keep_legacy_or_disable_it_before_migration" + migration_hint = "内置飞书入口关闭,旧飞书桥接运行中;迁移前先关闭旧桥接。" + elif not self.enabled: + recommended_action = "configure_and_enable_feishu_channel" + migration_hint = "内置飞书入口关闭;配置飞书凭证后可开启。" + elif missing_requirements: + recommended_action = "complete_feishu_requirements" + migration_hint = "内置飞书入口已启用,但依赖或飞书凭证不完整。" + elif not self.is_running(): + recommended_action = "restart_moviepilot_or_resave_config" + migration_hint = "内置飞书入口已启用但长连接未运行,建议保存配置或重启 MoviePilot。" + else: + recommended_action = "none" + migration_hint = "内置飞书入口运行正常。" + return { + "enabled": self.enabled, + "running": self.is_running(), + "sdk_available": sdk_available, + "app_id_configured": app_id_configured, + "app_secret_configured": app_secret_configured, + "verification_token_configured": verification_token_configured, + "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_mode": self.command_mode, + "command_whitelist": self.command_whitelist, + "alias_count": len(self.parse_alias_text(self.command_aliases)), + "legacy_bridge_running": legacy_bridge_running, + "conflict_warning": conflict_warning, + "ready_to_start": ready_to_start, + "safe_to_enable": safe_to_enable, + "missing_requirements": missing_requirements, + "sdk_message": sdk_message, + "recommended_action": recommended_action, + "migration_hint": migration_hint, + } + + def handle_long_connection_event(self, data: Any) -> None: + if not self.enabled: + return + event = getattr(data, "event", None) + header = getattr(data, "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 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 self.debug: + logger.info(f"[AgentResourceOfficer][Feishu] event_id={event_id} chat_id={chat_id}") + + if not self._is_allowed(chat_id=chat_id, user_open_id=sender_open_id): + self.reply_text(chat_id, sender_open_id, "该会话未在白名单中,命令已拒绝。") + return + if self._is_help_request(raw_text): + self.reply_text(chat_id, sender_open_id, self._build_help_text()) + return + if self._is_menu_request(raw_text): + self.reply_text(chat_id, sender_open_id, 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_text(chat_id, sender_open_id, f"命令 {cmd} 不在白名单中。\n\n{self._build_help_text()}") + return + if not self._handle_builtin_command(command_text, chat_id, sender_open_id): + self._submit_moviepilot_command(command_text, chat_id, sender_open_id) + + def _handle_builtin_command(self, command_text: str, chat_id: str, open_id: str) -> bool: + parts = command_text.split(maxsplit=1) + cmd = parts[0].strip() + arg = parts[1].strip() if len(parts) > 1 else "" + cache_key = self._cache_key(chat_id, open_id) + + if cmd == "/version": + self.reply_text(chat_id, open_id, f"Agent影视助手 {getattr(self.plugin, 'plugin_version', '')}\n飞书入口:{'运行中' if self.is_running() else '未运行'}") + return True + + if cmd == "/media_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:MP搜索 片名") + return True + self.reply_text(chat_id, open_id, f"正在使用 MP 原生搜索:{arg}") + self._run_thread("feishu-media-search", self._run_media_search, arg, chat_id, open_id) + return True + + if cmd == "/media_download": + if not arg or not arg.isdigit(): + self.reply_text(chat_id, open_id, "用法:下载资源 序号\n示例:下载资源 1") + return True + self.reply_text(chat_id, open_id, f"正在生成第 {arg} 条资源的下载计划,请稍候。") + self._run_thread("feishu-media-download", self._run_media_download, int(arg), chat_id, open_id) + return True + + if cmd in {"/media_subscribe", "/media_subscribe_search"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:订阅媒体 片名\n示例:订阅媒体 流浪地球2") + return True + immediate = cmd == "/media_subscribe_search" + self.reply_text(chat_id, open_id, f"正在{'订阅并搜索' if immediate else '订阅'}:{arg}") + self._run_thread("feishu-media-subscribe", self._run_media_subscribe, arg, immediate, chat_id, open_id) + return True + + if cmd == "/pansou_search": + if not arg: + self.reply_text(chat_id, open_id, "用法:盘搜搜索 片名\n示例:盘搜搜索 流浪地球2") + return True + self.reply_text(chat_id, open_id, f"正在使用盘搜搜索:{arg}") + self._run_thread("feishu-pansou-search", self._run_assistant_route, f"盘搜搜索 {arg}", cache_key, chat_id, open_id) + return True + + if cmd in {"/smart_entry", "/quark_save"}: + if not arg: + self.reply_text(chat_id, open_id, "用法:处理 片名 或 处理 分享链接") + return True + self.reply_text(chat_id, open_id, f"正在智能处理:{arg}") + self._run_thread("feishu-smart-entry", self._run_assistant_route, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/smart_pick": + if not arg: + self.reply_text(chat_id, open_id, "用法:选择 序号\n示例:选择 1\n也支持:详情、审查、n 下一页") + return True + self.reply_text(chat_id, open_id, f"正在继续执行:{arg}") + self._run_thread("feishu-smart-pick", self._run_assistant_pick, arg, cache_key, chat_id, open_id) + return True + + if cmd == "/p115_manual_transfer": + if not arg: + paths = self._get_p115_manual_transfer_paths() + if not paths: + self.reply_text(chat_id, open_id, "未配置待整理目录。请先在 P115StrmHelper 中配置 pan_transfer_paths,或发送:刮削 /待整理/") + return True + self.reply_text(chat_id, open_id, f"已开始刮削 {len(paths)} 个目录:\n" + "\n".join(f"- {path}" for path in paths)) + self._run_thread("feishu-p115-manual-transfer-batch", self._run_p115_manual_transfer_batch, paths, chat_id, open_id) + return True + self.reply_text(chat_id, open_id, f"已开始刮削:{arg}") + self._run_thread("feishu-p115-manual-transfer", self._run_p115_manual_transfer, arg, chat_id, open_id) + return True + + if cmd in {"/p115_inc_sync", "/p115_full_sync", "/p115_strm"}: + final_command = "/p115_full_sync" if cmd == "/p115_strm" and not arg else command_text + self._submit_p115_command(final_command, chat_id, open_id) + return True + + return False + + @staticmethod + def _run_thread(name: str, target: Any, *args: Any) -> None: + threading.Thread(target=target, args=args, name=name, daemon=True).start() + + def _run_assistant_route(self, text: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route(text=text, session=session) + self._reply_result(chat_id, open_id, result) + + def _run_assistant_pick(self, arg: str, session: str, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_pick(arg=arg, session=session) + self._reply_result(chat_id, open_id, result) + + def _reply_result(self, chat_id: str, open_id: str, result: Dict[str, Any]) -> None: + message = str(result.get("message") or "处理完成").strip() + self.reply_text(chat_id, open_id, message) + qrcode = self._find_nested_value(result.get("data"), "qrcode") + if isinstance(qrcode, str): + self.reply_qrcode_data_url(chat_id, open_id, qrcode) + + @classmethod + def _find_nested_value(cls, payload: Any, key: str) -> Any: + if isinstance(payload, dict): + if key in payload: + return payload.get(key) + for value in payload.values(): + found = cls._find_nested_value(value, key) + if found: + return found + elif isinstance(payload, list): + for value in payload: + found = cls._find_nested_value(value, key) + if found: + return found + return None + + def _run_media_search(self, keyword: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_search(keyword, self._cache_key(chat_id, open_id))) + + def _run_media_download(self, index: int, chat_id: str, open_id: str) -> None: + result = self.plugin.feishu_assistant_route( + text=f"下载资源 {index}", + session=self._cache_key(chat_id, open_id), + ) + self._reply_result(chat_id, open_id, result) + + def _run_media_subscribe(self, keyword: str, immediate: bool, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_media_subscribe(keyword, immediate)) + + def _execute_media_search(self, keyword: str, cache_key: str) -> str: + if not all([MetaInfo, MediaChain, SearchChain, StringUtils]): + return "MP 原生搜索失败:当前环境缺少 MoviePilot 搜索依赖。" + 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) + preview_limit = 20 + preview_results = results[:preview_limit] + lines = [ + f"已识别:{self._format_media_label(mediainfo, season)}", + f"共找到 {len(results)} 条资源,展示前 {len(preview_results)} 条:", + ] + for idx, context in enumerate(preview_results, 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"[AgentResourceOfficer][Feishu] 搜索资源失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"搜索资源失败:{keyword}\n错误:{exc}" + + def _query_media_detail(self, keyword: str, media_type: str = "", year: str = "") -> Dict[str, Any]: + if not all([MetaInfo, MediaChain]): + return {"success": False, "message": "媒体识别失败:当前环境缺少 MoviePilot 媒体识别依赖。", "item": {}} + title_text = str(keyword or "").strip() + if not title_text: + return {"success": False, "message": "媒体识别失败:缺少片名。", "item": {}} + try: + meta = MetaInfo(title_text) + if year: + try: + meta.year = str(year) + except Exception: + pass + mediainfo = MediaChain().recognize_media(meta=meta) + if not mediainfo: + return {"success": False, "message": f"未识别到媒体信息:{title_text}", "item": {"keyword": title_text}} + season = meta.begin_season if meta.begin_season else getattr(mediainfo, "season", None) + media_type_value = getattr(mediainfo, "type", None) + media_type_name = getattr(media_type_value, "name", "") or str(media_type_value or "") + item = { + "keyword": title_text, + "title": str(getattr(mediainfo, "title", "") or ""), + "original_title": str(getattr(mediainfo, "original_title", "") or ""), + "year": str(getattr(mediainfo, "year", "") or ""), + "type": media_type_name, + "tmdb_id": getattr(mediainfo, "tmdb_id", None), + "douban_id": getattr(mediainfo, "douban_id", None), + "imdb_id": str(getattr(mediainfo, "imdb_id", "") or ""), + "season": season, + "category": str(getattr(mediainfo, "category", "") or ""), + "overview": str(getattr(mediainfo, "overview", "") or "")[:300], + } + lines = [ + f"媒体识别:{title_text}", + f"结果:{item.get('title') or '-'} ({item.get('year') or '-'})", + f"类型:{item.get('type') or '-'} | TMDB:{item.get('tmdb_id') or '-'} | 豆瓣:{item.get('douban_id') or '-'}", + ] + if item.get("original_title") and item.get("original_title") != item.get("title"): + lines.append(f"原标题:{item.get('original_title')}") + if season: + lines.append(f"季:S{int(season):02d}" if isinstance(season, int) else f"季:{season}") + if item.get("overview"): + lines.append(f"简介:{item.get('overview')}") + lines.append("说明:这是 MoviePilot 原生识别结果,后续 MP 搜索、订阅和 PT 评分会以它为准。") + return {"success": True, "message": "\n".join(lines), "item": item} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 媒体识别失败:{title_text} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"媒体识别失败:{exc}", "item": {"keyword": title_text}} + + def _execute_media_download(self, index: int, cache_key: str) -> str: + if DownloadChain is None: + return "下载资源失败:当前环境缺少 MoviePilot 下载依赖。" + cache = self._get_search_cache(cache_key) + if not cache: + return "没有可用的搜索缓存,请先发送:MP搜索 片名" + 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: + save_path = "" + if self.plugin is not None: + save_path = str(getattr(self.plugin, "_mp_download_save_path", "") or "").strip() + download_id = DownloadChain().download_single( + context=context, + username="agentresourceofficer-feishu", + source="AgentResourceOfficer", + save_path=save_path or None, + ) + if not download_id: + return f"下载提交失败:{torrent.title}" + path_line = f"\n保存路径:{save_path}" if save_path else "" + return f"已提交下载:{torrent.title}\n站点:{torrent.site_name or '未知站点'}{path_line}\n任务ID:{download_id}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 下载资源失败:{torrent.title} {exc}\n{traceback.format_exc()}") + return f"下载资源失败:{torrent.title}\n错误:{exc}" + + def _query_download_tasks( + self, + *, + downloader: str = "", + status: str = "downloading", + title: str = "", + hash_value: str = "", + limit: int = 10, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "查询下载任务失败:当前环境缺少 MoviePilot 下载依赖。", "items": []} + try: + chain = DownloadChain() + status_name = str(status or "downloading").strip().lower() + downloader_name = str(downloader or "").strip() or None + tasks: List[Any] = [] + if hash_value: + tasks = chain.list_torrents(downloader=downloader_name, hashs=[hash_value]) or [] + elif status_name == "downloading": + tasks = chain.downloading(name=downloader_name) or [] + else: + for torrent_status in [TorrentStatus.DOWNLOADING, TorrentStatus.TRANSFER] if TorrentStatus else []: + tasks.extend(chain.list_torrents(downloader=downloader_name, status=torrent_status) or []) + if status_name == "completed": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() in {"seeding", "completed"}] + elif status_name == "paused": + tasks = [task for task in tasks if str(getattr(task, "state", "") or "").lower() == "paused"] + if title: + title_lower = title.lower() + tasks = [ + task for task in tasks + if title_lower in str(getattr(task, "title", "") or getattr(task, "name", "") or "").lower() + ] + items: List[Dict[str, Any]] = [] + for index, task in enumerate(tasks[:max(1, min(30, int(limit or 10)))], 1): + task_hash = str(getattr(task, "hash", "") or "") + history = DownloadHistoryOper().get_by_hash(task_hash) if DownloadHistoryOper and task_hash else None + title_text = str(getattr(task, "title", "") or getattr(task, "name", "") or "").strip() + if history and getattr(history, "title", None): + title_text = title_text or str(history.title) + size_value = getattr(task, "size", None) + size_text = StringUtils.str_filesize(size_value) if StringUtils and size_value else "" + progress = getattr(task, "progress", None) + try: + progress_text = f"{float(progress):.1f}%" if progress is not None else "" + except Exception: + progress_text = str(progress or "") + items.append({ + "index": index, + "hash": task_hash, + "hash_short": task_hash[:8], + "downloader": str(getattr(task, "downloader", "") or ""), + "title": title_text or "未命名任务", + "name": str(getattr(task, "name", "") or ""), + "size": size_text, + "progress": progress_text, + "state": str(getattr(task, "state", "") or ""), + "dlspeed": getattr(task, "dlspeed", None), + "upspeed": getattr(task, "upspeed", None), + "left_time": getattr(task, "left_time", None), + "tags": str(getattr(task, "tags", "") or ""), + "media_title": str(getattr(history, "title", "") or "") if history else "", + }) + status_label = { + "downloading": "下载中", + "completed": "已完成", + "paused": "已暂停", + "all": "全部", + }.get(status_name, status_name) + if not items: + return { + "success": True, + "message": f"未找到{status_label}下载任务。", + "items": [], + "total": len(tasks), + "status": status_name, + } + lines = [f"下载任务:{status_label},共 {len(tasks)} 条,展示前 {len(items)} 条:"] + for item in items: + details = [ + item.get("progress") or "进度未知", + item.get("size") or "大小未知", + item.get("state") or "状态未知", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('hash_short')}", + ] + lines.append(f"{item.get('index')}. {item.get('title')}") + lines.append(" " + " | ".join(details)) + lines.append("写入操作需确认:可发“暂停下载 1”“恢复下载 1”“删除下载 1”。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": len(tasks), + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载任务失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载任务失败:{exc}", "items": []} + + def _control_download_task( + self, + *, + action: str, + hash_value: str, + downloader: str = "", + delete_files: bool = False, + ) -> Dict[str, Any]: + if DownloadChain is None: + return {"success": False, "message": "操作下载任务失败:当前环境缺少 MoviePilot 下载依赖。"} + task_hash = str(hash_value or "").strip() + if len(task_hash) != 40 or not all(ch in "0123456789abcdefABCDEF" for ch in task_hash): + return {"success": False, "message": "操作下载任务失败:hash 格式无效,请先查询下载任务后按编号操作。"} + downloader_name = str(downloader or "").strip() or None + action_name = str(action or "").strip().lower() + try: + chain = DownloadChain() + if action_name in {"pause", "stop"}: + ok = chain.set_downloading(task_hash, "stop", name=downloader_name) + label = "暂停" + elif action_name in {"resume", "start"}: + ok = chain.set_downloading(task_hash, "start", name=downloader_name) + label = "恢复" + elif action_name in {"delete", "remove"}: + ok = chain.remove_torrents(hashs=[task_hash], downloader=downloader_name, delete_file=bool(delete_files)) + label = "删除" + else: + return {"success": False, "message": f"操作下载任务失败:不支持的动作 {action}"} + suffix = "(包含文件)" if action_name in {"delete", "remove"} and delete_files else "" + return { + "success": bool(ok), + "message": f"{label}下载任务{'成功' if ok else '失败'}:{task_hash[:8]}{suffix}", + "hash": task_hash, + "downloader": downloader_name or "", + "action": action_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作下载任务失败:{task_hash} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作下载任务失败:{exc}"} + + def _query_downloaders(self) -> Dict[str, Any]: + if SystemConfigOper is None or SystemConfigKey is None: + return {"success": False, "message": "查询下载器失败:当前环境缺少 MoviePilot 配置依赖。", "items": []} + try: + raw_items = SystemConfigOper().get(SystemConfigKey.Downloaders) or [] + items: List[Dict[str, Any]] = [] + for index, item in enumerate(raw_items, 1): + if not isinstance(item, dict): + continue + items.append({ + "index": index, + "name": str(item.get("name") or ""), + "type": str(item.get("type") or ""), + "enabled": bool(item.get("enabled")), + "default": bool(item.get("default")), + }) + enabled = [item for item in items if item.get("enabled")] + if not items: + return {"success": True, "message": "未配置下载器。", "items": [], "enabled_count": 0} + lines = [f"下载器配置:共 {len(items)} 个,启用 {len(enabled)} 个"] + for item in items: + status = "启用" if item.get("enabled") else "停用" + default = ",默认" if item.get("default") else "" + lines.append(f"{item.get('index')}. {item.get('name') or '-'} | {item.get('type') or '-'} | {status}{default}") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "enabled_count": len(enabled), + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载器失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载器失败:{exc}", "items": []} + + def _query_sites(self, *, status: str = "active", name: str = "", limit: int = 30) -> Dict[str, Any]: + if SiteOper is None: + return {"success": False, "message": "查询站点失败:当前环境缺少 MoviePilot 站点依赖。", "items": []} + try: + status_name = str(status or "active").strip().lower() + name_filter = str(name or "").strip().lower() + sites = SiteOper().list_order_by_pri() or [] + items: List[Dict[str, Any]] = [] + for site in sites: + is_active = bool(getattr(site, "is_active", False)) + if status_name == "active" and not is_active: + continue + if status_name == "inactive" and is_active: + continue + site_name = str(getattr(site, "name", "") or "") + if name_filter and name_filter not in site_name.lower(): + continue + cookie = str(getattr(site, "cookie", "") or "") + items.append({ + "index": len(items) + 1, + "id": getattr(site, "id", None), + "name": site_name, + "domain": str(getattr(site, "domain", "") or ""), + "url": str(getattr(site, "url", "") or ""), + "pri": getattr(site, "pri", None), + "is_active": is_active, + "has_cookie": bool(cookie), + "downloader": str(getattr(site, "downloader", "") or ""), + "proxy": bool(getattr(site, "proxy", False)), + "timeout": getattr(site, "timeout", None), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 30)))] + label = {"active": "已启用", "inactive": "已停用", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{label}站点。", "items": [], "total": total} + lines = [f"PT 站点:{label},共 {total} 个,展示前 {len(items)} 个:"] + for item in items: + cookie_state = "有Cookie" if item.get("has_cookie") else "无Cookie" + active_state = "启用" if item.get("is_active") else "停用" + lines.append( + f"{item.get('index')}. {item.get('name') or '-'} | {item.get('domain') or '-'} | " + f"{active_state} | {cookie_state} | 优先级:{item.get('pri')} | 下载器:{item.get('downloader') or '默认'}" + ) + lines.append("说明:这里不会返回 Cookie 明文;如站点搜索失败,优先检查是否启用、Cookie 是否存在、站点绑定下载器是否可用。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询站点失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询站点失败:{exc}", "items": []} + + def _query_subscribes( + self, + *, + status: str = "all", + media_type: str = "all", + name: str = "", + limit: int = 20, + ) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "查询订阅失败:当前环境缺少 MoviePilot 订阅依赖。", "items": []} + try: + status_name = str(status or "all").strip() + media_type_name = str(media_type or "all").strip().lower() + name_filter = str(name or "").strip().lower() + subscribes = SubscribeOper().list() or [] + items: List[Dict[str, Any]] = [] + for sub in subscribes: + state = str(getattr(sub, "state", "") or "") + if status_name != "all" and state != status_name: + continue + sub_type = str(getattr(sub, "type", "") or "").lower() + if media_type_name != "all" and media_type_name not in {sub_type, "movie" if sub_type == "电影" else sub_type, "tv" if sub_type == "电视剧" else sub_type}: + continue + title = str(getattr(sub, "name", "") or "") + if name_filter and name_filter not in title.lower(): + continue + items.append({ + "index": len(items) + 1, + "id": getattr(sub, "id", None), + "name": title or "未命名订阅", + "year": str(getattr(sub, "year", "") or ""), + "type": str(getattr(sub, "type", "") or ""), + "season": getattr(sub, "season", None), + "state": state, + "total_episode": getattr(sub, "total_episode", None), + "lack_episode": getattr(sub, "lack_episode", None), + "start_episode": getattr(sub, "start_episode", None), + "quality": str(getattr(sub, "quality", "") or ""), + "resolution": str(getattr(sub, "resolution", "") or ""), + "effect": str(getattr(sub, "effect", "") or ""), + "include": str(getattr(sub, "include", "") or ""), + "exclude": str(getattr(sub, "exclude", "") or ""), + "sites": getattr(sub, "sites", None), + "downloader": str(getattr(sub, "downloader", "") or ""), + "save_path": str(getattr(sub, "save_path", "") or ""), + "best_version": getattr(sub, "best_version", None), + "tmdbid": getattr(sub, "tmdbid", None), + "doubanid": str(getattr(sub, "doubanid", "") or ""), + "last_update": str(getattr(sub, "last_update", "") or ""), + }) + total = len(items) + items = items[:max(1, min(100, int(limit or 20)))] + status_label = {"R": "启用", "S": "暂停", "P": "待处理", "N": "完成", "all": "全部"}.get(status_name, status_name) + if not items: + return {"success": True, "message": f"未找到{status_label}订阅。", "items": [], "total": total} + lines = [f"MP 订阅:{status_label},共 {total} 条,展示前 {len(items)} 条:"] + for item in items: + season = f" S{int(item.get('season')):02d}" if item.get("season") else "" + lack = item.get("lack_episode") + lack_text = f"缺 {lack} 集" if lack not in (None, "", 0) else "无缺集" + filters = " / ".join(value for value in [item.get("resolution"), item.get("effect"), item.get("quality")] if value) or "默认规则" + lines.append(f"{item.get('index')}. #{item.get('id')} {item.get('name')} ({item.get('year') or '-'}){season}") + lines.append(f" 状态:{item.get('state') or '-'} | {lack_text} | 规则:{filters} | 下载器:{item.get('downloader') or '默认'}") + lines.append("写入操作需确认:可发“搜索订阅 1”“暂停订阅 1”“恢复订阅 1”“删除订阅 1”。") + return {"success": True, "message": "\n".join(lines), "items": items, "total": total, "status": status_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询订阅失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询订阅失败:{exc}", "items": []} + + def _control_subscribe(self, *, action: str, subscribe_id: int) -> Dict[str, Any]: + if SubscribeOper is None: + return {"success": False, "message": "操作订阅失败:当前环境缺少 MoviePilot 订阅依赖。"} + sid = int(subscribe_id or 0) + if sid <= 0: + return {"success": False, "message": "操作订阅失败:订阅 ID 无效。"} + action_name = str(action or "").strip().lower() + try: + oper = SubscribeOper() + sub = oper.get(sid) + if not sub: + return {"success": False, "message": f"操作订阅失败:订阅 #{sid} 不存在。"} + old_info = sub.to_dict() if hasattr(sub, "to_dict") else {} + if action_name in {"search", "run"}: + if Scheduler is None: + return {"success": False, "message": "搜索订阅失败:当前环境缺少调度器。"} + Scheduler().start(job_id="subscribe_search", **{"sid": sid, "state": None, "manual": True}) + return {"success": True, "message": f"已触发订阅搜索:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + if action_name in {"pause", "stop"}: + updated = oper.update(sid, {"state": "S"}) + label = "暂停" + elif action_name in {"resume", "start"}: + updated = oper.update(sid, {"state": "R"}) + label = "恢复" + elif action_name in {"delete", "remove"}: + sub_name = str(getattr(sub, "name", "") or "") + sub_year = str(getattr(sub, "year", "") or "") + oper.delete(sid) + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeDeleted, {"subscribe_id": sid, "subscribe_info": old_info}) + if SubscribeHelper: + SubscribeHelper().sub_done_async({"tmdbid": getattr(sub, "tmdbid", None), "doubanid": getattr(sub, "doubanid", None)}) + return {"success": True, "message": f"成功删除订阅:#{sid} {sub_name} ({sub_year})", "subscribe_id": sid, "action": action_name} + else: + return {"success": False, "message": f"操作订阅失败:不支持的动作 {action}"} + if eventmanager and EventType: + eventmanager.send_event(EventType.SubscribeModified, { + "subscribe_id": sid, + "old_subscribe_info": old_info, + "subscribe_info": updated.to_dict() if updated and hasattr(updated, "to_dict") else {}, + }) + return {"success": True, "message": f"{label}订阅成功:#{sid} {getattr(sub, 'name', '')}", "subscribe_id": sid, "action": action_name} + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 操作订阅失败:{sid} {exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"操作订阅失败:{exc}", "subscribe_id": sid} + + @staticmethod + def _path_preview(value: Any, max_parts: int = 4) -> str: + text = str(value or "").strip() + if not text: + return "" + normalized = text.replace("\\", "/") + parts = [part for part in normalized.split("/") if part] + if len(parts) <= max_parts: + return normalized + prefix = "/" if normalized.startswith("/") else "" + return f"{prefix}.../" + "/".join(parts[-max_parts:]) + + @staticmethod + def _transfer_status_bool(status: str) -> Optional[bool]: + name = str(status or "all").strip().lower() + if name in {"success", "succeeded", "ok", "true", "成功", "已成功"}: + return True + if name in {"failed", "fail", "error", "false", "失败", "错误"}: + return False + return None + + def _query_download_history( + self, + *, + title: str = "", + hash_value: str = "", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if DownloadHistory is None or DownloadHistoryOper is None: + return {"success": False, "message": "查询下载历史失败:当前环境缺少 MoviePilot 下载历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + title_text = str(title or "").strip() + hash_text = str(hash_value or "").strip() + oper = DownloadHistoryOper() + db = getattr(oper, "_db", None) + if db is None: + records = oper.list_by_page(page=1, count=500) or [] + if title_text: + title_lower = title_text.lower() + records = [ + item for item in records + if title_lower in str(getattr(item, "title", "") or "").lower() + or title_lower in str(getattr(item, "torrent_name", "") or "").lower() + or title_lower in str(getattr(item, "path", "") or "").lower() + ] + if hash_text: + records = [ + item for item in records + if str(getattr(item, "download_hash", "") or "").lower().startswith(hash_text.lower()) + ] + total = len(records) + selected_records = records[(page_num - 1) * page_size:(page_num - 1) * page_size + page_size] + else: + query = db.query(DownloadHistory) + if title_text: + like = f"%{title_text}%" + query = query.filter( + DownloadHistory.title.like(like) + | DownloadHistory.torrent_name.like(like) + | DownloadHistory.path.like(like) + ) + if hash_text: + query = query.filter(DownloadHistory.download_hash.like(f"{hash_text}%")) + query = query.order_by(DownloadHistory.date.desc(), DownloadHistory.id.desc()) + total = query.count() + selected_records = query.offset((page_num - 1) * page_size).limit(page_size).all() + + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=(page_num - 1) * page_size + 1): + task_hash = str(getattr(record, "download_hash", "") or "") + transfer_records = TransferHistory.list_by_hash(download_hash=task_hash) if TransferHistory is not None and task_hash else [] + transfer_success = any(bool(getattr(item, "status", False)) for item in transfer_records or []) + transfer_failed = any(not bool(getattr(item, "status", False)) for item in transfer_records or []) + if transfer_success: + transfer_status = "success" + transfer_status_text = "已入库" + elif transfer_failed: + transfer_status = "failed" + transfer_status_text = "整理失败" + else: + transfer_status = "none" + transfer_status_text = "未见整理记录" + transfer_dest = "" + transfer_error = "" + if transfer_records: + first_transfer = transfer_records[0] + transfer_dest = self._path_preview(getattr(first_transfer, "dest", "")) + transfer_error = str(getattr(first_transfer, "errmsg", "") or "")[:300] + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": str(getattr(record, "type", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash": task_hash, + "download_hash_short": task_hash[:8], + "torrent_name": str(getattr(record, "torrent_name", "") or ""), + "torrent_site": str(getattr(record, "torrent_site", "") or ""), + "username": str(getattr(record, "username", "") or ""), + "channel": str(getattr(record, "channel", "") or ""), + "path_preview": self._path_preview(getattr(record, "path", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + "transfer_status": transfer_status, + "transfer_status_text": transfer_status_text, + "transfer_count": len(transfer_records or []), + "transfer_dest_preview": transfer_dest, + } + if transfer_error and transfer_status == "failed": + item["transfer_error"] = transfer_error + items.append(item) + + title_label = f":{title_text or hash_text}" if title_text or hash_text else "" + if not items: + return { + "success": True, + "message": f"未找到下载历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + } + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"下载历史{title_label}:第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + details = [ + item.get("date") or "-", + f"站点:{item.get('torrent_site') or '-'}", + f"下载器:{item.get('downloader') or '默认'}", + f"Hash:{item.get('download_hash_short') or '-'}", + f"整理:{item.get('transfer_status_text')}", + ] + lines.append(" " + " | ".join(details)) + if item.get("path_preview"): + lines.append(f" 保存:{item.get('path_preview')}") + if item.get("transfer_dest_preview"): + lines.append(f" 入库:{item.get('transfer_dest_preview')}") + if item.get("transfer_error"): + lines.append(f" 整理错误:{item.get('transfer_error')}") + lines.append("说明:这是只读查询,用于追踪下载提交后是否进入整理流程。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询下载历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询下载历史失败:{exc}", "items": []} + + def _query_transfer_history( + self, + *, + title: str = "", + status: str = "all", + limit: int = 10, + page: int = 1, + ) -> Dict[str, Any]: + if TransferHistory is None: + return {"success": False, "message": "查询整理历史失败:当前环境缺少 MoviePilot 整理历史依赖。", "items": []} + try: + page_num = max(1, int(page or 1)) + page_size = max(1, min(50, int(limit or 10))) + status_bool = self._transfer_status_bool(status) + title_text = str(title or "").strip() + search_text = title_text + if title_text and jieba is not None: + try: + search_text = "%".join(jieba.cut(title_text, HMM=False)) + except Exception: + search_text = title_text + + if search_text: + records = TransferHistory.list_by_title(title=search_text, page=1, count=-1, status=None) or [] + if status_bool is not None: + records = [item for item in records if bool(getattr(item, "status", False)) is status_bool] + else: + records = TransferHistory.list_by_page(page=1, count=-1, status=status_bool) or [] + + total = len(records) + start = (page_num - 1) * page_size + selected_records = records[start:start + page_size] + items: List[Dict[str, Any]] = [] + for index, record in enumerate(selected_records, start=start + 1): + media_type = str(getattr(record, "type", "") or "") + if media_type_to_agent is not None: + try: + media_type = media_type_to_agent(media_type) + except Exception: + pass + status_ok = bool(getattr(record, "status", False)) + item = { + "index": index, + "id": getattr(record, "id", None), + "title": str(getattr(record, "title", "") or "未命名媒体"), + "year": str(getattr(record, "year", "") or ""), + "type": media_type, + "category": str(getattr(record, "category", "") or ""), + "season": str(getattr(record, "seasons", "") or ""), + "episode": str(getattr(record, "episodes", "") or ""), + "mode": str(getattr(record, "mode", "") or ""), + "status": "success" if status_ok else "failed", + "status_text": "成功" if status_ok else "失败", + "date": str(getattr(record, "date", "") or ""), + "downloader": str(getattr(record, "downloader", "") or ""), + "download_hash_short": str(getattr(record, "download_hash", "") or "")[:8], + "src_preview": self._path_preview(getattr(record, "src", "")), + "dest_preview": self._path_preview(getattr(record, "dest", "")), + "tmdbid": getattr(record, "tmdbid", None), + "doubanid": str(getattr(record, "doubanid", "") or ""), + } + errmsg = str(getattr(record, "errmsg", "") or "").strip() + if errmsg and not status_ok: + item["errmsg"] = errmsg[:300] + items.append(item) + + status_name = str(status or "all").strip().lower() + status_label = "成功" if status_bool is True else "失败" if status_bool is False else "全部" + title_label = f":{title_text}" if title_text else "" + if not items: + return { + "success": True, + "message": f"未找到{status_label}整理历史{title_label}。", + "items": [], + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + + total_pages = (total + page_size - 1) // page_size if total else 1 + lines = [f"整理历史{title_label}:{status_label},第 {page_num}/{total_pages} 页,共 {total} 条,展示 {len(items)} 条:"] + for item in items: + season_episode = " ".join(value for value in [item.get("season"), item.get("episode")] if value) + label_parts = [ + item.get("status_text") or "-", + item.get("type") or "-", + item.get("mode") or "-", + item.get("date") or "-", + ] + lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) {season_episode}".rstrip()) + lines.append(" " + " | ".join(label_parts)) + if item.get("dest_preview"): + lines.append(f" 目标:{item.get('dest_preview')}") + if item.get("errmsg"): + lines.append(f" 错误:{item.get('errmsg')}") + lines.append("说明:这是只读查询,用于判断下载后是否已经整理入库。") + return { + "success": True, + "message": "\n".join(lines), + "items": items, + "total": total, + "page": page_num, + "limit": page_size, + "status": status_name, + } + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 查询整理历史失败:{exc}\n{traceback.format_exc()}") + return {"success": False, "message": f"查询整理历史失败:{exc}", "items": []} + + def _execute_media_subscribe(self, keyword: str, immediate_search: bool) -> str: + if not all([MetaInfo, SubscribeChain]): + return "订阅失败:当前环境缺少 MoviePilot 订阅依赖。" + meta = MetaInfo(keyword) + try: + sid, message = SubscribeChain().add( + title=keyword, + year=meta.year, + mtype=meta.type, + season=meta.begin_season, + username="agentresourceofficer-feishu", + exist_ok=True, + message=False, + ) + if not sid: + return f"订阅失败:{keyword}\n原因:{message}" + lines = [f"已创建订阅:{keyword}", f"订阅ID:{sid}", f"结果:{message}"] + if immediate_search and Scheduler is not None: + 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"[AgentResourceOfficer][Feishu] 订阅媒体失败:{keyword} {exc}\n{traceback.format_exc()}") + return f"订阅失败:{keyword}\n错误:{exc}" + + @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 _set_search_cache(self, cache_key: str, keyword: str, mediainfo: Any, results: List[Any]) -> None: + with self._search_cache_lock: + now = time.time() + expired_keys = [ + key + for key, item in self._search_cache.items() + if now - float((item or {}).get("ts") or 0) > 1800 + ] + for key in expired_keys: + self._search_cache.pop(key, None) + while len(self._search_cache) >= self._search_cache_limit: + oldest_key = min( + self._search_cache, + key=lambda key: float((self._search_cache.get(key) or {}).get("ts") or 0), + ) + self._search_cache.pop(oldest_key, None) + self._search_cache[cache_key] = { + "ts": now, + "keyword": keyword, + "mediainfo": mediainfo, + "results": list(results or []), + } + + 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 _run_p115_manual_transfer_batch(self, paths: List[str], chat_id: str, open_id: str) -> None: + summaries = [self._execute_p115_manual_transfer(path) for path in paths] + self.reply_text(chat_id, open_id, "\n\n".join(item for item in summaries if item)) + + def _run_p115_manual_transfer(self, path: str, chat_id: str, open_id: str) -> None: + self.reply_text(chat_id, open_id, self._execute_p115_manual_transfer(path)) + + def _get_p115_manual_transfer_paths(self) -> List[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + raw = str(config.get("pan_transfer_paths") or "").strip() + return [line.strip() for line in raw.splitlines() if line.strip()] + except Exception as exc: + logger.warning(f"[AgentResourceOfficer][Feishu] 获取待整理目录失败:{exc}") + return [] + + 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 未初始化或未启用。" + result = servicer.monitorlife.once_transfer(path) + summary = self._format_p115_manual_transfer_result(result) + return summary or self._build_p115_manual_transfer_summary(log_path, log_offset, path) or f"刮削完成:{path}" + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] 手动刮削失败:{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 "" + failed_items = result.get("failed_items") or [] + lines = [ + f"刮削完成:{path}", + f"总计:{result.get('total', 0)} 个项目(文件 {result.get('files', 0)},文件夹 {result.get('dirs', 0)})", + f"成功:{result.get('success', 0)} 个", + f"失败:{result.get('failed', 0)} 个", + f"跳过:{result.get('skipped', 0)} 个", + ] + if result.get("error"): + lines.append(f"错误:{result.get('error')}") + if failed_items: + lines.append("失败示例:") + lines.extend(f"- {item}" for item in failed_items[:3]) + if len(failed_items) > 3: + lines.append(f"- 还有 {len(failed_items) - 3} 项未展示") + lines.extend(self._p115_strm_followup_lines(path)) + return "\n".join(lines) + + def _p115_strm_followup_lines(self, path: str) -> List[str]: + hint = self._get_p115_strm_hint_path() or path + return [ + "如需增量生成 STRM,请再发送:生成STRM", + "如需按全部媒体库全量生成,请再发送:全量STRM", + f"如需指定路径全量生成,请再发送:指定路径STRM {hint}", + ] + + def _get_p115_strm_hint_path(self) -> Optional[str]: + try: + config = self.plugin.systemconfig.get("plugin.P115StrmHelper") or {} + paths = str(config.get("full_sync_strm_paths") or "").strip() + first_line = next((line.strip() for line in paths.splitlines() if line.strip()), "") + if not first_line: + return None + parts = first_line.split("#") + return parts[1].strip() if len(parts) >= 2 and parts[1].strip() else None + except Exception: + return None + + @staticmethod + def _safe_log_offset(log_path: Path) -> int: + try: + return log_path.stat().st_size if log_path.exists() else 0 + except Exception: + 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) + 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 = pattern.search(chunk) + if not match: + return None + summary = ( + f"刮削完成:{path}\n" + f"总计:{match.group('total')} 个项目(文件 {match.group('files')},文件夹 {match.group('dirs')})\n" + f"成功:{match.group('success')} 个\n" + f"失败:{match.group('failed')} 个\n" + f"跳过:{match.group('skipped')} 个" + ) + return summary + "\n" + "\n".join(self._p115_strm_followup_lines(path)) + except Exception: + return None + + def _submit_p115_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if PluginManager is not None: + try: + if not PluginManager().running_plugins.get("P115StrmHelper"): + self.reply_text(chat_id, open_id, "P115StrmHelper 未加载或未启用,无法执行 STRM 命令。") + return + except Exception: + pass + self._submit_moviepilot_command(command_text, chat_id, open_id) + + def _submit_moviepilot_command(self, command_text: str, chat_id: str, open_id: str) -> None: + if eventmanager is None or EventType is None: + self.reply_text(chat_id, open_id, "当前环境缺少 MoviePilot 事件总线,无法转发该命令。") + return + eventmanager.send_event( + EventType.CommandExcute, + {"cmd": command_text, "source": None, "user": open_id or chat_id or "feishu"}, + ) + self.reply_text(chat_id, open_id, f"已接收命令:{command_text}\n任务已提交给 MoviePilot。") + + 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.plugin._extract_first_url(text) + if first_url and (self.plugin._is_115_url(first_url) or self.plugin._is_quark_url(first_url)): + return f"/smart_entry {text}".strip() + + alias_map = self.parse_alias_text(self.command_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_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) + + @staticmethod + def _is_duplicate_event_cross_instance(event_id: str, now: float) -> bool: + try: + _EVENT_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _EVENT_CACHE_FILE.touch(exist_ok=True) + with _EVENT_CACHE_FILE.open("r+", 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"[AgentResourceOfficer][Feishu] 跨实例事件去重失败:{exc}") + return False + + def _is_allowed(self, chat_id: str, user_open_id: str) -> bool: + return bool( + self.allow_all + or (chat_id and chat_id in self.allowed_chat_ids) + or (user_open_id and user_open_id in self.allowed_user_ids) + ) + + @staticmethod + def _extract_text(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) + return re.sub(r"\s+", " ", text).strip() + + @staticmethod + def _is_help_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"帮助", "/help", "help"} + + @staticmethod + def _is_menu_request(text: str) -> bool: + return FeishuChannel._sanitize_text(text) in {"菜单", "/menu", "menu", "面板", "控制面板"} + + def _build_help_text(self) -> str: + aliases = self.parse_alias_text(self.command_aliases) + alias_text = "\n".join(f"{key} -> {value}" for key, value in aliases.items()) or "未配置别名" + return ( + "可用命令:\n" + f"{', '.join(self.command_whitelist)}\n\n" + "别名:\n" + f"{alias_text}\n\n" + "快捷入口:发送“菜单”可查看可复制的快捷命令。" + ) + + @staticmethod + def _build_menu_text() -> str: + return ( + "快捷菜单\n" + "1. 云盘搜索 片名\n" + "2. 盘搜搜索 片名\n" + "3. 影巢搜索 片名\n" + "4. MP搜索 片名 / PT搜索 片名\n" + "5. 转存 片名(默认 115)\n" + "6. 夸克转存 片名\n" + "7. 下载 片名\n" + "8. 更新检查 片名\n" + "9. 选择 序号 / 详情 序号 / n\n" + "10. 115登录 / 115状态 / 115任务\n" + "11. 影巢签到 / 影巢签到日志" + ) + + @staticmethod + def _cache_key(chat_id: str, open_id: str) -> str: + return f"feishu::{chat_id or ''}::{open_id or ''}" + + @staticmethod + def _brief_response_error(data: Any) -> str: + if not isinstance(data, dict): + return "body=" + code = str(data.get("code") or "").strip() + msg = str(data.get("msg") or data.get("message") or "").strip() + parts: List[str] = [] + if code: + parts.append(f"code={code}") + if msg: + parts.append(f"msg={msg}") + return " ".join(parts) if parts else "body=" + + def reply_text(self, chat_id: str, open_id: str, text: str) -> None: + if not self.reply_enabled or not self.app_id or not self.app_secret: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else open_id + if not receive_id: + return + access_token = self._get_tenant_access_token() + if not access_token or RequestUtils is None: + return + url = f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={self.reply_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), + } + response = RequestUtils(headers=headers).post(url=url, json=payload) + if response is None: + logger.error("[AgentResourceOfficer][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"[AgentResourceOfficer][Feishu] 发送文本失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def reply_qrcode_data_url(self, chat_id: str, 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"[AgentResourceOfficer][Feishu] 解码二维码失败:{exc}") + return + image_key = self._upload_image(image_bytes=image_bytes, file_name="p115-qrcode.png") + if image_key: + self._reply_image(chat_id, open_id, image_key) + + def _upload_image(self, image_bytes: bytes, file_name: str) -> Optional[str]: + if not image_bytes or RequestUtils is None: + return None + access_token = self._get_tenant_access_token() + if not access_token: + return None + response = RequestUtils(headers={"Authorization": f"Bearer {access_token}"}).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("[AgentResourceOfficer][Feishu] 上传图片失败:无响应") + 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"[AgentResourceOfficer][Feishu] 上传图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + return None + return str(((data.get("data") or {}).get("image_key")) or "").strip() or None + + def _reply_image(self, chat_id: str, open_id: str, image_key: str) -> None: + if not image_key or RequestUtils is None: + return + receive_id = chat_id if self.reply_receive_id_type == "chat_id" else 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={self.reply_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("[AgentResourceOfficer][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"[AgentResourceOfficer][Feishu] 发送图片失败: status={response.status_code} " + f"{self._brief_response_error(data)}" + ) + + def _get_tenant_access_token(self) -> Optional[str]: + if RequestUtils is None: + return None + 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("[AgentResourceOfficer][Feishu] 获取 tenant_access_token 失败:无响应") + return None + try: + data = response.json() + except Exception as exc: + logger.error(f"[AgentResourceOfficer][Feishu] token 响应解析失败:{exc}") + return None + token = data.get("tenant_access_token") + expire = int(data.get("expire") or 0) + if not token: + logger.error( + f"[AgentResourceOfficer][Feishu] token 缺失:{self._brief_response_error(data)}" + ) + return None + self._token_cache = {"token": token, "expires_at": now + expire} + return token diff --git a/plugins/agentresourceofficer/requirements.txt b/plugins/agentresourceofficer/requirements.txt new file mode 100644 index 0000000..e892782 --- /dev/null +++ b/plugins/agentresourceofficer/requirements.txt @@ -0,0 +1,3 @@ +requests +cloudscraper +lark-oapi==1.5.3 diff --git a/plugins/agentresourceofficer/schemas.py b/plugins/agentresourceofficer/schemas.py new file mode 100644 index 0000000..ace4d1b --- /dev/null +++ b/plugins/agentresourceofficer/schemas.py @@ -0,0 +1,259 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class HDHiveSearchSessionToolInput(BaseModel): + keyword: str = Field(..., description="要搜索的影片或剧集名称") + media_type: str = Field(default="auto", description="媒体类型,auto / movie / tv;不确定时用 auto") + year: Optional[str] = Field(default=None, description="可选年份,用于缩小候选范围") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用默认目录") + + +class HDHiveSessionPickToolInput(BaseModel): + session_id: str = Field(..., description="上一步搜索返回的会话 ID") + choice: int = Field(default=0, description="当前阶段要选择的编号,从 1 开始;详情或翻页时可为 0") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则使用会话中的目录") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + + +class ShareRouteToolInput(BaseModel): + url: str = Field(..., description="115 或夸克分享链接") + path: Optional[str] = Field(default=None, description="目标目录") + access_code: Optional[str] = Field(default=None, description="提取码,可选") + + +class AssistantRouteToolInput(BaseModel): + text: Optional[str] = Field(default=None, description="统一智能入口文本,例如 盘搜搜索 片名、影巢搜索 片名、115登录 或直接粘贴 115/夸克分享链接") + session: Optional[str] = Field(default="default", description="会话标识,用于关联后续选择、115 待任务与扫码续跑") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,适合外部智能体按 sessions 列表中的精确会话继续使用") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则按当前模式使用默认目录") + mode: Optional[str] = Field(default=None, description="结构化模式:mp / pansou / hdhive") + keyword: Optional[str] = Field(default=None, description="结构化搜索关键词") + url: Optional[str] = Field(default=None, description="结构化分享链接,支持 115 / 夸克") + access_code: Optional[str] = Field(default=None, description="结构化提取码") + media_type: Optional[str] = Field(default=None, description="结构化媒体类型:auto / movie / tv") + year: Optional[str] = Field(default=None, description="结构化年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + action: Optional[str] = Field(default=None, description="结构化动作:p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel / assistant_help") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPickToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识,需与上一步统一智能入口保持一致") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: int = Field(default=0, description="选择的编号,从 1 开始;详情或翻页时可为 0") + action: Optional[str] = Field(default=None, description="可选动作:detail/details/review/详情/审查 或 next/n/下一页") + mode: Optional[str] = Field(default=None, description="推荐列表后续搜索方式:mp / hdhive / pansou") + path: Optional[str] = Field(default=None, description="可选目标目录,不填则沿用会话目录") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantHelpToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="可选会话标识;如该会话存在待继续的 115 任务,帮助里会附带任务摘要") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantSessionStateToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话当前状态") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantSessionClearToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则清理 default 会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + + +class AssistantCapabilitiesToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantReadinessToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class FeishuChannelHealthToolInput(BaseModel): + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPulseToolInput(BaseModel): + pass + + +class AssistantStartupToolInput(BaseModel): + pass + + +class AssistantMaintainToolInput(BaseModel): + execute: Optional[bool] = Field(default=False, description="是否立即执行低风险维护;默认只返回建议") + limit: Optional[int] = Field(default=100, description="单次最多清理多少条") + + +class AssistantToolboxToolInput(BaseModel): + pass + + +class AssistantRequestTemplatesToolInput(BaseModel): + limit: Optional[int] = Field(default=100, description="模板中批量类请求默认 limit,范围由插件限制") + names: Optional[str] = Field(default=None, description="可选模板名,多个用逗号或空格分隔,例如 maintain_execute,workflow_dry_run") + recipe: Optional[str] = Field(default=None, description="可选推荐流程名或别名,例如 plan / maintain / continue / bootstrap") + include_templates: Optional[bool] = Field(default=True, description="是否返回完整模板内容;关闭时只返回名称、无效项和执行策略") + + +class AssistantSelfcheckToolInput(BaseModel): + pass + + +class AssistantHistoryToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近执行记录") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条执行记录") + + +class AssistantExecuteActionToolInput(BaseModel): + name: str = Field(..., description="要执行的动作模板名,例如 pick_pansou_result / candidate_next_page / resume_pending_115") + session: Optional[str] = Field(default="default", description="可选会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + choice: Optional[int] = Field(default=None, description="需要选择编号时传入") + path: Optional[str] = Field(default=None, description="可选目标目录") + keyword: Optional[str] = Field(default=None, description="搜索类动作使用的关键词") + media_type: Optional[str] = Field(default=None, description="搜索类动作使用的媒体类型") + year: Optional[str] = Field(default=None, description="搜索类动作使用的年份") + url: Optional[str] = Field(default=None, description="直链类动作使用的分享链接") + access_code: Optional[str] = Field(default=None, description="可选提取码") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + kind: Optional[str] = Field(default=None, description="批量清理会话时的类型过滤") + has_pending_p115: Optional[bool] = Field(default=None, description="批量清理会话时是否仅清理带待继续 115 的会话") + stale_only: Optional[bool] = Field(default=False, description="批量清理会话时是否只清理过期会话") + all_sessions: Optional[bool] = Field(default=False, description="批量清理会话时是否清理全部会话") + limit: Optional[int] = Field(default=100, description="批量清理会话时的最多处理条数") + plan_id: Optional[str] = Field(default=None, description="计划动作使用的 plan_id") + prefer_unexecuted: Optional[bool] = Field(default=True, description="计划动作未指定 plan_id 时是否优先选择未执行计划") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecuteActionsToolInput(BaseModel): + actions: List[Dict[str, Any]] = Field(..., description="动作模板执行数组,每项可直接复用 action_templates 里的 action_body") + session: Optional[str] = Field(default="default", description="批量动作默认会话名;子动作未显式传 session/session_id 时自动继承") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否立即停止后续执行") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带每一步原始返回;默认关闭以减少 token 与负载") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantWorkflowToolInput(BaseModel): + name: str = Field(..., description="预设工作流名,例如 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / mp_search / mp_search_download / mp_subscribe / mp_recommend / mp_recommend_search / share_transfer / p115_status") + session: Optional[str] = Field(default="default", description="工作流会话名") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + keyword: Optional[str] = Field(default=None, description="搜索关键词") + choice: Optional[int] = Field(default=None, description="通用选择编号,盘搜转存默认使用 1") + candidate_choice: Optional[int] = Field(default=None, description="影巢候选影片编号") + resource_choice: Optional[int] = Field(default=None, description="影巢资源编号") + path: Optional[str] = Field(default=None, description="可选目标目录") + url: Optional[str] = Field(default=None, description="分享链接") + access_code: Optional[str] = Field(default=None, description="提取码") + media_type: Optional[str] = Field(default=None, description="媒体类型,auto / movie / tv") + mode: Optional[str] = Field(default=None, description="推荐后续搜索方式,mp / hdhive / pansou") + year: Optional[str] = Field(default=None, description="年份") + client_type: Optional[str] = Field(default=None, description="115 扫码客户端类型") + source: Optional[str] = Field(default=None, description="MP 推荐来源,例如 tmdb_trending / douban_movie_hot / bangumi_calendar") + limit: Optional[int] = Field(default=20, description="推荐数量上限") + dry_run: Optional[bool] = Field(default=False, description="只生成工作流计划,不实际执行") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPreferencesToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="偏好画像会话名;建议外部智能体固定传自己的用户会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + user_key: Optional[str] = Field(default=None, description="可选用户键;用于跨 session 共享同一套偏好") + preferences: Optional[Dict[str, Any]] = Field(default=None, description="要保存的偏好画像;不传则只读取") + reset: Optional[bool] = Field(default=False, description="是否重置偏好画像") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantExecutePlanToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选 dry_run 返回的 plan_id;不传时可按 session/session_id 自动选择最近计划") + session: Optional[str] = Field(default=None, description="可选会话名;未传 plan_id 时可按会话自动选择最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + prefer_unexecuted: Optional[bool] = Field(default=True, description="自动选计划时是否优先只选未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="遇到失败动作时是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + + +class AssistantPlansToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不填则返回全部最近计划") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只看已执行,false 只看未执行") + include_actions: Optional[bool] = Field(default=False, description="是否附带计划动作明细;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条计划") + + +class AssistantPlansClearToolInput(BaseModel): + plan_id: Optional[str] = Field(default=None, description="可选计划 ID;传入时只清理这一条") + session: Optional[str] = Field(default=None, description="可选会话名;按会话清理") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + executed: Optional[bool] = Field(default=None, description="可选过滤:true 只清理已执行,false 只清理未执行") + all_plans: Optional[bool] = Field(default=False, description="清理全部计划;未指定 plan_id/session/session_id/executed 时需要显式打开") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class AssistantRecoverToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;不传则自动从全局活跃会话和待执行计划里挑选最佳恢复项") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID,优先于 session") + execute: Optional[bool] = Field(default=False, description="是否直接执行推荐恢复动作;默认只返回恢复建议") + prefer_unexecuted: Optional[bool] = Field(default=True, description="执行保存计划时是否优先选择未执行计划") + stop_on_error: Optional[bool] = Field(default=True, description="执行恢复动作时遇到失败是否停止") + include_raw_results: Optional[bool] = Field(default=False, description="是否附带原始执行结果;默认关闭以减少 token") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启,只返回恢复所需关键字段") + limit: Optional[int] = Field(default=20, description="全局恢复扫描时最多查看多少个会话") + + +class AssistantSessionsToolInput(BaseModel): + kind: Optional[str] = Field(default=None, description="按会话类型过滤,例如 assistant_pansou / assistant_hdhive / assistant_p115_login") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只看带待继续 115 任务的会话") + compact: Optional[bool] = Field(default=True, description="是否使用低 token 回执;默认开启") + limit: Optional[int] = Field(default=20, description="最多返回多少条活跃会话摘要") + + +class AssistantSessionsClearToolInput(BaseModel): + session: Optional[str] = Field(default=None, description="可选会话名;只清理这一个会话") + session_id: Optional[str] = Field(default=None, description="可选 assistant:: 会话 ID;只清理这一个会话") + kind: Optional[str] = Field(default=None, description="按会话类型批量清理") + has_pending_p115: Optional[bool] = Field(default=None, description="是否只清理带待继续 115 任务的会话") + stale_only: Optional[bool] = Field(default=False, description="只清理已过期但仍残留的 assistant 会话") + all_sessions: Optional[bool] = Field(default=False, description="清理全部 assistant 会话;用于重置外部智能体状态") + limit: Optional[int] = Field(default=100, description="批量清理时最多清理多少条") + + +class P115QRCodeStartToolInput(BaseModel): + client_type: Optional[str] = Field(default="alipaymini", description="115 扫码客户端类型,默认 alipaymini") + + +class P115QRCodeCheckToolInput(BaseModel): + uid: str = Field(..., description="上一步二维码返回的 uid") + time: str = Field(..., description="上一步二维码返回的 time") + sign: str = Field(..., description="上一步二维码返回的 sign") + client_type: Optional[str] = Field(default="alipaymini", description="客户端类型,需与生成二维码时保持一致") + + +class P115StatusToolInput(BaseModel): + pass + + +class P115PendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则查看 default 会话") + + +class P115ResumePendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则继续 default 会话的待处理 115 任务") + + +class P115CancelPendingToolInput(BaseModel): + session: Optional[str] = Field(default="default", description="会话标识;不填则取消 default 会话的待处理 115 任务") diff --git a/plugins/agentresourceofficer/services/__init__.py b/plugins/agentresourceofficer/services/__init__.py new file mode 100644 index 0000000..4c1538f --- /dev/null +++ b/plugins/agentresourceofficer/services/__init__.py @@ -0,0 +1 @@ +"""Service modules for Agent影视助手.""" diff --git a/plugins/agentresourceofficer/services/hdhive_openapi.py b/plugins/agentresourceofficer/services/hdhive_openapi.py new file mode 100644 index 0000000..970c5ff --- /dev/null +++ b/plugins/agentresourceofficer/services/hdhive_openapi.py @@ -0,0 +1,1113 @@ +from datetime import datetime +import base64 +import json +import re +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import quote +from zoneinfo import ZoneInfo + +import requests + +try: + from app.chain.media import MediaChain +except Exception: + MediaChain = None + +try: + from app.core.config import settings +except Exception: + settings = None + + +class HDHiveOpenApiService: + """Reusable HDHive execution layer for Agent影视助手.""" + + _signin_action_name = "checkIn" + _signin_router_tree = ["", {"children": ["(app)", {"children": ["__PAGE__", {}, None, None]}, None, None]}, None, None, True] + _login_api_candidates = [ + "/api/customer/user/login", + "/api/customer/auth/login", + ] + _login_page = "/login" + _login_action_router_state = '%5B%22%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22login%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Flogin%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D%7D%2Cnull%2Cnull%2Ctrue%5D' + _login_action_fallback = "602b5a3af7ab2e93be6a14001ca83c1be491ccecea" + + def __init__( + self, + *, + api_key: str = "", + base_url: str = "https://hdhive.com", + timeout: int = 30, + ) -> None: + self.api_key = self.normalize_text(api_key) + self.base_url = (self.normalize_text(base_url) or "https://hdhive.com").rstrip("/") + self.timeout = self.safe_int(timeout, 30) + self._login_action_id = "" + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_slug(value: Any) -> str: + return str(value or "").strip().replace("-", "") + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def media_type_text(value: Any) -> str: + if value is None: + return "" + raw = str(getattr(value, "value", value)).strip().lower() + mapping = { + "电影": "movie", + "movie": "movie", + "电视剧": "tv", + "tv": "tv", + } + return mapping.get(raw, raw) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def base_headers(self) -> Dict[str, str]: + return { + "X-API-Key": self.api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + def api_url(self, path: str) -> str: + return f"{self.base_url.rstrip('/')}{path}" + + def tmdb_web_search_url(self, media_type: str, keyword: str) -> str: + query = quote(keyword) + if media_type == "movie": + return f"https://www.themoviedb.org/search/movie?query={query}" + if media_type == "tv": + return f"https://www.themoviedb.org/search/tv?query={query}" + return f"https://www.themoviedb.org/search?query={query}" + + def tmdb_web_search_headers(self) -> Dict[str, str]: + return { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + } + + @staticmethod + def extract_year_from_release(value: Any) -> str: + match = re.search(r"(19|20)\d{2}", str(value or "")) + return match.group(0) if match else "" + + def tmdb_web_search_candidates( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[List[Dict[str, Any]], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + search_order = [media_type] if media_type in {"movie", "tv"} else ["tv", "movie"] + pattern = re.compile( + r'href="/(?Ptv|movie)/(?P\d+)"[^>]*>\s*' + r']*>\s*' + r'(?P<title>[^]*srcset="(?P[^"]*)"[^>]*src="(?P[^"]+)"[^>]*>' + r'.*?(?P[^<]+)', + re.S, + ) + candidates: List[Dict[str, Any]] = [] + seen_ids: set[str] = set() + errors: List[str] = [] + for search_type in search_order: + try: + response = requests.get( + self.tmdb_web_search_url(search_type, keyword), + headers=self.tmdb_web_search_headers(), + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + response.raise_for_status() + except Exception as exc: + errors.append(f"{search_type}:{exc}") + continue + html = response.text or "" + for match in pattern.finditer(html): + item_type = self.normalize_text(match.group("media_type")).lower() + tmdb_id = self.normalize_text(match.group("tmdb_id")) + if not tmdb_id or tmdb_id in seen_ids: + continue + item_year = self.extract_year_from_release(match.group("release")) + if year and item_year and item_year != year: + continue + seen_ids.add(tmdb_id) + candidates.append( + { + "title": self.normalize_text(match.group("title")), + "year": item_year, + "media_type": item_type or search_type, + "tmdb_id": tmdb_id, + "poster_path": self.normalize_text(match.group("src")), + } + ) + if len(candidates) >= candidate_limit: + return candidates, "" + return candidates, ";".join(errors) + + def request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any], str, int]: + if not self.api_key: + return False, {}, "未配置影巢 API Key", 400 + + try: + response = requests.request( + method=method.upper(), + url=self.api_url(path), + headers=self.base_headers(), + params=params, + json=payload if payload is not None else None, + timeout=timeout or self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + return False, {}, f"请求异常: {exc}", 0 + + try: + result = response.json() + except Exception: + result = { + "success": False, + "message": response.text[:300] if response.text else f"HTTP {response.status_code}", + "description": "接口未返回有效 JSON", + } + + if response.ok and isinstance(result, dict) and result.get("success", True): + return True, result, "", response.status_code + + message = "" + if isinstance(result, dict): + message = ( + result.get("description") + or result.get("message") + or result.get("code") + or f"HTTP {response.status_code}" + ) + if not message: + message = f"HTTP {response.status_code}" + return False, result if isinstance(result, dict) else {}, message, response.status_code + + def resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: + pan = str(item.get("pan_type") or "").lower() + points = item.get("unlock_points") + try: + points_value = int(points) if points is not None and str(points) != "" else 0 + except Exception: + points_value = 9999 + validate = str(item.get("validate_status") or "").lower() + resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] + sources = [str(v) for v in (item.get("source") or [])] + pan_rank = 0 if pan == "115" else 1 if pan == "quark" else 2 + points_rank = 0 if points_value <= 0 else 1 + validate_rank = 0 if validate in {"valid", ""} else 1 + resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 + source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 + return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) + + async def resolve_candidates_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + ) -> Tuple[bool, Dict[str, Any], str]: + keyword = self.normalize_text(keyword) + media_type = self.normalize_text(media_type).lower() or "auto" + type_filter = "" if media_type in {"auto", "all", "*"} else media_type + year = self.normalize_text(year) + candidate_limit = min(50, max(1, self.safe_int(candidate_limit, 10))) + + if not keyword: + return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" + if type_filter and type_filter not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie、tv 或 auto", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie、tv 或 auto" + chain_error = "" + medias = [] + if MediaChain is None: + chain_error = "MoviePilot MediaChain 不可用" + else: + try: + _, medias = await MediaChain().async_search(title=keyword) + except Exception as exc: + chain_error = f"TMDB 解析失败: {exc}" + try: + medias = list(medias or []) + except Exception: + medias = [] + + candidates: List[Dict[str, Any]] = [] + for media in medias: + item_type = self.media_type_text(getattr(media, "type", "")) + item_year = self.normalize_text(getattr(media, "year", "")) + if type_filter and item_type and item_type != type_filter: + continue + if year and item_year and item_year != year: + continue + tmdb_id = getattr(media, "tmdb_id", None) + if not tmdb_id: + continue + candidates.append( + { + "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", + "year": item_year, + "media_type": item_type or type_filter or "movie", + "tmdb_id": tmdb_id, + "poster_path": getattr(media, "poster_path", "") or "", + } + ) + if len(candidates) >= candidate_limit: + break + + fallback_used = False + fallback_message = "" + if not candidates: + web_candidates, web_error = self.tmdb_web_search_candidates( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if web_candidates: + candidates = web_candidates + fallback_used = True + else: + fallback_message = web_error + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(candidates), + "status_code": 200 if candidates else 404, + "message": "success" if candidates else "未找到可用于影巢搜索的 TMDB 候选", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "meta": { + "total": len(candidates), + "candidate_source": "tmdb_web_search" if fallback_used else "mediainfo_chain", + }, + } + if fallback_used: + result["fallback_reason"] = chain_error or "MediaChain 未返回候选" + elif chain_error: + result["chain_warning"] = chain_error + if not candidates and fallback_message: + result["fallback_error"] = fallback_message + if chain_error: + result["message"] = f"{chain_error};TMDB 网页搜索兜底也未命中" + elif not candidates and chain_error: + result["message"] = chain_error + return bool(candidates), result, result["message"] + + def search_resources(self, media_type: str, tmdb_id: str) -> Tuple[bool, Dict[str, Any], str]: + media_type = (media_type or "").strip().lower() + tmdb_id = self.normalize_text(tmdb_id) + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" + if not tmdb_id: + return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" + + ok, payload, message, status_code = self.request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"media_type": media_type, "tmdb_id": tmdb_id}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + return ok, result, message + + async def search_resources_by_keyword( + self, + keyword: str, + media_type: str = "auto", + year: str = "", + candidate_limit: int = 10, + result_limit: int = 12, + ) -> Tuple[bool, Dict[str, Any], str]: + result_limit = min(50, max(1, self.safe_int(result_limit, 12))) + ok, candidate_result, candidate_message = await self.resolve_candidates_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=candidate_limit, + ) + if not ok: + result = dict(candidate_result) + result["data"] = [] + return False, result, candidate_message + candidates = candidate_result.get("candidates") or [] + + merged_items: List[Dict[str, Any]] = [] + seen_slugs: set[str] = set() + last_status = 200 + + for candidate in candidates: + ok, payload, message = self.search_resources( + media_type=candidate["media_type"] or media_type, + tmdb_id=str(candidate["tmdb_id"]), + ) + last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status + if not ok: + continue + for resource in payload.get("data") or []: + slug = self.normalize_slug(resource.get("slug")) + if not slug or slug in seen_slugs: + continue + seen_slugs.add(slug) + annotated = dict(resource) + annotated["matched_tmdb_id"] = candidate["tmdb_id"] + annotated["matched_title"] = candidate["title"] + annotated["matched_year"] = candidate["year"] + merged_items.append(annotated) + + merged_items.sort(key=self.resource_sort_key) + merged_items = merged_items[:result_limit] + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(merged_items), + "status_code": last_status, + "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "data": merged_items, + "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, + } + return bool(merged_items), result, result["message"] + + def unlock_resource(self, slug: str) -> Tuple[bool, Dict[str, Any], str]: + slug = self.normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self.request( + "POST", + "/api/open/resources/unlock", + payload={"slug": slug}, + ) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_me(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/me") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_usage_today(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/usage/today") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def fetch_weekly_free_quota(self) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self.request("GET", "/api/open/vip/weekly-free-quota") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + return ok, result, message + + def perform_checkin( + self, + *, + is_gambler: Optional[bool] = None, + trigger: str = "手动", + ) -> Tuple[bool, Dict[str, Any], str]: + gambler_mode = bool(is_gambler) + payload = {"is_gambler": True} if gambler_mode else None + ok, result_payload, message, status_code = self.request("POST", "/api/open/checkin", payload=payload) + data = result_payload.get("data") if isinstance(result_payload, dict) else {} + checked_in = bool((data or {}).get("checked_in")) if ok else False + if ok: + status_text = "签到成功" if checked_in else "今日已签到" + else: + status_text = "签到失败" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "trigger": trigger, + "is_gambler": gambler_mode, + "status": status_text, + "message": (data or {}).get("message") or result_payload.get("message") or message, + "data": data or {}, + } + return ok, result, message + + @staticmethod + def parse_cookie_string(cookie_str: Optional[str]) -> Dict[str, str]: + cookies: Dict[str, str] = {} + if not cookie_str: + return cookies + for cookie_item in str(cookie_str).split(";"): + if "=" in cookie_item: + name, value = cookie_item.strip().split("=", 1) + cookies[name] = value + return cookies + + @staticmethod + def _decode_token_user_id(token: str) -> str: + if not token or "." not in token: + return "" + try: + payload = token.split(".", 2)[1] + padding = "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8", "ignore") + data = json.loads(decoded) + return str(data.get("user_id") or data.get("sub") or data.get("id") or "").strip() + except Exception: + return "" + + @staticmethod + def _cookie_string_from_mapping(cookies: Dict[str, str]) -> str: + token_cookie = str((cookies or {}).get("token") or "").strip() + csrf_cookie = str((cookies or {}).get("csrf_access_token") or "").strip() + if not token_cookie: + return "" + cookie_items = [f"token={token_cookie}"] + if csrf_cookie: + cookie_items.append(f"csrf_access_token={csrf_cookie}") + return "; ".join(cookie_items) + + @classmethod + def _extract_login_action_id_from_text(cls, text: str) -> str: + patterns = [ + r'next-action"\s*:\s*"([a-fA-F0-9]{16,64})"', + r'name="next-action"\s+value="([a-fA-F0-9]{16,64})"', + r'createServerReference\("([a-f0-9]{40,})"[^\\n]+?"login"\)', + ] + for pattern in patterns: + match = re.search(pattern, text or "") + if match: + return str(match.group(1) or "").strip() + return "" + + def _discover_login_action_id(self, warm_text: str, scraper: Any) -> str: + if self._login_action_id: + return self._login_action_id + + action_id = self._extract_login_action_id_from_text(warm_text) + if action_id: + self._login_action_id = action_id + return action_id + + script_paths = re.findall( + r']+src="([^"]+/app/\(auth\)/login/page-[^"]+\.js)"', + warm_text or "", + ) + for script_path in script_paths: + script_url = script_path if script_path.startswith("http") else f"{self.base_url}{script_path}" + try: + resp = scraper.get( + script_url, + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Referer": f"{self.base_url}{self._login_page}", + "Accept": "*/*", + }, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + action_id = self._extract_login_action_id_from_text(getattr(resp, "text", "") or "") + if action_id: + self._login_action_id = action_id + return action_id + + self._login_action_id = self._login_action_fallback + return self._login_action_id + + @staticmethod + def _parse_server_action_error(response_text: str) -> str: + if not response_text: + return "" + try: + for line in response_text.splitlines(): + line = line.strip() + if not line.startswith("1:"): + continue + payload = json.loads(line[2:]) + error = payload.get("error") or {} + message = str(error.get("message") or "").strip() + description = str(error.get("description") or "").strip() + if message or description: + return f"{message} ({description})" if description and description != message else (message or description) + except Exception: + return "" + return "" + + def login_for_cookie(self, *, username: str, password: str) -> Tuple[bool, str, str]: + username = self.normalize_text(username) + password = self.normalize_text(password) + if not username or not password: + return False, "", "未配置影巢用户名或密码,无法自动刷新 Cookie" + + try: + import cloudscraper + scraper = cloudscraper.create_scraper() + except Exception: + scraper = requests + + login_url = f"{self.base_url}{self._login_page}" + warm_text = "" + try: + resp_warm = scraper.get( + login_url, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + warm_text = getattr(resp_warm, "text", "") or "" + except Exception: + pass + if "系统维护中" in warm_text or "maintenance" in warm_text.lower(): + return False, "", "影巢站点当前处于维护页,暂时无法自动登录刷新 Cookie" + + for path in self._login_api_candidates: + url = f"{self.base_url}{path}" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "application/json", + } + payload = {"username": username, "password": password} + try: + resp = scraper.post( + url, + headers=headers, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception: + continue + + cookies_dict: Dict[str, str] = {} + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "API 登录成功" + + try: + data = resp.json() + except Exception: + data = {} + meta = (data.get("meta") or {}) if isinstance(data, dict) else {} + access_token = str(meta.get("access_token") or "").strip() + refresh_token = str(meta.get("refresh_token") or "").strip() + if access_token: + cookie_items = [f"token={access_token}"] + if refresh_token: + cookie_items.append(f"refresh_token={refresh_token}") + return True, "; ".join(cookie_items), "API 登录成功" + + action_id = self._discover_login_action_id(warm_text, scraper) + if action_id: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Origin": self.base_url, + "Referer": login_url, + "Content-Type": "text/plain;charset=UTF-8", + "next-action": action_id, + "next-router-state-tree": self._login_action_router_state, + } + body = json.dumps([{"username": username, "password": password}, "/"], separators=(",", ":")) + try: + resp = scraper.post( + login_url, + headers=headers, + data=body, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + ) + except Exception as exc: + resp = None + server_action_message = f"Server Action 登录请求异常: {exc}" + else: + server_action_message = "" + if resp is not None: + try: + cookies_dict = getattr(resp, "cookies", None).get_dict() if getattr(resp, "cookies", None) else {} + except Exception: + cookies_dict = {} + cookie_string = self._cookie_string_from_mapping(cookies_dict) + if cookie_string: + return True, cookie_string, "Server Action 登录成功" + action_error = self._parse_server_action_error(getattr(resp, "text", "") or "") + if action_error: + server_action_message = action_error + else: + server_action_message = "未解析到登录 Action" + + try: + from playwright.sync_api import sync_playwright + except Exception: + return False, "", server_action_message or "自动登录失败,且 Playwright 不可用" + + try: + proxy = None + try: + proxy_config = getattr(settings, "PROXY", None) if settings is not None else None + server = (proxy_config or {}).get("http") or (proxy_config or {}).get("https") + if server: + proxy = {"server": server} + except Exception: + proxy = None + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True, proxy=proxy) if proxy else pw.chromium.launch(headless=True) + context = browser.new_context() + page = context.new_page() + page.goto(login_url, wait_until="domcontentloaded", timeout=self.timeout * 1000) + for selector in [ + "input[name='username']", + "input[name='email']", + "input[type='email']", + "input[placeholder*='邮箱']", + "input[placeholder*='email']", + "input[placeholder*='用户名']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, username) + break + except Exception: + continue + for selector in [ + "input[name='password']", + "input[type='password']", + "input[placeholder*='密码']", + ]: + try: + if page.query_selector(selector): + page.fill(selector, password) + break + except Exception: + continue + try: + button = ( + page.query_selector("button[type='submit']") + or page.query_selector("button:has-text('登录')") + or page.query_selector("button:has-text('Login')") + ) + if button: + button.click() + else: + page.keyboard.press("Enter") + except Exception: + page.keyboard.press("Enter") + try: + page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass + cookies = context.cookies() + context.close() + browser.close() + except Exception as exc: + return False, "", f"Playwright 自动登录失败: {exc}" + + cookie_map = {str(item.get("name") or ""): str(item.get("value") or "") for item in cookies or []} + cookie_string = self._cookie_string_from_mapping(cookie_map) + if cookie_string: + return True, cookie_string, "Playwright 登录成功" + return False, "", server_action_message or "自动登录失败,未获取到有效 Cookie" + + @classmethod + def _build_signin_tree_header(cls) -> str: + return quote(json.dumps(cls._signin_router_tree, separators=(",", ":"))) + + @staticmethod + def _build_signin_action_body(is_gambler: bool) -> str: + return json.dumps([bool(is_gambler)], separators=(",", ":")) + + @staticmethod + def _normalize_response_text(text: str) -> str: + if not text: + return "" + if "ä½" in text or "å·²" in text or "签到" in text: + try: + return text.encode("latin1", errors="ignore").decode("utf-8", errors="ignore") + except Exception: + return text + return text + + @classmethod + def _extract_signin_action_id_from_chunk(cls, chunk_text: str) -> str: + if not chunk_text: + return "" + patterns = [ + rf'createServerReference[\s\S]{{0,120}}?\("([a-f0-9]{{32,}})"[\s\S]{{0,1200}}?"{re.escape(cls._signin_action_name)}"', + rf'([a-f0-9]{{32,}}).{{0,240}}?"{re.escape(cls._signin_action_name)}"', + ] + for pattern in patterns: + match = re.search(pattern, chunk_text, re.S) + if match: + return match.group(1) + return "" + + @classmethod + def _parse_signin_action_response(cls, text: str) -> Tuple[bool, str]: + text = cls._normalize_response_text(text) + if not text: + return False, "签到响应为空" + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + _, payload = line.split(":", 1) + try: + data = json.loads(payload) + except Exception: + continue + if not isinstance(data, dict): + continue + if isinstance(data.get("response"), dict): + data = data["response"] + error = data.get("error") + if isinstance(error, dict): + message = cls._normalize_response_text(error.get("description") or error.get("message") or "签到失败") + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return False, message + message = cls._normalize_response_text(data.get("message") or data.get("description")) + success = data.get("success") + if message: + if success is False: + return False, message + if "已经签到" in message or "签到过" in message or "明天再来" in message: + return True, message + return True, message + return False, "签到响应格式异常" + + def _discover_signin_action_id(self, cookies: Dict[str, str], token: str, referer: str) -> str: + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + try: + home_resp = requests.get( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception: + return "" + if home_resp.status_code != 200: + return "" + html = home_resp.text or "" + chunk_paths = list(dict.fromkeys(re.findall(r'/_next/static/chunks/[A-Za-z0-9._-]+\.js', html))) + for chunk_path in chunk_paths: + try: + chunk_resp = requests.get( + url=f"{self.base_url}{chunk_path}", + headers={ + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/javascript,text/javascript,*/*;q=0.1", + "Connection": "close", + }, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=min(self.timeout, 20), + verify=False, + ) + except Exception: + continue + if chunk_resp.status_code != 200: + continue + action_id = self._extract_signin_action_id_from_chunk(chunk_resp.text or "") + if action_id: + return action_id + return "" + + def perform_legacy_web_checkin( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 400, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": "缺少可用的影巢网页 Cookie", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "Origin": self.base_url, + "Referer": referer, + "Authorization": f"Bearer {token}", + } + if csrf_token: + headers["X-CSRF-TOKEN"] = csrf_token + + payload = {"is_gambler": True} if is_gambler else {} + try: + response = requests.post( + url=f"{self.base_url}/api/customer/user/checkin", + headers=headers, + cookies=cookies, + json=payload, + timeout=self.timeout, + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + verify=False, + ) + except Exception as exc: + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"网页签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_legacy", + } + return False, result, result["message"] + + try: + body = response.json() + except Exception: + body = {} + + message = "" + if isinstance(body, dict): + message = str(body.get("description") or body.get("message") or body.get("code") or "").strip() + if not message: + message = str(response.text or f"HTTP {response.status_code}").strip()[:200] + + lowered = message.lower() + already_signed = "已经签到" in message or "签到过" in message or "明天再来" in message + success = bool(response.status_code < 400 and (not isinstance(body, dict) or body.get("success") is not False)) + if already_signed: + success = True + + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if already_signed else "签到成功" if success else "签到失败", + "message": message or ("签到成功" if success else f"HTTP {response.status_code}"), + "data": body if isinstance(body, dict) else {}, + "source": "hdhive_web_legacy", + } + return success, result, result["message"] + + def perform_web_checkin_with_fallback( + self, + *, + cookie_string: str, + is_gambler: bool = False, + trigger: str = "网页兜底", + ) -> Tuple[bool, Dict[str, Any], str]: + legacy_ok, legacy_result, legacy_message = self.perform_legacy_web_checkin( + cookie_string=cookie_string, + is_gambler=is_gambler, + trigger=trigger, + ) + if legacy_ok: + return legacy_ok, legacy_result, legacy_message + + cookies = self.parse_cookie_string(cookie_string) + token = str(cookies.get("token") or "").strip() + csrf_token = str(cookies.get("csrf_access_token") or "").strip() + if not cookies or not token: + return legacy_ok, legacy_result, legacy_message + + user_id = self._decode_token_user_id(token) + referer = f"{self.base_url}/user/{user_id}" if user_id else f"{self.base_url}/" + action_id = self._discover_signin_action_id(cookies, token, referer) + if not action_id: + message = "旧版网页签到接口不可用,且未能解析当前站点签到 Action;请更新影巢网页 Cookie 后重试" + legacy_result["message"] = message + legacy_result["status"] = "签到失败" + legacy_result["source"] = "hdhive_web_next_action" + return False, legacy_result, message + + headers = { + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot") if settings is not None else "MoviePilot", + "Accept": "text/x-component", + "Content-Type": "text/plain;charset=UTF-8", + "Origin": self.base_url, + "Referer": f"{self.base_url}/", + "Authorization": f"Bearer {token}", + "next-action": action_id, + "next-router-state-tree": self._build_signin_tree_header(), + } + if csrf_token: + headers["x-csrf-token"] = csrf_token + + try: + response = requests.post( + url=f"{self.base_url}/", + headers=headers, + cookies=cookies, + data=self._build_signin_action_body(is_gambler), + proxies=getattr(settings, "PROXY", None) if settings is not None else None, + timeout=self.timeout, + verify=False, + ) + except Exception as exc: + return False, { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 0, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": f"Next Action 签到请求异常: {exc}", + "data": {}, + "source": "hdhive_web_next_action", + }, f"Next Action 签到请求异常: {exc}" + + redirect_target = str(response.headers.get("x-action-redirect") or response.headers.get("Location") or "").strip() + if "/login" in redirect_target: + message = "影巢网页 Cookie 已失效,请先在 HDHiveDailySign 中更新 Cookie 或重新自动登录" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {"redirect": redirect_target}, + "source": "hdhive_web_next_action", + } + return False, result, message + if response.status_code in (404, 405): + message = f"影巢网页签到入口暂不可用或 Cookie 已失效(HTTP {response.status_code}),请更新本插件里的影巢网页 Cookie 后重试" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return False, result, message + + response_text = "" + try: + response_text = response.content.decode("utf-8", errors="ignore") + except Exception: + response_text = response.text or "" + success, message = self._parse_signin_action_response(response_text) + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": success, + "status_code": response.status_code, + "trigger": trigger, + "is_gambler": bool(is_gambler), + "status": "今日已签到" if "已经签到" in message or "签到过" in message or "明天再来" in message else "签到成功" if success else "签到失败", + "message": message, + "data": {}, + "source": "hdhive_web_next_action", + } + return success, result, message diff --git a/plugins/agentresourceofficer/services/p115_transfer.py b/plugins/agentresourceofficer/services/p115_transfer.py new file mode 100644 index 0000000..536f9b5 --- /dev/null +++ b/plugins/agentresourceofficer/services/p115_transfer.py @@ -0,0 +1,823 @@ +import importlib +import re +import sys +from base64 import b64encode +from dataclasses import asdict, is_dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from zoneinfo import ZoneInfo + +try: + from app.core.config import settings +except Exception: + settings = None +try: + from app.core.plugin import PluginManager +except Exception: + PluginManager = None + + +class P115TransferService: + """Reusable 115 share transfer execution layer for Agent影视助手.""" + + CLIENT_COOKIE_REQUIRED_KEYS = {"UID", "CID", "SEID"} + QR_CLIENT_TYPES = { + "web", + "android", + "115android", + "ios", + "115ios", + "alipaymini", + "wechatmini", + "115ipad", + "tv", + "qandroid", + } + + def __init__( + self, + *, + default_target_path: str = "/待整理", + cookie: str = "", + prefer_direct: bool = True, + ) -> None: + self.default_target_path = self.normalize_pan_path(default_target_path) or "/待整理" + self.cookie = self.normalize_text(cookie) + self.prefer_direct = bool(prefer_direct) + + def set_cookie(self, cookie: str = "") -> None: + self.cookie = self.normalize_text(cookie) + + @staticmethod + def normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def _ensure_helper_import_paths() -> None: + candidate_dirs = [] + try: + plugin_parent = Path(__file__).resolve().parents[2] + candidate_dirs.append(str(plugin_parent)) + except Exception: + pass + try: + app_plugins_spec = importlib.util.find_spec("app.plugins") + for location in app_plugins_spec.submodule_search_locations or []: + candidate_dirs.append(str(Path(location).resolve())) + except Exception: + pass + for base in candidate_dirs: + path = Path(base) + if path.exists(): + text = str(path) + if text not in sys.path: + sys.path.append(text) + + @staticmethod + def is_115_share_url(url: str) -> bool: + host = urlparse(url).netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + def ensure_115_share_url(self, url: str, access_code: str = "") -> str: + clean_url = self.normalize_text(url) + if not clean_url: + return "" + access_code = self.normalize_text(access_code) + parsed = urlparse(clean_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if access_code and "password" not in query: + query["password"] = access_code + clean_url = urlunparse(parsed._replace(query=urlencode(query))) + return clean_url + + @staticmethod + def _extract_115_payload(url: str) -> Tuple[str, str]: + clean_url = str(url or "").strip() + if not clean_url: + return "", "" + try: + from p115client.util import share_extract_payload + + payload = share_extract_payload(clean_url) or {} + return str(payload.get("share_code") or "").strip(), str(payload.get("receive_code") or "").strip() + except Exception: + parsed = urlparse(clean_url) + share_code = "" + match = re.search(r"/s/([^/?#]+)", parsed.path or "") + if match: + share_code = match.group(1).strip() + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + receive_code = str(query.get("password") or query.get("receive_code") or query.get("pwd") or "").strip() + return share_code, receive_code + + @classmethod + def parse_cookie_pairs(cls, cookie: str) -> Dict[str, str]: + pairs: Dict[str, str] = {} + for part in cls.normalize_text(cookie).strip(";").split(";"): + if "=" not in part: + continue + key, value = part.split("=", 1) + key = key.strip() + value = value.strip() + if key and value: + pairs[key] = value + return pairs + + @classmethod + def validate_client_cookie(cls, cookie: str) -> Tuple[bool, str]: + if not cls.normalize_text(cookie): + return False, "未配置独立 115 Cookie" + pairs = cls.parse_cookie_pairs(cookie) + missing = sorted(cls.CLIENT_COOKIE_REQUIRED_KEYS - set(pairs)) + if missing: + return False, f"当前 115 Cookie 缺少 {'/'.join(missing)},看起来不是扫码客户端 Cookie;不建议使用网页版 Cookie" + return True, "" + + def cookie_state(self) -> Dict[str, Any]: + configured = bool(self.normalize_text(self.cookie)) + pairs = self.parse_cookie_pairs(self.cookie) + cookie_keys = sorted(pairs.keys()) + if not configured: + return { + "configured": False, + "valid": False, + "mode": "none", + "cookie_keys": [], + "message": "未配置独立 115 会话,将优先复用 P115StrmHelper 已登录客户端", + } + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + return { + "configured": True, + "valid": cookie_ok, + "mode": "client_cookie" if cookie_ok else "invalid_cookie", + "cookie_keys": cookie_keys, + "message": "" if cookie_ok else cookie_message, + } + + @classmethod + def normalize_qrcode_client_type(cls, client_type: Any) -> str: + text = cls.normalize_text(client_type).lower() + return text if text in cls.QR_CLIENT_TYPES else "alipaymini" + + @staticmethod + def jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool, list, dict)): + return value + if is_dataclass(value): + return asdict(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + if hasattr(value, "__dict__"): + return {k: v for k, v in vars(value).items() if not k.startswith("_")} + return str(value) + + def tz_now(self) -> datetime: + if settings is not None: + try: + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + @staticmethod + def _safe_int(value: Any, default: int = -1) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _response_error(resp: Any) -> str: + if not isinstance(resp, dict): + return str(resp or "") + for key in ("error", "message", "msg", "errno"): + value = resp.get(key) + if value not in (None, ""): + return str(value) + return str(resp) + + @classmethod + def _is_already_saved_message(cls, value: Any) -> bool: + text = cls.normalize_text(value) + return any( + marker in text + for marker in ( + "已经转存", + "已转存", + "已经保存", + "已保存", + "already", + "exist", + ) + ) + + @staticmethod + def _response_ok(resp: Any) -> bool: + if not isinstance(resp, dict): + return False + if resp.get("state") is True: + return True + if resp.get("code") in (0, "0") and resp.get("state") not in (False, 0): + return True + if resp.get("errno") in (0, "0") and resp.get("state") not in (False, 0): + return True + return False + + @staticmethod + def _p115_request_kwargs(*, app: bool = False) -> Dict[str, Any]: + try: + P115TransferService._ensure_helper_import_paths() + from app.plugins.p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + try: + P115TransferService._ensure_helper_import_paths() + from p115strmhelper.core.config import configer + + return configer.get_ios_ua_app(app=app) or {} + except Exception: + pass + return {} + + @staticmethod + def _resolve_servicer_from_loaded_plugin() -> Tuple[Optional[Any], Optional[str]]: + if PluginManager is None: + return None, "PluginManager 不可用" + try: + plugin = PluginManager().running_plugins.get("P115StrmHelper") + except Exception as exc: + return None, f"读取 P115StrmHelper 运行态失败: {exc}" + if not plugin: + return None, "P115StrmHelper 未加载" + + module_names = [] + plugin_module = getattr(plugin.__class__, "__module__", "") or "" + if plugin_module: + module_names.append(f"{plugin_module}.service") + module_names.extend( + [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ] + ) + + for module_name in module_names: + try: + self._ensure_helper_import_paths() + module = sys.modules.get(module_name) or importlib.import_module(module_name) + servicer = getattr(module, "servicer", None) + if servicer is not None: + return servicer, None + except Exception: + continue + return None, "P115StrmHelper 运行态已加载,但未找到 service.servicer" + + def _get_loaded_p115_client(self) -> Tuple[Optional[Any], str]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + return None, helper_error or "P115StrmHelper 未加载" + client = getattr(servicer, "client", None) + if not client: + return None, "P115StrmHelper 未登录 115 或客户端不可用" + return client, "p115strmhelper_client" + + def _get_cookie_p115_client(self) -> Tuple[Optional[Any], str]: + if not self.cookie: + return None, "未配置独立 115 Cookie" + cookie_ok, cookie_message = self.validate_client_cookie(self.cookie) + if not cookie_ok: + return None, cookie_message + try: + from p115client import P115Client + + return P115Client( + self.cookie, + check_for_relogin=False, + ensure_cookies=False, + console_qrcode=False, + ), "direct_cookie" + except Exception as exc: + return None, f"独立 115 Cookie 初始化失败: {exc}" + + @classmethod + def create_qrcode_login(cls, client_type: str = "alipaymini") -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_token() + check_response(resp) + resp_info = resp.get("data", {}) if isinstance(resp, dict) else {} + uid = str(resp_info.get("uid") or "") + qrcode_time = str(resp_info.get("time") or "") + sign = str(resp_info.get("sign") or "") + qrcode = P115Client.login_qrcode(uid) + if not isinstance(qrcode, (bytes, bytearray)): + return False, {}, "获取二维码失败:返回内容类型异常" + return True, { + "uid": uid, + "time": qrcode_time, + "sign": sign, + "client_type": final_client_type, + "tips": "请使用 115 App 扫码登录", + "qrcode": f"data:image/png;base64,{b64encode(qrcode).decode('utf-8')}", + }, "success" + except Exception as exc: + return False, {}, f"获取 115 登录二维码失败: {exc}" + + @classmethod + def check_qrcode_login( + cls, + *, + uid: str, + time_value: str, + sign: str, + client_type: str = "alipaymini", + ) -> Tuple[bool, Dict[str, Any], str]: + final_client_type = cls.normalize_qrcode_client_type(client_type) + try: + from p115client import P115Client, check_response + + payload = {"uid": uid, "time": time_value, "sign": sign} + resp = P115Client.login_qrcode_scan_status(payload) + if not isinstance(resp, dict): + return False, {}, "检查二维码状态失败:返回内容类型异常" + check_response(resp) + status_code = (resp.get("data") or {}).get("status") + except Exception as exc: + return False, {}, f"检查二维码状态失败: {exc}" + + if status_code == 0: + return True, {"status": "waiting", "client_type": final_client_type}, "等待扫码" + if status_code == 1: + return True, {"status": "scanned", "client_type": final_client_type}, "已扫码,等待确认" + if status_code == -1 or status_code is None: + return False, {"status": "expired", "client_type": final_client_type}, "二维码已过期" + if status_code == -2: + return False, {"status": "cancelled", "client_type": final_client_type}, "用户取消登录" + if status_code != 2: + return False, {"status": "unknown", "client_type": final_client_type}, f"未知二维码状态: {status_code}" + + try: + from p115client import P115Client, check_response + + resp = P115Client.login_qrcode_scan_result(uid, app=final_client_type) + if not isinstance(resp, dict): + return False, {}, "获取登录结果失败:返回内容类型异常" + check_response(resp) + except Exception as exc: + return False, {}, f"获取登录结果失败: {exc}" + + cookie_data = (resp.get("data") or {}).get("cookie") if isinstance(resp, dict) else None + if not isinstance(cookie_data, dict): + return False, {}, "登录成功但未返回 Cookie" + cookie = "; ".join(f"{name}={value}" for name, value in cookie_data.items() if name and value).strip() + cookie_ok, cookie_message = cls.validate_client_cookie(cookie) + if not cookie_ok: + return False, {}, cookie_message + return True, { + "status": "success", + "client_type": final_client_type, + "cookie": cookie, + "cookie_keys": sorted(cls.parse_cookie_pairs(cookie).keys()), + }, "登录成功" + + def get_direct_client(self) -> Tuple[Optional[Any], str, str]: + client, source = self._get_cookie_p115_client() + if client: + return client, source, "" + cookie_error = source + client, source = self._get_loaded_p115_client() + if client: + return client, source, "" + return None, "none", source or cookie_error + + @classmethod + def _import_servicer_fallback(cls) -> Tuple[Optional[Any], Optional[str]]: + last_error = "" + for module_name in [ + "app.plugins.p115strmhelper.service", + "p115strmhelper.service", + ]: + try: + cls._ensure_helper_import_paths() + service_module = importlib.import_module(module_name) + servicer = getattr(service_module, "servicer", None) + if servicer is not None: + return servicer, None + last_error = f"{module_name} 未暴露 servicer" + except Exception as exc: + last_error = f"{module_name} 导入失败: {exc}" + return None, last_error or "P115StrmHelper 未安装或无法导入" + + def get_share_helper(self) -> Tuple[Optional[Any], Optional[str]]: + servicer, helper_error = self._resolve_servicer_from_loaded_plugin() + if not servicer: + servicer, helper_error = self._import_servicer_fallback() + if not servicer: + return None, f"P115StrmHelper 未安装或无法导入: {helper_error}" + if not servicer: + return None, "P115StrmHelper 未初始化" + if not getattr(servicer, "client", None): + return None, "P115StrmHelper 未登录 115 或客户端不可用" + helper = getattr(servicer, "sharetransferhelper", None) + if not helper: + return None, "P115StrmHelper 分享转存模块不可用" + return helper, None + + def health(self) -> Tuple[bool, Dict[str, Any], str]: + cookie_state = self.cookie_state() + direct_client, direct_source, direct_error = self.get_direct_client() + direct_ready = direct_client is not None + helper, helper_error = self.get_share_helper() + helper_ready = bool(helper and not helper_error) + ready = direct_ready or helper_ready + message = "" if ready else direct_error or helper_error or "115 转存不可用" + return ready, { + "ready": ready, + "direct_ready": direct_ready, + "direct_source": direct_source if direct_ready else "", + "direct_message": "" if direct_ready else direct_error, + "helper_ready": helper_ready, + "helper_message": "" if helper_ready else helper_error, + "cookie_state": cookie_state, + "message": message or "success", + }, message + + def _get_or_create_path_cid(self, client: Any, path: str) -> int: + return self._get_path_cid(client, path, create=True) + + def _get_path_cid(self, client: Any, path: str, *, create: bool = True) -> int: + target_path = self.normalize_pan_path(path) or "/" + if target_path == "/": + return 0 + get_kwargs = self._p115_request_kwargs(app=False) + mkdir_kwargs = self._p115_request_kwargs(app=True) + try: + resp = client.fs_dir_getid(target_path, **get_kwargs) + pid = self._safe_int(resp.get("id") if isinstance(resp, dict) else None, -1) + if pid > 0: + return pid + except Exception: + pass + + if not create: + return -1 + + try: + resp = client.fs_makedirs_app(target_path, pid=0, **mkdir_kwargs) + cid = self._safe_int(resp.get("cid") if isinstance(resp, dict) else None, -1) + if cid >= 0: + return cid + if self._response_ok(resp): + cid = self._safe_int((resp.get("data") or {}).get("cid") if isinstance(resp.get("data"), dict) else None, -1) + if cid >= 0: + return cid + raise RuntimeError(self._response_error(resp)) + except Exception as exc: + raise RuntimeError(f"无法创建或定位 115 目录 {target_path}: {exc}") from exc + + def list_directory_current_layer(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "path": target_path, + "items": [], + "file_count": 0, + "folder_count": 0, + "removed_count": 0, + "message": "", + } + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + result["direct_source"] = source + return False, result, result["message"] + + cid = self._get_path_cid(client, target_path, create=False) + if cid < 0: + result["ok"] = True + result["direct_source"] = source + result["message"] = "115 默认目录不存在,视为空目录" + return True, result, result["message"] + + payload = { + "cid": int(cid), + "limit": 1150, + "offset": 0, + "show_dir": 1, + "cur": 1, + "count_folders": 1, + } + items: list[dict[str, Any]] = [] + total = 0 + try: + while True: + resp = client.fs_files(payload, **self._p115_request_kwargs(app=False)) + if not isinstance(resp, dict): + result["message"] = "读取 115 目录失败:返回内容异常" + result["direct_source"] = source + return False, result, result["message"] + batch = resp.get("data") or [] + total = self._safe_int(resp.get("count"), total) + for entry in batch: + if not isinstance(entry, dict): + continue + fid = self._safe_int(entry.get("fid"), -1) + item_cid = self._safe_int(entry.get("cid"), -1) + is_dir = fid < 0 + item_id = item_cid if is_dir else fid + if item_id < 0: + continue + items.append( + { + "id": item_id, + "name": self.normalize_text(entry.get("n") or entry.get("fn") or entry.get("file_name")), + "is_dir": is_dir, + "type": "folder" if is_dir else "file", + "raw": entry, + } + ) + payload["offset"] = int(payload["offset"]) + len(batch) + if not batch or len(batch) < int(payload["limit"]) or int(payload["offset"]) >= total: + break + except Exception as exc: + result["message"] = f"读取 115 目录失败: {exc}" + result["direct_source"] = source + return False, result, result["message"] + + file_count = len([item for item in items if not item.get("is_dir")]) + folder_count = len([item for item in items if item.get("is_dir")]) + result.update( + { + "ok": True, + "direct_source": source, + "cid": cid, + "items": items, + "file_count": file_count, + "folder_count": folder_count, + "message": "success", + } + ) + return True, result, "success" + + def delete_items(self, items: list[dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + client, source, client_error = self.get_direct_client() + result = { + "ok": False, + "direct_source": source, + "removed_count": 0, + "message": "", + } + if not client: + result["message"] = client_error or "没有可用的 115 客户端" + return False, result, result["message"] + + ids = [str(self._safe_int(item.get("id"), -1)) for item in items or [] if self._safe_int(item.get("id"), -1) >= 0] + if not ids: + result.update({"ok": True, "message": "115 默认目录当前层已是空目录"}) + return True, result, result["message"] + + try: + resp = client.fs_delete(ids, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"删除 115 目录内容失败: {exc}" + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "删除 115 目录内容失败" + result["raw"] = self.jsonable(resp) + return False, result, result["message"] + + result.update( + { + "ok": True, + "removed_count": len(ids), + "message": "115 默认目录已清空当前层", + "raw": self.jsonable(resp), + } + ) + return True, result, result["message"] + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + target_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + listed_ok, listed_result, listed_message = self.list_directory_current_layer(target_path) + if not listed_ok: + return False, listed_result, listed_message + + items = listed_result.get("items") or [] + if not items: + listed_result["message"] = "115 默认目录当前层已是空目录" + return True, listed_result, listed_result["message"] + + delete_ok, delete_result, delete_message = self.delete_items(items) + merged = dict(listed_result) + merged.update( + { + "ok": delete_ok, + "removed_count": delete_result.get("removed_count", 0), + "direct_source": delete_result.get("direct_source", listed_result.get("direct_source")), + "delete_raw": delete_result.get("raw"), + "message": delete_message, + } + ) + return delete_ok, merged, delete_message + + def transfer_share_direct( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "strategy": "direct", + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + share_code, receive_code = self._extract_115_payload(share_url) + if not share_code or not receive_code: + result["message"] = "解析 115 分享链接失败,缺少分享码或提取码" + return False, result, result["message"] + + client, source, client_error = self.get_direct_client() + if not client: + result["message"] = client_error or "没有可用的 115 直转客户端" + result["data"] = {"direct_source": source} + return False, result, result["message"] + + try: + parent_id = self._get_or_create_path_cid(client, transfer_path) + except Exception as exc: + result["message"] = str(exc) + result["data"] = {"direct_source": source} + return False, result, result["message"] + + payload = { + "share_code": share_code, + "receive_code": receive_code, + "file_id": 0, + "cid": int(parent_id), + "is_check": 0, + } + try: + resp = client.share_receive(payload, **self._p115_request_kwargs(app=False)) + except Exception as exc: + result["message"] = f"调用 115 直转接口失败: {exc}" + result["data"] = {"direct_source": source, "parent_id": parent_id} + return False, result, result["message"] + + if not self._response_ok(resp): + result["message"] = self._response_error(resp) or "115 直转失败" + result["data"] = { + "direct_source": source, + "parent_id": parent_id, + "raw": self.jsonable(resp), + } + if self._is_already_saved_message(result["message"]): + result["ok"] = True + result["message"] = "115 直转已存在" + return True, result, result["message"] + return False, result, result["message"] + + result.update( + { + "ok": True, + "message": "115 直转成功", + "data": { + "direct_source": source, + "share_code": share_code, + "receive_code": receive_code, + "save_parent": transfer_path, + "parent_id": parent_id, + "raw": self.jsonable(resp), + }, + } + ) + return True, result, result["message"] + + def transfer_share( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self.normalize_pan_path(path) or self.default_target_path or "/待整理" + share_url = self.ensure_115_share_url(url or "", access_code or "") + result = { + "time": self.tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的分享链接" + return False, result, result["message"] + if not self.is_115_share_url(share_url): + result["message"] = "当前链接不是 115 分享链接,无法直接转存到 115" + return False, result, result["message"] + + if self.prefer_direct: + direct_ok, direct_result, direct_message = self.transfer_share_direct( + url=share_url, + access_code=access_code, + path=transfer_path, + trigger=trigger, + ) + if direct_ok: + return True, direct_result, direct_message + result["data"]["direct_fallback"] = direct_result + + helper, helper_error = self.get_share_helper() + if helper_error or not helper: + direct_error = ((result.get("data") or {}).get("direct_fallback") or {}).get("message") + result["message"] = helper_error or direct_error or "P115StrmHelper 不可用" + return False, result, result["message"] + + try: + transfer_result = helper.add_share_115( + share_url, + notify=False, + pan_path=transfer_path, + ) + except Exception as exc: + result["message"] = f"调用 P115StrmHelper 转存失败: {exc}" + return False, result, result["message"] + + if not transfer_result or not transfer_result[0]: + error_message = "" + if isinstance(transfer_result, tuple): + if len(transfer_result) > 2: + error_message = self.normalize_text(transfer_result[2]) + elif len(transfer_result) > 1: + error_message = self.normalize_text(transfer_result[1]) + if self._is_already_saved_message(error_message): + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存已存在", + "data": {"raw": self.jsonable(transfer_result)}, + } + ) + return True, result, result["message"] + result["message"] = error_message or "115 转存失败" + result["data"] = {"raw": self.jsonable(transfer_result)} + return False, result, result["message"] + + media_info = transfer_result[1] if len(transfer_result) > 1 else None + save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path + parent_id = transfer_result[3] if len(transfer_result) > 3 else None + result.update( + { + "ok": True, + "strategy": "p115strmhelper", + "message": "115 转存成功", + "data": { + "media_info": self.jsonable(media_info), + "save_parent": save_parent, + "parent_id": parent_id, + }, + } + ) + return True, result, result["message"] diff --git a/plugins/agentresourceofficer/services/quark_transfer.py b/plugins/agentresourceofficer/services/quark_transfer.py new file mode 100644 index 0000000..68261e8 --- /dev/null +++ b/plugins/agentresourceofficer/services/quark_transfer.py @@ -0,0 +1,664 @@ +import json +import random +import re +import time +from datetime import datetime +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlparse, urlencode + +import requests + +from app.log import logger + +try: + from app.core.config import settings +except Exception: + settings = None + + +class QuarkTransferService: + """ + Reusable execution layer migrated out of QuarkShareSaver. + + This service intentionally focuses on transfer execution and directory + resolution. UI, plugin form logic, and entry adapters stay outside. + """ + + def __init__( + self, + *, + cookie: str = "", + timeout: int = 30, + default_target_path: str = "/飞书", + auto_import_cookiecloud: bool = True, + cookie_refresh_callback: Optional[Callable[[], str]] = None, + ) -> None: + self.cookie = self.clean_text(cookie) + self.timeout = max(10, self.safe_int(timeout, 30)) + self.default_target_path = self.normalize_path(default_target_path or "/飞书") + self.auto_import_cookiecloud = auto_import_cookiecloud + self.cookie_refresh_callback = cookie_refresh_callback + self.path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + @staticmethod + def extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + @classmethod + def extract_share_info(cls, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = cls.clean_text(share_text) + share_url = cls.extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = cls.clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = cls.clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = cls.clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + @classmethod + def validate_share_url(cls, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if cls.is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def set_cookie(self, cookie: str) -> None: + self.cookie = self.clean_text(cookie) + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self.cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _refresh_cookie(self) -> bool: + if not self.auto_import_cookiecloud or not self.cookie_refresh_callback: + return False + try: + cookie = self.clean_text(self.cookie_refresh_callback()) + except Exception as exc: + logger.warning(f"[Agent影视助手] 刷新夸克 Cookie 失败: {exc}") + return False + if not cookie: + return False + self.cookie = cookie + return True + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookie_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + try: + response = requests.request( + method=method.upper(), + url=url, + params=params or None, + json=json_body, + headers=self._build_headers(), + timeout=self.timeout, + ) + status_code = response.status_code + raw_body = response.text or "" + except requests.RequestException as exc: + return False, {}, f"请求失败: {exc}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = response.json() + except Exception: + text = str(raw_body)[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code in {401, 403} and allow_cookie_retry and self._refresh_cookie(): + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookie_retry=False, + ) + + if status_code != 200: + if isinstance(data, dict): + code = self.clean_text(data.get("code")) + detail = self.clean_text(data.get("message") or data.get("msg")) + if detail: + if code: + return False, data, f"HTTP {status_code} [{code}]: {detail}" + return False, data, f"HTTP {status_code}: {detail}" + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + def get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self.clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self.safe_int(meta.get("_total"), 0) + count = self.safe_int(meta.get("_count"), len(current)) + size = max(1, self.safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + "raw": item, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def delete_items(self, items: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + source_items = [item for item in (items or []) if isinstance(item, dict)] + + def build_fids(candidates: List[Dict[str, Any]]) -> List[str]: + result: List[str] = [] + for item in candidates: + fid = self.clean_text(item.get("fid")) + if fid: + result.append(fid) + return result + + def item_label(item: Dict[str, Any]) -> str: + return self.clean_text(item.get("name") or item.get("file_name") or item.get("fid")) + + def call_delete(candidates: List[Dict[str, Any]]) -> Tuple[bool, Dict[str, Any], str]: + fids = build_fids(candidates) + if not fids: + return False, {}, "默认目录当前层没有可删除项目" + payloads = [ + { + "action_type": 2, + "exclude_fids": [], + "filelist": [{"fid": fid} for fid in fids], + }, + { + "action_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + { + # Some web scripts historically used this misspelled key. + "actoin_type": 2, + "exclude_fids": [], + "filelist": fids, + }, + ] + last_data: Dict[str, Any] = {} + last_message = "" + for index, payload in enumerate(payloads, start=1): + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/file/delete", + params={ + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + }, + json_body=payload, + ) + if ok: + if isinstance(data, dict): + data["delete_payload_variant"] = index + return True, data, "" + last_data = data if isinstance(data, dict) else {} + last_message = message or last_message + return False, last_data, last_message or "夸克删除失败" + + filelist: List[Dict[str, Any]] = [] + for item in source_items: + fid = self.clean_text((item or {}).get("fid")) if isinstance(item, dict) else "" + if fid: + filelist.append({"fid": fid}) + if not filelist: + return False, {}, "默认目录当前层没有可删除项目" + + ok, data, message = call_delete(source_items) + if ok: + data["deleted_count"] = len(filelist) + data["delete_mode"] = "batch" + return True, data, "" + + if len(source_items) <= 1: + return False, data, message or "夸克删除失败" + + deleted_count = 0 + failed_items: List[Dict[str, Any]] = [] + for item in source_items: + single_ok, single_data, single_message = call_delete([item]) + if single_ok: + deleted_count += 1 + continue + failed_items.append({ + "fid": self.clean_text(item.get("fid")), + "name": item_label(item), + "message": single_message or "删除失败", + "result": single_data, + }) + + result = { + "deleted_count": deleted_count, + "failed_count": len(failed_items), + "failed_items": failed_items[:20], + "delete_mode": "single_fallback", + "batch_error": message or "夸克批量删除失败", + "batch_result": data, + } + if failed_items: + return False, result, f"夸克逐项删除后仍有 {len(failed_items)} 项失败" + return True, result, "" + + def clear_directory(self, path: str = "") -> Tuple[bool, Dict[str, Any], str]: + ok, target_fid, normalized_path = self.ensure_target_dir(path or self.default_target_path) + if not ok: + return False, {}, target_fid or "定位夸克目录失败" + + ok, children, message = self.list_children(target_fid) + if not ok: + return False, {}, message or "读取夸克目录失败" + + files = [item for item in children if not bool(item.get("dir"))] + folders = [item for item in children if bool(item.get("dir"))] + if not children: + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": 0, + "file_count": 0, + "folder_count": 0, + "items": [], + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "默认目录当前层为空" + + ok, delete_result, message = self.delete_items(children) + removed_count = self.safe_int((delete_result or {}).get("deleted_count"), len(children) if ok else 0) + if not ok: + return False, { + "target_path": normalized_path, + "target_fid": target_fid, + "file_count": len(files), + "folder_count": len(folders), + "removed_count": removed_count, + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "failed_items": (delete_result or {}).get("failed_items") or [], + "delete_result": delete_result, + }, message or "夸克清空默认目录失败" + + return True, { + "target_path": normalized_path, + "target_fid": target_fid, + "removed_count": removed_count, + "file_count": len(files), + "folder_count": len(folders), + "items": [self.clean_text(item.get("name")) for item in children[:20]], + "delete_result": delete_result, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, "success" + + def find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self.list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self.clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self.normalize_path(path or self.default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self.path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self.path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self.find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self.create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self.path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self.clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self.safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self.clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self.list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + trigger: str = "Agent影视助手", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self.extract_share_info(share_text, access_code) + ok, message = self.validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + if not self.cookie: + self._refresh_cookie() + if not self.cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self.get_stoken(pwd_id, final_code) + if not ok: + return False, {}, message + + ok, share_items, message = self.get_share_items(pwd_id, stoken) + if not ok: + return False, {}, message + + ok, target_fid, normalized_path = self.ensure_target_dir(target_path or self.default_target_path) + if not ok: + return False, {}, target_fid + + ok, task_id, message = self.create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + return False, {}, message + + ok, task, message = self.wait_task(task_id) + if not ok: + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + return True, result, "success" diff --git a/plugins/airecognizerenhancer/ARCHITECTURE.md b/plugins/airecognizerenhancer/ARCHITECTURE.md new file mode 100644 index 0000000..314622b --- /dev/null +++ b/plugins/airecognizerenhancer/ARCHITECTURE.md @@ -0,0 +1,83 @@ +# AI识别增强架构草案 + +`AI识别增强` 用来承接 MoviePilot 原生识别失败后的本地 AI 兜底链路。 + +## 设计目标 + +- 摆脱外部 AI Gateway 的强依赖 +- 直接使用 MoviePilot 已启用的 LLM 配置 +- 输出结构化识别结果,而不是只回传一段自由文本 + +## 模块分层 + +### 1. hooks + +负责接住识别失败事件和后续整理事件。 + +### 2. llm + +负责封装对 MP 当前 LLM 的调用: + +- 标准提示词 +- 结构化返回约束 +- 超时与错误兜底 + +### 3. normalize + +负责把 AI 输出转换成可继续进入 MP 整理链路的数据: + +- 标题 +- 年份 +- 类型 +- 季 +- 集 +- 置信度 + +### 4. actions + +负责根据结果执行后续动作: + +- 二次识别 +- 二次整理 +- 记录失败样本 + +## 首期配置模型 + +- `enabled` +- `notify` +- `debug` +- `confidence_threshold` +- `request_timeout` +- `max_retries` +- `save_failed_samples` + +## 二期规划 + +- 生成自定义识别词建议 +- 失败样本聚合分析 +- 提供给 MP Agent / Skill 直接调起 + +## 首个里程碑 + +第一个可用版本只追求: + +1. 原生识别失败后自动触发本地 LLM 判断 +2. 拿到结构化结果后自动二次整理 +3. 能明确记录“成功 / 放弃 / 失败原因” + +## 当前实现状态 + +- 已接住 `ChainEventType.NameRecognize` +- 已复用 `LLMHelper.get_llm(streaming=False)` 做结构化输出 +- 已提供手动调试接口用于验证标题识别结果 +- 已支持查看低置信度样本,并继续生成为 MoviePilot 自定义识别词建议 +- 已支持直接基于失败样本生成建议并一键写入 `CustomIdentifiers` +- 已支持失败样本摘要列表、样本清理、样本去重和保留上限控制 +- 已支持失败样本洞察汇总,自动挑出重复问题和优先处理样本 +- 已支持失败样本出队:写入识别词后自动移除,或单独按索引移除 +- 已支持失败样本复查:按当前识别词和当前识别器重跑,并可自动把已修复样本出队 +- 已支持失败样本批量复查:可批量重跑并按结果批量出队 +- 已支持失败样本批量建议与批量写入:可批量生成建议并批量落库 +- 已支持低 token 精简摘要输出,适合作为智能体批处理入口 +- 已支持识别词建议模型退化时自动切换到精确规则兜底,优先保证稳定落地 +- 下一步重点会放在提示词打磨、失败样本回放和识别词建议质量提升 diff --git a/plugins/airecognizerenhancer/README.md b/plugins/airecognizerenhancer/README.md index ea48943..e10d001 100644 --- a/plugins/airecognizerenhancer/README.md +++ b/plugins/airecognizerenhancer/README.md @@ -83,6 +83,8 @@ MoviePilot 原版智能体已经提供“整理失败后自动接管再试一次 当前版本:`0.1.12` +当前 Release:https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v0.2.68 + 这版已经验证过: - 最新版 MoviePilot 下可以正常加载 diff --git a/plugins/airecognizerenhancer/__init__.py b/plugins/airecognizerenhancer/__init__.py index 4471184..eee252e 100644 --- a/plugins/airecognizerenhancer/__init__.py +++ b/plugins/airecognizerenhancer/__init__.py @@ -1411,6 +1411,10 @@ AI 识别增强结果: return guess = result.get("guess") or {} if isinstance(event_data, dict): + if event_data.get("source_plugin"): + if self._debug: + logger.info(f"[AI识别增强] 已有插件处理识别结果,跳过覆盖: {event_data.get('source_plugin')}") + return event_data["name"] = guess.get("name", "") event_data["year"] = guess.get("year", "") event_data["season"] = guess.get("season", 0) diff --git a/plugins/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md b/plugins/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md new file mode 100644 index 0000000..75060bd --- /dev/null +++ b/plugins/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md @@ -0,0 +1,188 @@ +# 外部智能体接入 Agent影视助手 + +让 `OpenClaw`、`Hermes`、`WorkBuddy` 或其他外部智能体,也能稳定调用 MoviePilot 的搜片、转存、下载、签到和修复能力。 + +核心思路很简单:外部智能体负责理解你说的话、调用 `Agent影视助手`、展示结果;真正的资源搜索、转存、下载和账号操作,都交给 MoviePilot 里的插件执行。 + +--- + +## 一步接入 + +把下面这段直接发给你的外部智能体: + +```text +请从这个仓库创建并使用 agent-resource-officer Skill: +https://github.com/liuyuexi1987/MoviePilot-Plugins + +创建后请依次读取: +1. skills/agent-resource-officer/SKILL.md +2. skills/agent-resource-officer/EXTERNAL_AGENTS.md +3. docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md + +连接配置: +ARO_BASE_URL=http://MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN + +如果你的客户端支持 MoviePilot 官方 MCP,也请同时接入: +MCP 地址:http://MoviePilot地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN + +分工规则: +1. 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询,可以优先用 MCP。 +2. 云盘搜索、盘搜、影巢、转存、夸克转存、115转存、下载、更新检查、编号选择、翻页、详情、Cookie 修复,继续优先用 agent-resource-officer skill / helper。 +3. 只有当前会话真的加载出 mcp__moviepilot__* 工具,才算 MCP 已接通;没接通时不要假装在用 MCP。 + +请把配置写入 ~/.config/agent-resource-officer/config。 +然后运行 readiness 验证连接,成功后按文档规则接入。 +``` + +`ARO_API_KEY` 在 MoviePilot 管理后台的系统设置 / 安全设置里找。 + +--- + +## 连接地址怎么填 + +先判断 MoviePilot 和智能体是不是在同一台机器。 + +### 同机部署 + +如果 MoviePilot 和智能体在同一台电脑或同一个容器网络里,可以这样填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +这也是最简单的情况。 + +### 跨机器部署 + +如果 MoviePilot 在 NAS,智能体在 Win / Mac 电脑上,`ARO_BASE_URL` 必须填 NAS 的实际地址: + +```bash +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +这里的 `127.0.0.1` 只代表智能体自己这台机器,不是 NAS。 + +如果你有多套 MoviePilot,要特别注意: + +- `ARO_BASE_URL` 指向哪套 MoviePilot,`下载 / MP搜索 / PT搜索 / 转存` 就使用哪套 MoviePilot。 +- 如果当前 MoviePilot 只用于网盘或 STRM,不要在这套实例里确认 PT 下载。 +- 如果 MoviePilot 和 qBittorrent 不在一台机器,可在 Agent影视助手设置里填写 `PT 下载保存路径`,路径要按目标 NAS / qB 的真实下载目录填写。 + +跨机器部署详细说明见 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)。 + +--- + +## 手动添加 MCP + +有些智能体不会自动读取或启用 MoviePilot MCP,需要你在智能体的 MCP 设置里手动添加。 + +填写: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS,地址要写 NAS 的实际地址: + +```text +MCP 地址:http://你的NAS地址:3000/api/v1/mcp +``` + +添加后,需要在智能体里确认 MCP 已启用,并且当前会话能看到类似 `mcp__moviepilot__*` 的工具。 + +如果看不到这些工具,就说明 MCP 没有真正加载成功。此时不要让智能体假装在用 MCP,资源流继续走 `agent-resource-officer skill / helper`。 + +--- + +## 怎么用 + +接入完成后,直接对智能体说: + +| 命令 | 作用 | +|---|---| +| `搜索 蜘蛛侠` | 搜索云盘资源,默认走盘搜 | +| `云盘搜索 蜘蛛侠` | 盘搜 + 影巢一起搜 | +| `MP搜索 蜘蛛侠` / `PT搜索 蜘蛛侠` | 走 MoviePilot 原生 PT 搜索 | +| `转存 蜘蛛侠` | 默认等同 `115转存 蜘蛛侠` | +| `115转存 蜘蛛侠` | 搜索后转存到 115 | +| `夸克转存 蜘蛛侠` | 搜索后转存到夸克 | +| `下载 蜘蛛侠` | 搜索并生成 PT 下载计划 | +| `更新检查 蜘蛛侠` | 检查是否有新资源 | +| `115登录` | 扫码登录 115 | +| `影巢签到` | 执行影巢签到 | + +完整命令列表见:`docs/ALL_COMMANDS.md`。 + +--- + +## MCP 要不要接 + +MoviePilot 官方 MCP 可以接,但它和 `agent-resource-officer skill / helper` 的定位不同。 + +推荐这样分工: + +| 场景 | 推荐入口 | +|---|---| +| 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询 | 官方 MCP | +| 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情、Cookie 修复 | `agent-resource-officer skill / helper` | +| `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流 | 优先 `agent-resource-officer skill / helper` | + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +``` + +认证头: + +```text +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +注意:只有当前智能体客户端真的加载出了 `mcp__moviepilot__*` 工具,才算 MCP 已接通。没有接通时,不要让智能体假装在用 MCP;资源流继续走 `agent-resource-officer`。 + +--- + +## 给智能体看的执行规则 + +这部分规则已经写在 `agent-resource-officer` Skill 里,普通用户不用背。 + +接入时只要让外部智能体读取本仓库里的 Skill,它就会知道哪些命令必须走 `route / pick`、哪些动作需要确认、哪些结果不能重排编号。 + +--- + +## 长线程维护 + +微信、飞书、WorkBuddy、Claw 这类长线程用久后,可能会出现: + +- `15详情` 被误解成 `选择 15` +- 编号续接到旧搜索结果 +- 一直套用旧格式或旧规则 + +这时直接对智能体说: + +```text +校准影视技能 +``` + +这条命令会让智能体重新加载影视助手的关键规则。不要在普通 `搜索 / 更新检查 / 检查` 前主动清会话,否则会破坏正常编号续接。 + +--- + +## 相关文档 + +- 全部命令一览:`docs/ALL_COMMANDS.md` +- [跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [Skill 说明](../skills/agent-resource-officer/SKILL.md) +- 外部智能体详细规范:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` diff --git a/plugins/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md b/plugins/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md new file mode 100644 index 0000000..b624fa0 --- /dev/null +++ b/plugins/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md @@ -0,0 +1,177 @@ +# Agent影视助手跨机器部署 + +这份文档只讲一种常见情况: + +```text +MoviePilot 在 NAS / Docker / 远程主机 +外部智能体在 Win / Mac 电脑 +``` + +这属于正常用法,不是特殊模式。关键只有一个:智能体要能访问到 MoviePilot。 + +--- + +## 先填对 ARO_BASE_URL + +外部智能体所在电脑的配置文件一般是: + +```text +~/.config/agent-resource-officer/config +``` + +如果 MoviePilot 在 NAS,配置应类似: + +```text +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要写: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +因为这里的 `127.0.0.1` 代表智能体自己这台电脑,不是 NAS。 + +只有 MoviePilot 和智能体在同一台机器时,才用: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +--- + +## 多套 MoviePilot 时要注意 + +`ARO_BASE_URL` 指向哪套 MoviePilot,下面这些命令就使用哪套 MoviePilot: + +```text +MP搜索 +PT搜索 +下载 +订阅 +转存 +更新检查 +``` + +如果你有一套 MoviePilot 只用于网盘 / STRM,不要在这套实例里确认 PT 下载。 + +如果你真正下载用的是 NAS 上另一套 MoviePilot,就把 `ARO_BASE_URL` 指向那一套。 + +--- + +## MP 和 qB 不同机时 + +如果 MoviePilot 和 qBittorrent 不在一台机器,可以在 `Agent影视助手` 设置页填写: + +```text +PT 下载保存路径 +``` + +简单理解: + +- MoviePilot 和 qB 在同一台机器:通常不用填。 +- MoviePilot 和 qB 不在一台机器:填 qB 能识别的真实下载目录。 + +示例: + +```text +/downloads +/volume1/downloads +local:/downloads +``` + +不要填你当前电脑上的临时路径,除非 qB 也真的在这台电脑上。 + +--- + +## 盘搜 API 地址按 MoviePilot 视角填 + +这里容易混: + +- `ARO_BASE_URL` 是外部智能体访问 MoviePilot 的地址。 +- `盘搜 API 地址` 是 MoviePilot 插件访问 PanSou 的地址。 + +如果 PanSou 和 MoviePilot 在同一台 NAS / Docker 网络里,`盘搜 API 地址` 要填 MoviePilot 那边能访问到的地址,不一定是你电脑能访问到的地址。 + +--- + +## Cookie 修复读的是哪台电脑 + +这些命令会用到浏览器 Cookie: + +```text +刷新影巢Cookie +修复影巢签到 +刷新夸克Cookie +修复夸克转存 +``` + +跨机器时,它们读取的是**智能体所在电脑**的浏览器登录态,然后写回 NAS 上的 MoviePilot。 + +所以如果 MoviePilot 在 NAS、智能体在 Mac: + +1. 在 Mac 浏览器里登录 `https://hdhive.com` 或 `https://pan.quark.cn`。 +2. 再让智能体执行修复命令。 +3. 不需要去 NAS 桌面上找浏览器 Cookie。 + +--- + +## 最小验证 + +在智能体所在机器执行: + +```bash +python3 scripts/aro_request.py readiness +``` + +如果通过,说明智能体已经能访问 MoviePilot 插件。 + +再试一个只读命令: + +```bash +python3 scripts/aro_request.py route "115状态" +``` + +如果也能返回,跨机器主链基本就通了。 + +--- + +## 常见错误 + +### 1. NAS 环境还写 127.0.0.1 + +表现:智能体连接失败、请求打到自己电脑。 + +解决:把 `ARO_BASE_URL` 改成 NAS 的局域网 IP 或域名。 + +### 2. 改了仓库文件,但 MoviePilot 还在跑旧插件 + +仓库里的文件改完后,不等于容器里的插件已经更新。 + +如果页面或接口还是旧表现,先确认 MoviePilot 实际加载的是最新插件。 + +### 3. 长线程被旧上下文污染 + +表现: + +- `15详情` 被当成 `选择 15` +- 编号接到旧搜索结果 +- 明明更新了规则,智能体还是按旧说法执行 + +直接对智能体说: + +```text +校准影视技能 +``` + +不要在普通搜索前固定清会话,否则会破坏正常编号续接。 + +--- + +## 推荐阅读 + +- [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- 全部命令:`docs/ALL_COMMANDS.md` +- [插件安装说明](./PLUGIN_INSTALL.md) diff --git a/plugins/docs/MAINTENANCE_COMMANDS.md b/plugins/docs/MAINTENANCE_COMMANDS.md new file mode 100644 index 0000000..93602d9 --- /dev/null +++ b/plugins/docs/MAINTENANCE_COMMANDS.md @@ -0,0 +1,193 @@ +# 仓库维护命令索引 + +这份文档只列当前常用的仓库维护与发布命令,不解释历史方案。 + +## 当前状态 + +- 当前插件版本:`AgentResourceOfficer 0.2.68` +- 当前 Skill helper 版本:`0.1.46` +- 当前 Release: + +## 最常用入口 + +- 仓库卫生检查: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 发版前完整检查: + +```bash +bash scripts/release-preflight.sh +``` + +- 低层发布检查: + +```bash +bash scripts/pre-release-check.sh +``` + +## 推荐顺序 + +- 日常看状态或准备整理仓库: + +```bash +bash scripts/repo-hygiene.sh +``` + +- 想清理本地生成文件或顺手删除 `dist/`: + +```bash +bash scripts/clean-generated.sh +bash scripts/clean-generated.sh --dist +``` + +- 准备发版、打包、更新 Draft Release 之前: + +```bash +bash scripts/release-preflight.sh +``` + +- 准备在 GitHub 上创建或更新 Draft Release: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 想确认最近一次 GitHub Actions 产物是否完整: + +```bash +bash scripts/verify-release-preflight-artifact.sh +``` + +## 状态与审计 + +- 检查当前状态文档是否和代码版本一致: + +```bash +python3 scripts/check-doc-current-state.py +``` + +- 审计远端和本地历史分支: + +```bash +python3 scripts/audit-remote-branches.py +``` + +- 归档本地非 `main` 分支到 `archive/*` tag: + +```bash +python3 scripts/archive-local-branches.py +python3 scripts/archive-local-branches.py --apply +``` + +## 打包与发布 + +- 创建 Draft Release 前 dry-run: + +```bash +bash scripts/create-draft-release.sh --dry-run +``` + +- 创建 Draft Release: + +```bash +bash scripts/create-draft-release.sh +``` + +- 用当前 `dist/` 覆盖已有 Draft Release 附件: + +```bash +bash scripts/update-draft-release-assets.sh +``` + +- 校验公开 Release 下载附件: + +```bash +bash scripts/verify-release-download.sh +``` + +- 单独打包公开 Skill ZIP: + +```bash +bash scripts/package-skills.sh +``` + +## Artifact 与产物校验 + +- 下载并校验最近一次成功的 `Release Preflight` workflow artifact: + +```bash +bash scripts/verify-release-preflight-artifact.sh +bash scripts/verify-release-preflight-artifact.sh +``` + +- 校验本地 release 资产目录: + +```bash +bash scripts/verify-release-assets.sh +bash scripts/verify-release-assets.sh /path/to/release-assets +``` + +- 校验插件 ZIP: + +```bash +DIST_DIR=dist bash scripts/verify-dist.sh +``` + +- 校验 Skill ZIP: + +```bash +DIST_DIR=dist/skills bash scripts/verify-skill-dist.sh +``` + +## 汇总输出 + +- 打印插件 ZIP Markdown 表格: + +```bash +bash scripts/print-release-summary.sh +``` + +- 打印 Skill ZIP Markdown 表格: + +```bash +bash scripts/print-skill-release-summary.sh +``` + +- 生成 Release notes: + +```bash +bash scripts/generate-release-notes.sh +``` + +## 帮助 + +这些脚本现在都支持 `--help` 或 `-h`,包括: + +- `repo-hygiene.sh` +- `release-preflight.sh` +- `pre-release-check.sh` +- `check-skills.sh` +- `clean-generated.sh` +- `package-plugin.sh` +- `package-skills.sh` +- `sync-repo-layout.sh` +- `sync-package-v2.sh` +- `create-draft-release.sh` +- `update-draft-release-assets.sh` +- `generate-release-notes.sh` +- `write-dist-sha256.sh` +- `patch-p115strmhelper-mp-compat.sh` +- `verify-release-preflight-artifact.sh` +- `verify-ci-artifact.sh` +- `verify-release-download.sh` +- `verify-release-assets.sh` +- `verify-dist.sh` +- `verify-skill-dist.sh` +- `print-release-summary.sh` +- `print-skill-release-summary.sh` +- `check-doc-current-state.py` +- `audit-remote-branches.py` +- `archive-local-branches.py` diff --git a/plugins/docs/PLUGIN_INSTALL.md b/plugins/docs/PLUGIN_INSTALL.md new file mode 100644 index 0000000..5d42c27 --- /dev/null +++ b/plugins/docs/PLUGIN_INSTALL.md @@ -0,0 +1,172 @@ +# 插件安装说明 + +这份文档只讲普通用户怎么安装、先装什么、装完从哪里开始。 + +如果你只是新手,不需要看打包、发布、维护命令。 + +--- + +## 先装哪两个 + +优先安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这两个就是当前主线: + +- `Agent影视助手`:飞书命令入口、外部智能体入口、盘搜、影巢、115、夸克、MP/PT 下载。 +- `AI识别增强`:MoviePilot 原生识别失败时,用 LLM 做一层兜底。 + +旧插件可以先不装。 + +--- + +## 插件仓库安装 + +在 MoviePilot 插件市场里添加自定义插件仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +然后在插件市场安装: + +```text +Agent影视助手 +AI识别增强 +``` + +这是最推荐的安装方式。 + +--- + +## 本地 ZIP 安装 + +如果你拿到的是 Release 里的 ZIP 包,也可以在 MoviePilot 插件页本地上传安装。 + +普通用户只需要优先认这两个包: + +```text +AgentResourceOfficer-版本号.zip +AIRecognizerEnhancer-版本号.zip +``` + +其他旧插件包只用于兼容旧链路,新装一般不用优先安装。 + +--- + +## 装完 Agent影视助手后做什么 + +打开 `Agent影视助手` 设置页面,按你要用的功能填写: + +| 你想用的功能 | 需要配置 | +|---|---| +| 飞书命令入口 | 飞书应用的 `App ID` / `App Secret` | +| 盘搜搜索 | `盘搜 API 地址` | +| 影巢搜索 | `影巢 OpenAPI Key` | +| 115 转存 | `115 默认目录`,然后发 `115登录` 扫码 | +| 夸克转存 | 夸克 Cookie 或 CookieCloud | +| PT 下载 | 通常依赖 MoviePilot 原生下载器;MP 和 qB 不同机时可填 `PT 下载保存路径` | + +不用的功能可以先不填,插件会自动跳过。 + +--- + +## 不接智能体,只用飞书 + +如果你不使用外部智能体,只想把飞书当成命令入口: + +1. 在插件设置页配好飞书。 +2. 确认只保留一个飞书入口监听,避免旧飞书插件和新插件同时收消息。 +3. 直接在飞书里发命令。 + +常用命令: + +```text +云盘搜索 片名 +盘搜搜索 片名 +影巢搜索 片名 +转存 片名 +夸克转存 片名 +下载 片名 +更新检查 片名 +115登录 +影巢签到 +``` + +完整命令见:`docs/ALL_COMMANDS.md` + +--- + +## 接外部智能体 + +如果你要让 `OpenClaw`、`Hermes`、`WorkBuddy` 这类外部智能体控制 MoviePilot,安装插件后还要让智能体安装 `agent-resource-officer skill / helper`。 + +最短路径: + +1. MoviePilot 安装并启用 `Agent影视助手`。 +2. 把 [外部智能体接入](./AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) 里的提示词发给你的智能体。 +3. 智能体按文档安装 skill,并填写: + +```text +ARO_BASE_URL=http://你的MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS、智能体在 Win / Mac,请看: + +[跨机器部署](./AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) + +### MCP 怎么办 + +如果你的智能体客户端支持 MoviePilot 官方 MCP,也可以同时接: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +建议分工: + +- 查插件列表、下载器状态、站点状态、历史记录、工作流这类 MoviePilot 管理信息,可以优先用 MCP。 +- 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、Cookie 修复,继续优先用 `agent-resource-officer skill / helper`。 +- `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流,也继续优先交给 `agent-resource-officer`,避免智能体绕过插件规则。 + +--- + +## AI识别增强怎么用 + +`AI识别增强` 不需要额外 Gateway。 + +它直接复用 MoviePilot 当前已经启用的 LLM 配置,在原生文件名识别失败时做兜底,然后把结果交回 MoviePilot 原生整理链。 + +详细说明见:[AI识别增强](../AIRecognizerEnhancer/README.md) + +--- + +## 旧插件还要不要装 + +新装一般不需要优先安装旧插件。 + +| 旧插件 | 用途 | 建议 | +|---|---|---| +| `FeishuCommandBridgeLong` | 旧飞书入口 | 新环境优先用 Agent影视助手内置飞书入口 | +| `HdhiveOpenApi` | 旧影巢独立能力 | 主能力已收进 Agent影视助手 | +| `QuarkShareSaver` | 旧夸克独立转存 | 主能力已收进 Agent影视助手 | + +如果你是老环境迁移,可以暂时保留;如果是新装,先用 `Agent影视助手`。 + +--- + +## 维护者文档 + +如果你只是普通用户,到这里就够了。 + +如果你要打包、发布或维护仓库,再看: + +- [维护命令](./MAINTENANCE_COMMANDS.md) +- 发布检查:`docs/RELEASE_CHECKLIST.md` +- 打包说明:`docs/PACKAGING.md` diff --git a/plugins/feishucommandbridgelong/README.md b/plugins/feishucommandbridgelong/README.md new file mode 100644 index 0000000..66acbcf --- /dev/null +++ b/plugins/feishucommandbridgelong/README.md @@ -0,0 +1,109 @@ +# FeishuCommandBridgeLong + +MoviePilot 的飞书长连接桥接插件。当前定位是兼容/备份入口;新用户更推荐直接使用 `Agent影视助手` 内置的飞书入口。 + +## 这版的定位 + +- 保留旧飞书桥接的轻量远程操作体验 +- 作为迁移期兼容插件继续可用 +- 新功能优先进入 `Agent影视助手`,避免飞书入口和资源执行逻辑继续分叉 +- 如果只想装一个插件完成云盘资源整合 + 飞书入口,优先安装并开启 `Agent影视助手` 的内置飞书入口 + +## 当前能力 + +- 飞书长连接接收 `im.message.receive_v1` +- 智能单入口:自动识别片名、115 链接、夸克链接、盘搜搜索 +- 影巢两段式搜索:先选影片,再看资源 +- `详情` / `审查` / `n 下一页` 会话续接 +- MoviePilot 原生搜索、下载、订阅、订阅搜索 +- `P115StrmHelper` 的手动整理、增量 STRM、全量 STRM +- 115 扫码登录与状态查询 +- 待继续 115 任务查看、继续、取消 + +## 执行后端 + +- `旧桥接直连` + 适合保持现有飞书操作习惯,速度快。 +- `自动优先新主线,失败回落旧桥接` + 优先委托 `Agent影视助手`,失败再退回旧桥接。 +- `仅走 Agent影视助手 新主线` + 调试和后续统一主干时更合适。 + +日常老环境可以继续用 `旧桥接直连`。新环境建议改用 `Agent影视助手` 内置飞书入口;如果暂时仍使用本插件,建议切到 `仅走 Agent影视助手 新主线`,让资源动作统一落到 Agent影视助手。 + +## 新推荐入口 + +`Agent影视助手` 已内置可选 `Feishu Channel`,开启后可以直接接收飞书长连接消息,并复用同一套 `assistant/route`、`assistant/pick`、115 扫码和待任务续跑能力。 + +迁移建议: + +1. 在本插件里先关闭 `启用插件`。 +2. 到 `Agent影视助手` 中打开 `启用内置飞书入口`。 +3. 迁移同一组飞书 `App ID / App Secret / Verification Token / 白名单`。 +4. 确认 `GET /api/v1/plugin/AgentResourceOfficer/feishu/health` 显示运行正常。 + +## 常用飞书命令 + +```txt +处理 流浪地球2 +影巢搜索 流浪地球2 +yc流浪地球2 +2流浪地球2 + +盘搜搜索 流浪地球2 +ps流浪地球2 +1流浪地球2 + +链接 https://115cdn.com/s/xxxx path=/待整理 +链接 https://pan.quark.cn/s/xxxx path=/飞书 + +选择 1 +选择 1 path=/最新动画 + +详情 +审查 +n 下一页 +``` + +## 115 相关命令 + +```txt +115登录 +115扫码 +检查115登录 +115登录状态 +115状态 +115帮助 +115任务 +继续115任务 +取消115任务 +``` + +- 当飞书桥接走 `Agent影视助手` 新主线时,`115登录` 会直接拉起扫码登录流程 +- 如果飞书回复里带了二维码图片,直接用 115 App 扫码即可 +- 某次 115 转存因为登录或会话问题失败后,可直接回复 `115任务` 查看当前待处理任务 +- 登录成功后回复 `检查115登录`,会自动尝试继续上一次待处理的 115 任务 + +## 智能单入口说明 + +- 发片名:进入影巢或盘搜搜索流程 +- 发 115 / 夸克链接:自动识别并转存,其中 115 链接会优先委托 `Agent影视助手`,确保失败后的待任务、扫码续跑和取消任务都在同一条会话链里 +- `path=/目录`、`位置=目录` 都支持 +- 裸链接也支持,不一定要带 `处理` 或 `链接` 前缀 + +## 智能体 API + +插件提供两条更适合外部智能体调用的入口: + +```txt +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/route +POST /api/v1/plugin/FeishuCommandBridgeLong/assistant/pick +``` + +`route` 负责分流,`pick` 负责继续选择。飞书消息入口和这两条 API 用的是同一套会话逻辑。 + +## 依赖 + +```txt +lark-oapi==1.5.3 +``` diff --git a/plugins/feishucommandbridgelong/__init__.py b/plugins/feishucommandbridgelong/__init__.py new file mode 100644 index 0000000..4e1b478 --- /dev/null +++ b/plugins/feishucommandbridgelong/__init__.py @@ -0,0 +1,4111 @@ +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 diff --git a/plugins/feishucommandbridgelong/requirements.txt b/plugins/feishucommandbridgelong/requirements.txt new file mode 100644 index 0000000..db1f7ac --- /dev/null +++ b/plugins/feishucommandbridgelong/requirements.txt @@ -0,0 +1 @@ +lark-oapi==1.5.3 diff --git a/plugins/hdhiveopenapi/__init__.py b/plugins/hdhiveopenapi/__init__.py new file mode 100644 index 0000000..f7627b2 --- /dev/null +++ b/plugins/hdhiveopenapi/__init__.py @@ -0,0 +1,2012 @@ +import importlib +import json +from dataclasses import asdict, is_dataclass +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse +from zoneinfo import ZoneInfo + +import requests +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger +from fastapi import Request + +try: + from app.chain.media import MediaChain +except Exception: + MediaChain = None + +from app.core.config import settings +from app.log import logger +from app.plugins import _PluginBase + +try: + from app.schemas import NotificationType +except Exception: + NotificationType = None + + +class HdhiveOpenApi(_PluginBase): + plugin_name = "影巢 OpenAPI" + plugin_desc = "通过 HDHive Open API 完成签到、关键词/TMDB 搜索、资源解锁、115 转存、分享管理与配额查询。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/hdhive.ico" + plugin_version = "0.3.0" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "hdhiveopenapi_" + plugin_order = 30 + auth_level = 1 + + _enabled = False + _notify = True + _onlyonce = False + _cron = "0 8 * * *" + _api_key = "" + _base_url = "https://hdhive.com" + _gambler_mode = False + _timeout = 30 + _history_days = 30 + + _search_media_type = "movie" + _search_tmdb_id = "" + _search_once = False + + _unlock_slug = "" + _unlock_once = False + _transfer_115_enabled = False + _transfer_115_path = "/待整理" + _auto_transfer_115_on_unlock = False + _transfer_115_once = False + + _share_action = "list" + _share_slug = "" + _share_page = 1 + _share_page_size = 10 + _share_payload = "" + _share_once = False + + _scheduler: Optional[BackgroundScheduler] = None + + _history_key = "checkin_history" + _account_key = "last_account" + _quota_key = "last_quota" + _usage_today_key = "last_usage_today" + _usage_key = "last_usage" + _weekly_quota_key = "last_weekly_quota" + _search_key = "last_resource_search" + _unlock_key = "last_resource_unlock" + _transfer_115_key = "last_transfer_115" + _check_resource_key = "last_check_resource" + _shares_list_key = "last_shares_list" + _share_detail_key = "last_share_detail" + _share_action_key = "last_share_action" + _ping_key = "last_ping" + _last_error_key = "last_error" + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _normalize_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _normalize_slug(value: Any) -> str: + return str(value or "").strip().replace("-", "") + + @staticmethod + def _normalize_pan_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("/"): + text = f"/{text}" + return text.rstrip("/") or "/" + + @staticmethod + def _media_type_text(value: Any) -> str: + if value is None: + return "" + raw = str(getattr(value, "value", value)).strip().lower() + mapping = { + "电影": "movie", + "movie": "movie", + "电视剧": "tv", + "tv": "tv", + } + return mapping.get(raw, raw) + + @staticmethod + def _coerce_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + def _tz_now(self) -> datetime: + try: + return datetime.now(ZoneInfo(settings.TZ)) + except Exception: + return datetime.now() + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "cron": self._cron, + "api_key": self._api_key, + "base_url": self._base_url, + "gambler_mode": self._gambler_mode, + "timeout": self._timeout, + "history_days": self._history_days, + "search_media_type": self._search_media_type, + "search_tmdb_id": self._search_tmdb_id, + "search_once": self._search_once, + "unlock_slug": self._unlock_slug, + "unlock_once": self._unlock_once, + "transfer_115_enabled": self._transfer_115_enabled, + "transfer_115_path": self._transfer_115_path, + "auto_transfer_115_on_unlock": self._auto_transfer_115_on_unlock, + "transfer_115_once": self._transfer_115_once, + "share_action": self._share_action, + "share_slug": self._share_slug, + "share_page": self._share_page, + "share_page_size": self._share_page_size, + "share_payload": self._share_payload, + "share_once": self._share_once, + } + if overrides: + config.update(overrides) + return config + + def _save_state(self, key: str, value: Any) -> None: + try: + self.save_data(key=key, value=value) + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 保存状态失败 {key}: {exc}") + + def _load_state(self, key: str, default: Any = None) -> Any: + try: + value = self.get_data(key) + return default if value is None else value + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 读取状态失败 {key}: {exc}") + return default + + def _mask_secret(self, value: str, prefix: int = 4, suffix: int = 4) -> str: + if not value: + return "" + if len(value) <= prefix + suffix: + return "*" * len(value) + return f"{value[:prefix]}{'*' * (len(value) - prefix - suffix)}{value[-suffix:]}" + + def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None: + self._save_state( + self._last_error_key, + { + "action": action, + "message": message, + "payload": payload or {}, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + + def _is_115_share_url(self, url: str) -> bool: + host = urlparse(url).netloc.lower() + return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host + + def _ensure_115_share_url(self, url: str, access_code: str = "") -> str: + clean_url = self._normalize_text(url) + if not clean_url: + return "" + access_code = self._normalize_text(access_code) + parsed = urlparse(clean_url) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if access_code and "password" not in query: + query["password"] = access_code + clean_url = urlunparse(parsed._replace(query=urlencode(query))) + return clean_url + + @staticmethod + def _jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool, list, dict)): + return value + if is_dataclass(value): + return asdict(value) + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + if hasattr(value, "__dict__"): + return {k: v for k, v in vars(value).items() if not k.startswith("_")} + return str(value) + + def _get_p115_share_helper(self) -> Tuple[Optional[Any], Optional[str]]: + try: + service_module = importlib.import_module("app.plugins.p115strmhelper.service") + except Exception as exc: + return None, f"P115StrmHelper 未安装或无法导入: {exc}" + + servicer = getattr(service_module, "servicer", None) + if not servicer: + return None, "P115StrmHelper 未初始化" + if not getattr(servicer, "client", None): + return None, "P115StrmHelper 未登录 115 或客户端不可用" + helper = getattr(servicer, "sharetransferhelper", None) + if not helper: + return None, "P115StrmHelper 分享转存模块不可用" + return helper, None + + def _base_headers(self) -> Dict[str, str]: + return { + "X-API-Key": self._api_key, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": getattr(settings, "USER_AGENT", "MoviePilot"), + } + + def _api_url(self, path: str) -> str: + return f"{self._base_url.rstrip('/')}{path}" + + def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + payload: Optional[Dict[str, Any]] = None, + timeout: Optional[int] = None, + ) -> Tuple[bool, Dict[str, Any], str, int]: + if not self._api_key: + return False, {}, "未配置影巢 API Key", 400 + + try: + response = requests.request( + method=method.upper(), + url=self._api_url(path), + headers=self._base_headers(), + params=params, + json=payload if payload is not None else None, + timeout=timeout or self._timeout, + proxies=getattr(settings, "PROXY", None), + ) + except Exception as exc: + return False, {}, f"请求异常: {exc}", 0 + + try: + result = response.json() + except Exception: + result = { + "success": False, + "message": response.text[:300] if response.text else f"HTTP {response.status_code}", + "description": "接口未返回有效 JSON", + } + + if response.ok and isinstance(result, dict) and result.get("success", True): + return True, result, "", response.status_code + + message = "" + if isinstance(result, dict): + message = ( + result.get("description") + or result.get("message") + or result.get("code") + or f"HTTP {response.status_code}" + ) + if not message: + message = f"HTTP {response.status_code}" + return False, result if isinstance(result, dict) else {}, message, response.status_code + + def _notify_message(self, title: str, text: str) -> None: + if not self._notify: + return + if not hasattr(self, "post_message"): + return + try: + if NotificationType is not None: + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + else: + self.post_message(title=title, text=text) + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 发送通知失败: {exc}") + + def _append_history(self, record: Dict[str, Any]) -> None: + history = self._load_state(self._history_key, default=[]) or [] + history.append(record) + now = self._tz_now() + valid_history: List[Dict[str, Any]] = [] + for item in history: + date_text = str(item.get("time") or item.get("date") or "").strip() + if not date_text: + continue + try: + item_dt = datetime.strptime(date_text, "%Y-%m-%d %H:%M:%S") + except Exception: + valid_history.append(item) + continue + if (now.replace(tzinfo=None) - item_dt).days < self._history_days: + valid_history.append(item) + self._save_state(self._history_key, valid_history[-100:]) + + def _refresh_snapshots(self, silent: bool = False) -> None: + ok, data, message = self.ping(remember=True) + if not ok and not silent: + self._remember_error("ping", message, data) + return + self.fetch_me(remember=True) + self.fetch_quota(remember=True) + self.fetch_usage_today(remember=True) + self.fetch_weekly_free_quota(remember=True) + + def init_plugin(self, config: dict = None): + self.stop_service() + + config = config or {} + self._enabled = bool(config.get("enabled")) + self._notify = bool(config.get("notify", True)) + self._onlyonce = bool(config.get("onlyonce")) + self._cron = self._normalize_text(config.get("cron")) or "0 8 * * *" + self._api_key = self._normalize_text(config.get("api_key")) + self._base_url = (self._normalize_text(config.get("base_url")) or "https://hdhive.com").rstrip("/") + self._gambler_mode = bool(config.get("gambler_mode")) + self._timeout = self._safe_int(config.get("timeout"), 30) + self._history_days = self._safe_int(config.get("history_days"), 30) + + self._search_media_type = self._normalize_text(config.get("search_media_type")) or "movie" + if self._search_media_type not in {"movie", "tv"}: + self._search_media_type = "movie" + self._search_tmdb_id = self._normalize_text(config.get("search_tmdb_id")) + self._search_once = bool(config.get("search_once")) + + self._unlock_slug = self._normalize_slug(config.get("unlock_slug")) + self._unlock_once = bool(config.get("unlock_once")) + self._transfer_115_enabled = bool(config.get("transfer_115_enabled")) + self._transfer_115_path = self._normalize_pan_path(config.get("transfer_115_path")) or "/待整理" + self._auto_transfer_115_on_unlock = bool(config.get("auto_transfer_115_on_unlock")) + self._transfer_115_once = bool(config.get("transfer_115_once")) + + self._share_action = self._normalize_text(config.get("share_action")) or "list" + if self._share_action not in {"list", "detail", "create", "update", "delete"}: + self._share_action = "list" + self._share_slug = self._normalize_slug(config.get("share_slug")) + self._share_page = max(1, self._safe_int(config.get("share_page"), 1)) + self._share_page_size = min(100, max(1, self._safe_int(config.get("share_page_size"), 10))) + self._share_payload = str(config.get("share_payload") or "").strip() + self._share_once = bool(config.get("share_once")) + + if self._enabled and self._api_key: + self._refresh_snapshots(silent=True) + + scheduled_jobs: List[Tuple[str, Any]] = [] + reset_config: Dict[str, Any] = {} + if self._onlyonce: + scheduled_jobs.append(("影巢 OpenAPI 立即签到", self._run_checkin_once)) + reset_config["onlyonce"] = False + self._onlyonce = False + if self._search_once: + scheduled_jobs.append(("影巢 OpenAPI 资源查询", self._run_search_once)) + reset_config["search_once"] = False + self._search_once = False + if self._unlock_once: + scheduled_jobs.append(("影巢 OpenAPI 资源解锁", self._run_unlock_once)) + reset_config["unlock_once"] = False + self._unlock_once = False + if self._transfer_115_once: + scheduled_jobs.append(("影巢 OpenAPI 转存到115", self._run_transfer_115_once)) + reset_config["transfer_115_once"] = False + self._transfer_115_once = False + if self._share_once: + scheduled_jobs.append(("影巢 OpenAPI 分享操作", self._run_share_once)) + reset_config["share_once"] = False + self._share_once = False + + if scheduled_jobs: + self._scheduler = BackgroundScheduler(timezone=getattr(settings, "TZ", "Asia/Shanghai")) + base_time = self._tz_now() + for index, (job_name, func) in enumerate(scheduled_jobs): + self._scheduler.add_job( + func=func, + trigger="date", + run_date=base_time + timedelta(seconds=3 + index), + name=job_name, + ) + self._scheduler.start() + + if reset_config: + self.update_config(self._build_config(reset_config)) + + def get_state(self) -> bool: + return self._enabled and bool(self._api_key) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled or not self._api_key or not self._cron: + return [] + return [ + { + "id": "hdhiveopenapi_checkin", + "name": "影巢 OpenAPI 每日签到", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self._scheduled_checkin, + "kwargs": {}, + } + ] + + def stop_service(self): + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._scheduler.shutdown(wait=False) + except Exception as exc: + logger.warning(f"[HdhiveOpenApi] 停止调度器失败: {exc}") + finally: + self._scheduler = None + + def ping(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/ping") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._ping_key, result) + if not ok: + self._remember_error("ping", message, payload) + return ok, result, message + + def fetch_me(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/me") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._account_key, result) + if not ok: + self._remember_error("me", message, payload) + return ok, result, message + + def fetch_quota(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/quota") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._quota_key, result) + if not ok: + self._remember_error("quota", message, payload) + return ok, result, message + + def fetch_usage(self, start_date: str = "", end_date: str = "", remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + params: Dict[str, Any] = {} + if start_date: + params["start_date"] = start_date + if end_date: + params["end_date"] = end_date + ok, payload, message, status_code = self._request("GET", "/api/open/usage", params=params or None) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": params, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._usage_key, result) + if not ok: + self._remember_error("usage", message, payload) + return ok, result, message + + def fetch_usage_today(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/usage/today") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._usage_today_key, result) + if not ok: + self._remember_error("usage_today", message, payload) + return ok, result, message + + def fetch_weekly_free_quota(self, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("GET", "/api/open/vip/weekly-free-quota") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._weekly_quota_key, result) + if not ok: + self._remember_error("weekly_free_quota", message, payload) + return ok, result, message + + def perform_checkin( + self, + *, + is_gambler: Optional[bool] = None, + remember: bool = True, + trigger: str = "手动", + ) -> Tuple[bool, Dict[str, Any], str]: + gambler_mode = self._gambler_mode if is_gambler is None else bool(is_gambler) + payload = {"is_gambler": gambler_mode} if gambler_mode else None + ok, result_payload, message, status_code = self._request("POST", "/api/open/checkin", payload=payload) + data = result_payload.get("data") if isinstance(result_payload, dict) else {} + checked_in = bool((data or {}).get("checked_in")) if ok else False + status_text = "签到成功" if checked_in else "今日已签到" + if not ok: + status_text = "签到失败" + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "trigger": trigger, + "is_gambler": gambler_mode, + "status": status_text, + "message": (data or {}).get("message") or result_payload.get("message") or message, + "data": data or {}, + } + if remember: + self._append_history(result) + if ok: + self.fetch_me(remember=True) + self.fetch_weekly_free_quota(remember=True) + else: + self._remember_error("checkin", message, result_payload) + + if ok: + title = "【影巢 OpenAPI 签到】" + text = ( + f"时间:{result['time']}\n" + f"方式:{trigger}\n" + f"模式:{'赌狗签到' if gambler_mode else '普通签到'}\n" + f"结果:{result['status']}\n" + f"详情:{result['message']}" + ) + self._notify_message(title, text) + return ok, result, message + + def search_resources(self, media_type: str, tmdb_id: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + media_type = (media_type or "").strip().lower() + tmdb_id = self._normalize_text(tmdb_id) + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "媒体类型必须是 movie 或 tv" + if not tmdb_id: + return False, {"message": "TMDB ID 不能为空", "query": {"media_type": media_type, "tmdb_id": tmdb_id}}, "TMDB ID 不能为空" + + ok, payload, message, status_code = self._request("GET", f"/api/open/resources/{media_type}/{tmdb_id}") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"media_type": media_type, "tmdb_id": tmdb_id}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._search_key, result) + if not ok: + self._remember_error("resources_search", message, payload) + return ok, result, message + + def _resource_sort_key(self, item: Dict[str, Any]) -> Tuple[int, int, int, int, str]: + pan = str(item.get("pan_type") or "").lower() + points = item.get("unlock_points") + try: + points_value = int(points) if points is not None and str(points) != "" else 0 + except Exception: + points_value = 9999 + validate = str(item.get("validate_status") or "").lower() + resolutions = [str(v).upper() for v in (item.get("video_resolution") or [])] + sources = [str(v) for v in (item.get("source") or [])] + pan_rank = 0 if pan == "115" else 1 + points_rank = 0 if points_value <= 0 else 1 + validate_rank = 0 if validate in {"valid", ""} else 1 + resolution_rank = 0 if "4K" in resolutions else 1 if "1080P" in resolutions else 2 + source_rank = 0 if "蓝光原盘/REMUX" in sources else 1 if "WEB-DL/WEBRip" in sources else 2 + return (pan_rank, points_rank, validate_rank, resolution_rank + source_rank, str(item.get("title") or "")) + + async def search_resources_by_keyword( + self, + keyword: str, + media_type: str = "movie", + year: str = "", + candidate_limit: int = 5, + result_limit: int = 10, + remember: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + keyword = self._normalize_text(keyword) + media_type = self._normalize_text(media_type).lower() or "movie" + year = self._normalize_text(year) + candidate_limit = min(10, max(1, self._safe_int(candidate_limit, 5))) + result_limit = min(50, max(1, self._safe_int(result_limit, 10))) + + if not keyword: + return False, {"message": "keyword 不能为空", "query": {"keyword": "", "media_type": media_type}}, "keyword 不能为空" + if media_type not in {"movie", "tv"}: + return False, {"message": "媒体类型必须是 movie 或 tv", "query": {"keyword": keyword, "media_type": media_type}}, "媒体类型必须是 movie 或 tv" + if MediaChain is None: + return False, {"message": "MoviePilot MediaChain 不可用", "query": {"keyword": keyword, "media_type": media_type}}, "MoviePilot MediaChain 不可用" + + try: + _, medias = await MediaChain().async_search(title=keyword) + except Exception as exc: + return False, {"message": f"TMDB 解析失败: {exc}", "query": {"keyword": keyword, "media_type": media_type}}, f"TMDB 解析失败: {exc}" + + candidates: List[Dict[str, Any]] = [] + for media in medias or []: + item_type = self._media_type_text(getattr(media, "type", "")) + item_year = self._normalize_text(getattr(media, "year", "")) + if media_type and item_type and item_type != media_type: + continue + if year and item_year and item_year != year: + continue + tmdb_id = getattr(media, "tmdb_id", None) + if not tmdb_id: + continue + candidates.append( + { + "title": getattr(media, "title", "") or getattr(media, "en_title", "") or "", + "year": item_year, + "media_type": item_type or media_type, + "tmdb_id": tmdb_id, + "poster_path": getattr(media, "poster_path", "") or "", + } + ) + if len(candidates) >= candidate_limit: + break + + if not candidates: + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "status_code": 404, + "message": "未找到可用于影巢搜索的 TMDB 候选", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": [], + "data": [], + "meta": {"total": 0}, + } + if remember: + self._save_state(self._search_key, result) + return False, result, result["message"] + + merged_items: List[Dict[str, Any]] = [] + seen_slugs: set[str] = set() + last_status = 200 + + for candidate in candidates: + ok, payload, message = self.search_resources( + media_type=candidate["media_type"] or media_type, + tmdb_id=str(candidate["tmdb_id"]), + remember=False, + ) + last_status = payload.get("status_code", last_status) if isinstance(payload, dict) else last_status + if not ok: + continue + for resource in payload.get("data") or []: + slug = self._normalize_slug(resource.get("slug")) + if not slug or slug in seen_slugs: + continue + seen_slugs.add(slug) + annotated = dict(resource) + annotated["matched_tmdb_id"] = candidate["tmdb_id"] + annotated["matched_title"] = candidate["title"] + annotated["matched_year"] = candidate["year"] + merged_items.append(annotated) + + merged_items.sort(key=self._resource_sort_key) + merged_items = merged_items[:result_limit] + + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": bool(merged_items), + "status_code": last_status, + "message": "success" if merged_items else "已解析 TMDB,但影巢暂无匹配资源", + "query": {"keyword": keyword, "media_type": media_type, "year": year}, + "candidates": candidates, + "data": merged_items, + "meta": {"total": len(merged_items), "candidate_count": len(candidates)}, + } + if remember: + self._save_state(self._search_key, result) + if not merged_items: + self._remember_error("resources_search_keyword", result["message"], result) + return bool(merged_items), result, result["message"] + + def unlock_resource( + self, + slug: str, + remember: bool = True, + *, + transfer_115: bool = False, + transfer_path: str = "", + ) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + + ok, payload, message, status_code = self._request( + "POST", + "/api/open/resources/unlock", + payload={"slug": slug}, + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + should_transfer = bool(ok and transfer_115) + if should_transfer: + unlock_data = result.get("data") or {} + transfer_ok, transfer_result, transfer_message = self.transfer_115_share( + url=unlock_data.get("full_url") or unlock_data.get("url") or "", + access_code=unlock_data.get("access_code") or "", + path=transfer_path or self._transfer_115_path, + remember=True, + trigger="解锁后自动转存", + ) + result["transfer_115"] = transfer_result + if not transfer_ok: + result["transfer_115_message"] = transfer_message + if remember: + self._save_state(self._unlock_key, result) + if ok: + self.fetch_me(remember=True) + else: + self._remember_error("resources_unlock", message, payload) + return ok, result, message + + def transfer_115_share( + self, + *, + url: str = "", + access_code: str = "", + path: str = "", + remember: bool = True, + trigger: str = "手动转存", + ) -> Tuple[bool, Dict[str, Any], str]: + transfer_path = self._normalize_pan_path(path) or self._transfer_115_path or "/待整理" + unlock_snapshot = self._load_state(self._unlock_key, {}) or {} + unlock_data = unlock_snapshot.get("data") or {} + share_url = self._ensure_115_share_url( + url or unlock_data.get("full_url") or unlock_data.get("url") or "", + access_code or unlock_data.get("access_code") or "", + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": False, + "trigger": trigger, + "path": transfer_path, + "url": share_url, + "message": "", + "data": {}, + } + if not share_url: + result["message"] = "没有可用于 115 转存的解锁链接" + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + if not self._is_115_share_url(share_url): + result["message"] = "当前解锁结果不是 115 分享链接,无法直接转存到 115" + if remember: + self._save_state(self._transfer_115_key, result) + return False, result, result["message"] + + helper, helper_error = self._get_p115_share_helper() + if helper_error or not helper: + result["message"] = helper_error or "P115StrmHelper 不可用" + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + + try: + transfer_result = helper.add_share_115( + share_url, + notify=False, + pan_path=transfer_path, + ) + except Exception as exc: + result["message"] = f"调用 P115StrmHelper 转存失败: {exc}" + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + + if not transfer_result or not transfer_result[0]: + error_message = "" + if isinstance(transfer_result, tuple): + if len(transfer_result) > 2: + error_message = self._normalize_text(transfer_result[2]) + elif len(transfer_result) > 1: + error_message = self._normalize_text(transfer_result[1]) + result["message"] = error_message or "115 转存失败" + result["data"] = {"raw": self._jsonable(transfer_result)} + if remember: + self._save_state(self._transfer_115_key, result) + self._remember_error("transfer_115", result["message"], result) + return False, result, result["message"] + + media_info = transfer_result[1] if len(transfer_result) > 1 else None + save_parent = transfer_result[2] if len(transfer_result) > 2 else transfer_path + parent_id = transfer_result[3] if len(transfer_result) > 3 else None + result.update( + { + "ok": True, + "message": "115 转存成功", + "data": { + "media_info": self._jsonable(media_info), + "save_parent": save_parent, + "parent_id": parent_id, + }, + } + ) + if remember: + self._save_state(self._transfer_115_key, result) + return True, result, result["message"] + + def check_resource(self, url: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + clean_url = self._normalize_text(url) + if not clean_url: + return False, {"message": "url 不能为空", "url": ""}, "url 不能为空" + + ok, payload, message, status_code = self._request( + "POST", + "/api/open/check/resource", + payload={"url": clean_url}, + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "url": clean_url, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._check_resource_key, result) + if not ok: + self._remember_error("check_resource", message, payload) + return ok, result, message + + def list_shares(self, page: int = 1, page_size: int = 20, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + page = max(1, self._safe_int(page, 1)) + page_size = min(100, max(1, self._safe_int(page_size, 20))) + ok, payload, message, status_code = self._request( + "GET", + "/api/open/shares", + params={"page": page, "page_size": page_size}, + ) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "query": {"page": page, "page_size": page_size}, + "data": payload.get("data") if isinstance(payload, dict) else [], + "meta": payload.get("meta") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._shares_list_key, result) + if not ok: + self._remember_error("shares_list", message, payload) + return ok, result, message + + def get_share_detail(self, slug: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + + ok, payload, message, status_code = self._request("GET", f"/api/open/shares/{slug}") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "message": payload.get("message") if ok else message, + "slug": slug, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._share_detail_key, result) + if not ok: + self._remember_error("shares_detail", message, payload) + return ok, result, message + + def create_share(self, share_payload: Dict[str, Any], remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + ok, payload, message, status_code = self._request("POST", "/api/open/shares", payload=share_payload) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "action": "create", + "message": payload.get("message") if ok else message, + "payload": share_payload, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._share_action_key, result) + if ok: + self.fetch_me(remember=True) + else: + self._remember_error("shares_create", message, payload) + return ok, result, message + + def update_share(self, slug: str, share_payload: Dict[str, Any], remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self._request("PATCH", f"/api/open/shares/{slug}", payload=share_payload) + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "action": "update", + "slug": slug, + "message": payload.get("message") if ok else message, + "payload": share_payload, + "data": payload.get("data") if isinstance(payload, dict) else {}, + } + if remember: + self._save_state(self._share_action_key, result) + if not ok: + self._remember_error("shares_update", message, payload) + return ok, result, message + + def delete_share(self, slug: str, remember: bool = True) -> Tuple[bool, Dict[str, Any], str]: + slug = self._normalize_slug(slug) + if not slug: + return False, {"message": "slug 不能为空", "slug": ""}, "slug 不能为空" + ok, payload, message, status_code = self._request("DELETE", f"/api/open/shares/{slug}") + result = { + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + "ok": ok, + "status_code": status_code, + "action": "delete", + "slug": slug, + "message": payload.get("message") if ok else message, + "data": payload.get("data") if isinstance(payload, dict) else None, + } + if remember: + self._save_state(self._share_action_key, result) + if ok: + self.fetch_me(remember=True) + else: + self._remember_error("shares_delete", message, payload) + return ok, result, message + + def _scheduled_checkin(self) -> None: + self.perform_checkin(trigger="定时任务", remember=True) + + def _run_checkin_once(self) -> None: + self.perform_checkin(trigger="配置页立即运行", remember=True) + + def _run_search_once(self) -> None: + ok, result, message = self.search_resources(self._search_media_type, self._search_tmdb_id, remember=True) + if ok: + logger.info( + "[HdhiveOpenApi] 一次性资源查询完成: %s/%s, 返回 %s 条", + self._search_media_type, + self._search_tmdb_id, + len(result.get("data") or []), + ) + else: + logger.warning("[HdhiveOpenApi] 一次性资源查询失败: %s", message) + + def _run_unlock_once(self) -> None: + ok, _, message = self.unlock_resource( + self._unlock_slug, + remember=True, + transfer_115=self._auto_transfer_115_on_unlock, + transfer_path=self._transfer_115_path, + ) + if ok: + logger.info("[HdhiveOpenApi] 一次性资源解锁完成: %s", self._unlock_slug) + else: + logger.warning("[HdhiveOpenApi] 一次性资源解锁失败: %s", message) + + def _run_transfer_115_once(self) -> None: + ok, _, message = self.transfer_115_share( + path=self._transfer_115_path, + remember=True, + trigger="配置页立即转存", + ) + if ok: + logger.info("[HdhiveOpenApi] 一次性 115 转存完成: %s", self._transfer_115_path) + else: + logger.warning("[HdhiveOpenApi] 一次性 115 转存失败: %s", message) + + def _parse_share_payload(self) -> Tuple[bool, Dict[str, Any], str]: + if not self._share_payload.strip(): + return True, {}, "" + try: + payload = json.loads(self._share_payload) + except Exception as exc: + return False, {}, f"分享请求 JSON 解析失败: {exc}" + if not isinstance(payload, dict): + return False, {}, "分享请求 JSON 必须是对象" + return True, payload, "" + + def _run_share_once(self) -> None: + ok, payload, message = self._parse_share_payload() + if not ok: + self._remember_error("share_payload", message, {}) + logger.warning("[HdhiveOpenApi] 一次性分享操作失败: %s", message) + return + + action = self._share_action + if action == "list": + self.list_shares(page=self._share_page, page_size=self._share_page_size, remember=True) + return + if action == "detail": + self.get_share_detail(self._share_slug, remember=True) + return + if action == "create": + self.create_share(payload, remember=True) + return + if action == "update": + self.update_share(self._share_slug, payload, remember=True) + return + if action == "delete": + self.delete_share(self._share_slug, remember=True) + return + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "notify", "label": "签到发送通知"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "gambler_mode", "label": "默认赌狗签到"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "onlyonce", "label": "立即签到一次"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "api_key", + "label": "影巢 Open API Key", + "placeholder": "请输入影巢 API Key", + "type": "password", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "base_url", + "label": "影巢站点地址", + "placeholder": "https://hdhive.com", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VCronField", + "props": { + "model": "cron", + "label": "每日签到周期", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "timeout", + "label": "接口超时(秒)", + "type": "number", + "placeholder": "30", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "search_media_type", + "label": "资源查询类型", + "items": [ + {"title": "电影 movie", "value": "movie"}, + {"title": "剧集 tv", "value": "tv"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 5}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "search_tmdb_id", + "label": "查询 TMDB ID(可留空)", + "placeholder": "例如 550;留空时可直接用 API keyword 搜索", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "search_once", "label": "立即查询资源"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "unlock_slug", + "label": "解锁资源 slug", + "placeholder": "请输入 32 位资源 slug", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "unlock_once", "label": "立即解锁资源"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_115_enabled", "label": "启用 115 转存"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "transfer_115_path", + "label": "115 固定目录", + "placeholder": "/待整理/影巢", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "auto_transfer_115_on_unlock", "label": "解锁后自动转存"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_115_once", "label": "转存最近一次 115 链接"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VSelect", + "props": { + "model": "share_action", + "label": "分享操作", + "items": [ + {"title": "list 列表", "value": "list"}, + {"title": "detail 详情", "value": "detail"}, + {"title": "create 创建", "value": "create"}, + {"title": "update 更新", "value": "update"}, + {"title": "delete 删除", "value": "delete"}, + ], + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 3}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_slug", + "label": "分享 slug", + "placeholder": "detail/update/delete 时填写", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_page", + "label": "列表页码", + "type": "number", + "placeholder": "1", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_page_size", + "label": "每页条数", + "type": "number", + "placeholder": "10", + }, + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 2}, + "content": [ + {"component": "VSwitch", "props": {"model": "share_once", "label": "立即执行分享操作"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "share_payload", + "label": "分享请求 JSON", + "rows": 8, + "placeholder": "{\"tmdb_id\":\"550\",\"media_type\":\"movie\",\"title\":\"Fight Club 4K REMUX\",\"url\":\"https://pan.example.com/s/abc123\",\"access_code\":\"x1y2\",\"unlock_points\":10}", + "hint": "create/update 时填写 JSON。list/detail/delete 可留空。", + "persistent-hint": True, + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "核心能力已覆盖:用户信息、每日签到、资源查询与解锁、分享管理、用量与配额。\\n" + "新增:支持把解锁出来的 115 分享链接直接转存到固定目录。\\n" + "注意:只有解锁结果本身是 115 分享链接时才能直接转存,天翼/夸克/阿里等链接不会自动塞进 115。\\n" + "页面内的一次性操作适合联调;真正对外集成时,建议直接调用插件 API。\\n" + "插件 API 示例:\\n" + "GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&tmdb_id=550\\n" + "GET /api/v1/plugin/HdhiveOpenApi/resources/search?type=movie&keyword=超级马里奥兄弟大电影\\n" + "POST /api/v1/plugin/HdhiveOpenApi/resources/unlock\\n" + "POST /api/v1/plugin/HdhiveOpenApi/transfer/115\\n" + "GET /api/v1/plugin/HdhiveOpenApi/shares\\n" + "POST /api/v1/plugin/HdhiveOpenApi/shares/create" + ), + }, + } + ], + } + ], + }, + ], + } + ], self._build_config() + + def _build_key_value_card(self, title: str, rows: List[Tuple[str, Any]], md: int = 6) -> dict: + return { + "component": "VCol", + "props": {"cols": 12, "md": md}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": title}, + { + "component": "VCardText", + "content": [ + { + "component": "div", + "props": {"class": "text-body-2 py-1"}, + "text": f"{label}:{value if value not in (None, '') else '—'}", + } + for label, value in rows + ], + }, + ], + } + ], + } + + def _build_resource_rows(self, items: List[Dict[str, Any]]) -> List[dict]: + rows: List[dict] = [] + for item in items[:20]: + rows.append( + { + "component": "tr", + "content": [ + {"component": "td", "text": item.get("slug", "")}, + {"component": "td", "text": item.get("title", "—")}, + {"component": "td", "text": item.get("share_size", "—")}, + {"component": "td", "text": "/".join(item.get("source") or []) or "—"}, + {"component": "td", "text": "/".join(item.get("video_resolution") or []) or "—"}, + {"component": "td", "text": str(item.get("unlock_points", "0"))}, + {"component": "td", "text": "是" if item.get("is_unlocked") else "否"}, + {"component": "td", "text": "是" if item.get("is_official") else "否"}, + ], + } + ) + return rows + + def _build_share_rows(self, items: List[Dict[str, Any]]) -> List[dict]: + rows: List[dict] = [] + for item in items[:20]: + rows.append( + { + "component": "tr", + "content": [ + {"component": "td", "text": item.get("slug", "")}, + {"component": "td", "text": item.get("title", "—")}, + {"component": "td", "text": item.get("share_size", "—")}, + {"component": "td", "text": str(item.get("unlock_points", "0"))}, + {"component": "td", "text": str(item.get("unlocked_users_count", "0"))}, + {"component": "td", "text": item.get("created_at", "—")}, + ], + } + ) + return rows + + def get_page(self) -> List[dict]: + ping = self._load_state(self._ping_key, {}) or {} + account = self._load_state(self._account_key, {}) or {} + quota = self._load_state(self._quota_key, {}) or {} + usage_today = self._load_state(self._usage_today_key, {}) or {} + weekly_quota = self._load_state(self._weekly_quota_key, {}) or {} + search_result = self._load_state(self._search_key, {}) or {} + unlock_result = self._load_state(self._unlock_key, {}) or {} + transfer_115_result = self._load_state(self._transfer_115_key, {}) or {} + shares_list = self._load_state(self._shares_list_key, {}) or {} + share_detail = self._load_state(self._share_detail_key, {}) or {} + share_action = self._load_state(self._share_action_key, {}) or {} + last_error = self._load_state(self._last_error_key, {}) or {} + history = list(reversed(self._load_state(self._history_key, []) or []))[:20] + + user = (account.get("data") or {}) if isinstance(account, dict) else {} + user_meta = (user.get("user_meta") or {}) if isinstance(user, dict) else {} + quota_data = quota.get("data") or {} + usage_today_data = usage_today.get("data") or {} + weekly_data = weekly_quota.get("data") or {} + resource_items = search_result.get("data") or [] + share_items = shares_list.get("data") or [] + unlock_data = unlock_result.get("data") or {} + transfer_115_data = transfer_115_result.get("data") or {} + share_detail_data = share_detail.get("data") or {} + share_action_data = share_action.get("data") or {} + + history_rows = [ + { + "component": "tr", + "content": [ + {"component": "td", "text": item.get("time", "")}, + {"component": "td", "text": item.get("trigger", "—")}, + {"component": "td", "text": "赌狗" if item.get("is_gambler") else "普通"}, + {"component": "td", "text": item.get("status", "—")}, + {"component": "td", "text": item.get("message", "—")}, + ], + } + for item in history + ] + + page_content: List[dict] = [ + { + "component": "VContainer", + "content": [ + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "连接状态", + [ + ("启用", "是" if self._enabled else "否"), + ("API Key", self._mask_secret(self._api_key) or "未填写"), + ("Base URL", self._base_url), + ("最近 Ping", ping.get("time", "—")), + ("Ping 状态", "成功" if ping.get("ok") else (ping.get("message") or "未执行")), + ], + ), + self._build_key_value_card( + "用户信息", + [ + ("昵称", user.get("nickname", "—")), + ("用户名", user.get("username", "—")), + ("积分", user_meta.get("points", "—")), + ("VIP", "是" if user.get("is_vip") else "否"), + ("VIP 到期", user.get("vip_expiration_date", "—")), + ("累计签到", user_meta.get("signin_days_total", "—")), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "配额与今日用量", + [ + ("配额重置", quota_data.get("daily_reset", "—")), + ("接口上限", quota_data.get("endpoint_limit", "—")), + ("剩余配额", quota_data.get("endpoint_remaining", "—")), + ("今日总调用", usage_today_data.get("total_calls", "—")), + ("今日成功", usage_today_data.get("success_calls", "—")), + ("平均耗时(ms)", usage_today_data.get("avg_latency", "—")), + ], + ), + self._build_key_value_card( + "每周免费解锁额度", + [ + ("永久 VIP", "是" if weekly_data.get("is_forever_vip") else "否"), + ("周额度", weekly_data.get("limit", "—")), + ("本周已用", weekly_data.get("used", "—")), + ("剩余额度", weekly_data.get("remaining", "—")), + ("无限额度", "是" if weekly_data.get("unlimited") else "否"), + ("累积额度", weekly_data.get("bonus_quota", "—")), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": "签到历史"}, + { + "component": "VCardText", + "content": [ + { + "component": "VTable", + "props": {"density": "compact", "hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "时间"}, + {"component": "th", "text": "触发方式"}, + {"component": "th", "text": "模式"}, + {"component": "th", "text": "状态"}, + {"component": "th", "text": "说明"}, + ], + } + ], + }, + { + "component": "tbody", + "content": history_rows + or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 5}, "text": "暂无签到记录"}]}], + }, + ], + } + ], + }, + ], + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": "最近一次资源查询"}, + { + "component": "VCardSubtitle", + "text": ( + f"{search_result.get('time', '未执行')} | " + f"{(search_result.get('query') or {}).get('media_type', '—')} / " + f"{(search_result.get('query') or {}).get('tmdb_id', '—')} | " + f"{search_result.get('message', '—')}" + ), + }, + { + "component": "VCardText", + "content": [ + { + "component": "VTable", + "props": {"density": "compact", "hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "slug"}, + {"component": "th", "text": "标题"}, + {"component": "th", "text": "大小"}, + {"component": "th", "text": "片源"}, + {"component": "th", "text": "分辨率"}, + {"component": "th", "text": "解锁积分"}, + {"component": "th", "text": "已解锁"}, + {"component": "th", "text": "官方"}, + ], + } + ], + }, + { + "component": "tbody", + "content": self._build_resource_rows(resource_items) + or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 8}, "text": "暂无资源查询结果"}]}], + }, + ], + } + ], + }, + ], + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "最近一次资源解锁", + [ + ("时间", unlock_result.get("time", "—")), + ("slug", unlock_result.get("slug", "—")), + ("结果", unlock_result.get("message", "—")), + ("链接", unlock_data.get("url", "—")), + ("提取码", unlock_data.get("access_code", "—")), + ("完整链接", unlock_data.get("full_url", "—")), + ], + ), + self._build_key_value_card( + "最近一次 115 转存", + [ + ("时间", transfer_115_result.get("time", "—")), + ("触发方式", transfer_115_result.get("trigger", "—")), + ("结果", transfer_115_result.get("message", "—")), + ("目录", transfer_115_result.get("path", "—")), + ("保存位置", transfer_115_data.get("save_parent", "—")), + ("父目录 ID", transfer_115_data.get("parent_id", "—")), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + self._build_key_value_card( + "最近一次分享详情/操作", + [ + ("详情时间", share_detail.get("time", "—")), + ("详情标题", share_detail_data.get("title", "—")), + ("详情媒体", ((share_detail_data.get("media") or {}).get("title") if isinstance(share_detail_data.get("media"), dict) else "—") or "—"), + ("操作时间", share_action.get("time", "—")), + ("操作类型", share_action.get("action", "—")), + ("操作结果", share_action.get("message", "—")), + ("操作标题", share_action_data.get("title", "—") if isinstance(share_action_data, dict) else "—"), + ], + ), + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"flat": True, "border": True}, + "content": [ + {"component": "VCardTitle", "text": "最近一次分享列表"}, + { + "component": "VCardSubtitle", + "text": ( + f"{shares_list.get('time', '未执行')} | " + f"page={(shares_list.get('query') or {}).get('page', '—')} | " + f"size={(shares_list.get('query') or {}).get('page_size', '—')} | " + f"{shares_list.get('message', '—')}" + ), + }, + { + "component": "VCardText", + "content": [ + { + "component": "VTable", + "props": {"density": "compact", "hover": True}, + "content": [ + { + "component": "thead", + "content": [ + { + "component": "tr", + "content": [ + {"component": "th", "text": "slug"}, + {"component": "th", "text": "标题"}, + {"component": "th", "text": "大小"}, + {"component": "th", "text": "解锁积分"}, + {"component": "th", "text": "已解锁人数"}, + {"component": "th", "text": "创建时间"}, + ], + } + ], + }, + { + "component": "tbody", + "content": self._build_share_rows(share_items) + or [{"component": "tr", "content": [{"component": "td", "props": {"colspan": 6}, "text": "暂无分享列表结果"}]}], + }, + ], + } + ], + }, + ], + } + ], + } + ], + }, + ], + } + ] + + if last_error: + page_content[0]["content"].append( + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "warning", + "variant": "tonal", + "text": f"最近一次错误:{last_error.get('action', '—')} | {last_error.get('time', '—')} | {last_error.get('message', '—')}", + }, + } + ], + } + ], + } + ) + + return page_content + + def get_api(self) -> List[Dict[str, Any]]: + return [ + {"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查插件与 API Key 状态", "auth": "bear"}, + {"path": "/account", "endpoint": self.api_account, "methods": ["GET"], "summary": "获取当前用户信息", "auth": "bear"}, + {"path": "/checkin", "endpoint": self.api_checkin, "methods": ["POST"], "summary": "执行普通或赌狗签到", "auth": "bear"}, + {"path": "/quota", "endpoint": self.api_quota, "methods": ["GET"], "summary": "获取配额信息", "auth": "bear"}, + {"path": "/usage", "endpoint": self.api_usage, "methods": ["GET"], "summary": "获取用量统计", "auth": "bear"}, + {"path": "/usage/today", "endpoint": self.api_usage_today, "methods": ["GET"], "summary": "获取今日用量", "auth": "bear"}, + {"path": "/vip/weekly-free-quota", "endpoint": self.api_weekly_quota, "methods": ["GET"], "summary": "获取每周免费解锁额度", "auth": "bear"}, + {"path": "/resources/search", "endpoint": self.api_search_resources, "methods": ["GET"], "summary": "按 TMDB ID 或关键词搜索资源", "auth": "bear"}, + {"path": "/resources/unlock", "endpoint": self.api_unlock_resource, "methods": ["POST"], "summary": "按 slug 解锁资源", "auth": "bear"}, + {"path": "/transfer/115", "endpoint": self.api_transfer_115, "methods": ["POST"], "summary": "把 115 分享链接转存到固定目录", "auth": "bear"}, + {"path": "/resource/check", "endpoint": self.api_check_resource, "methods": ["POST"], "summary": "检测资源链接类型", "auth": "bear"}, + {"path": "/shares", "endpoint": self.api_list_shares, "methods": ["GET"], "summary": "获取我的分享列表", "auth": "bear"}, + {"path": "/shares/detail", "endpoint": self.api_share_detail, "methods": ["GET"], "summary": "获取分享详情", "auth": "bear"}, + {"path": "/shares/create", "endpoint": self.api_share_create, "methods": ["POST"], "summary": "创建分享", "auth": "bear"}, + {"path": "/shares/update", "endpoint": self.api_share_update, "methods": ["POST"], "summary": "更新分享", "auth": "bear"}, + {"path": "/shares/delete", "endpoint": self.api_share_delete, "methods": ["POST"], "summary": "删除分享", "auth": "bear"}, + ] + + async def api_health(self) -> Dict[str, Any]: + ok, result, message = self.ping(remember=False) + return { + "success": ok, + "message": result.get("message") or message or "success", + "data": { + "plugin_enabled": self._enabled, + "api_key_configured": bool(self._api_key), + "base_url": self._base_url, + "ping": result, + }, + } + + async def api_account(self) -> Dict[str, Any]: + ok, result, message = self.fetch_me(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_checkin(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + ok, result, message = self.perform_checkin( + is_gambler=self._coerce_bool(body.get("is_gambler"), self._gambler_mode), + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_quota(self) -> Dict[str, Any]: + ok, result, message = self.fetch_quota(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_usage(self, request: Request) -> Dict[str, Any]: + start_date = request.query_params.get("start_date", "") + end_date = request.query_params.get("end_date", "") + ok, result, message = self.fetch_usage(start_date=start_date, end_date=end_date, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_usage_today(self) -> Dict[str, Any]: + ok, result, message = self.fetch_usage_today(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_weekly_quota(self) -> Dict[str, Any]: + ok, result, message = self.fetch_weekly_free_quota(remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result.get("data") or {}} + + async def api_search_resources(self, request: Request) -> Dict[str, Any]: + media_type = request.query_params.get("type") or request.query_params.get("media_type") or "movie" + tmdb_id = request.query_params.get("tmdb_id", "") + keyword = request.query_params.get("keyword", "") + year = request.query_params.get("year", "") + candidate_limit = request.query_params.get("candidate_limit", "5") + result_limit = request.query_params.get("limit", "10") + if tmdb_id: + ok, result, message = self.search_resources(media_type=media_type, tmdb_id=tmdb_id, remember=True) + else: + ok, result, message = await self.search_resources_by_keyword( + keyword=keyword, + media_type=media_type, + year=year, + candidate_limit=self._safe_int(candidate_limit, 5), + result_limit=self._safe_int(result_limit, 10), + remember=True, + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_unlock_resource(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + slug = body.get("slug") or "" + transfer_115 = self._coerce_bool( + body.get("transfer_115"), + self._transfer_115_enabled and self._auto_transfer_115_on_unlock, + ) + transfer_path = body.get("path") or body.get("transfer_path") or self._transfer_115_path + ok, result, message = self.unlock_resource( + slug=slug, + remember=True, + transfer_115=transfer_115, + transfer_path=transfer_path, + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_transfer_115(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + ok, result, message = self.transfer_115_share( + url=body.get("url") or "", + access_code=body.get("access_code") or "", + path=body.get("path") or body.get("transfer_path") or self._transfer_115_path, + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_check_resource(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + url = body.get("url") or "" + ok, result, message = self.check_resource(url=url, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_list_shares(self, request: Request) -> Dict[str, Any]: + page = self._safe_int(request.query_params.get("page"), 1) + page_size = self._safe_int(request.query_params.get("page_size"), 20) + ok, result, message = self.list_shares(page=page, page_size=page_size, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_detail(self, request: Request) -> Dict[str, Any]: + slug = request.query_params.get("slug", "") + ok, result, message = self.get_share_detail(slug=slug, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_create(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + ok, result, message = self.create_share(body or {}, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_update(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + slug = body.pop("slug", "") if isinstance(body, dict) else "" + ok, result, message = self.update_share(slug=slug, share_payload=body or {}, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} + + async def api_share_delete(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + slug = body.get("slug", "") if isinstance(body, dict) else "" + ok, result, message = self.delete_share(slug=slug, remember=True) + return {"success": ok, "message": result.get("message") or message or "success", "data": result} diff --git a/plugins/quarksharesaver/README.md b/plugins/quarksharesaver/README.md new file mode 100644 index 0000000..1791c62 --- /dev/null +++ b/plugins/quarksharesaver/README.md @@ -0,0 +1,45 @@ +# QuarkShareSaver + +轻量夸克分享转存插件。 + +它只负责一件事: + +- 把夸克分享链接直接转存到你自己的夸克网盘目录 + +适合的调用方式: + +- 智能体调用插件 API +- 飞书桥接发送简短命令 + +推荐接口: + +- `GET /api/v1/plugin/QuarkShareSaver/health` +- `GET /api/v1/plugin/QuarkShareSaver/folders?path=/` +- `POST /api/v1/plugin/QuarkShareSaver/share/info` +- `POST /api/v1/plugin/QuarkShareSaver/transfer` + +`transfer` 请求体示例: + +```json +{ + "url": "https://pan.quark.cn/s/xxxxxxxx", + "access_code": "abcd", + "path": "/来自分享/夸克" +} +``` + +飞书推荐命令: + +```text +夸克转存 https://pan.quark.cn/s/xxxxxxxx pwd=abcd path=/最新动画 +``` + +配置重点: + +- `Cookie` 使用浏览器登录 `pan.quark.cn` 后复制完整 Cookie +- `默认保存目录` 建议填一个固定路径,例如 `/来自分享/夸克` + +这类轻插件更适合做“稳定执行层”: + +- 智能体负责理解意图和补参数 +- 插件负责真正转存 diff --git a/plugins/quarksharesaver/__init__.py b/plugins/quarksharesaver/__init__.py new file mode 100644 index 0000000..1369467 --- /dev/null +++ b/plugins/quarksharesaver/__init__.py @@ -0,0 +1,1113 @@ +import hmac +import json +import random +import re +import time +from datetime import datetime +from hashlib import md5 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlparse, urlencode +from urllib.request import Request as UrlRequest, urlopen +from fastapi import Request + +from app.log import logger +from app.plugins import _PluginBase + +try: + from app.core.config import settings +except Exception: + settings = None + +try: + from app.schemas import NotificationType +except Exception: + NotificationType = None + +try: + from app.utils.crypto import CryptoJsUtils +except Exception: + CryptoJsUtils = None + + +class QuarkShareSaver(_PluginBase): + plugin_name = "夸克分享转存" + plugin_desc = "把夸克分享链接直接转存到自己的夸克网盘目录,适合作为智能体和飞书的稳定执行入口。" + plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/quark.ico" + plugin_version = "0.1.0" + plugin_author = "liuyuexi1987" + plugin_level = 1 + author_url = "https://github.com/liuyuexi1987" + plugin_config_prefix = "quarksharesaver_" + plugin_order = 32 + auth_level = 1 + + _enabled = False + _notify = True + _cookie = "" + _default_target_path = "/飞书" + _timeout = 30 + _auto_import_cookiecloud = True + _import_cookiecloud_once = False + + _share_url = "" + _access_code = "" + _target_path = "" + _transfer_once = False + + _last_transfer_key = "last_transfer" + _last_error_key = "last_error" + _path_cache: Dict[str, str] = {"/": "0"} + + @staticmethod + def _clean_text(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + @staticmethod + def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except Exception: + return default + + @staticmethod + def _normalize_path(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "/" + if not text.startswith("/"): + text = f"/{text}" + text = re.sub(r"/+", "/", text) + return text.rstrip("/") or "/" + + def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + config = { + "enabled": self._enabled, + "notify": self._notify, + "cookie": self._cookie, + "default_target_path": self._default_target_path, + "timeout": self._timeout, + "auto_import_cookiecloud": self._auto_import_cookiecloud, + "import_cookiecloud_once": self._import_cookiecloud_once, + "share_url": self._share_url, + "access_code": self._access_code, + "target_path": self._target_path, + "transfer_once": self._transfer_once, + } + if overrides: + config.update(overrides) + return config + + def _tz_now(self) -> datetime: + if settings is not None: + try: + from zoneinfo import ZoneInfo + + return datetime.now(ZoneInfo(getattr(settings, "TZ", "Asia/Shanghai"))) + except Exception: + pass + return datetime.now() + + def _save_state(self, key: str, value: Any) -> None: + try: + self.save_data(key=key, value=value) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 保存状态失败 {key}: {exc}") + + def _load_state(self, key: str, default: Any = None) -> Any: + try: + value = self.get_data(key) + return default if value is None else value + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 读取状态失败 {key}: {exc}") + return default + + def _remember_error(self, action: str, message: str, payload: Optional[dict] = None) -> None: + self._save_state( + self._last_error_key, + { + "action": action, + "message": message, + "payload": payload or {}, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + + def _notify_message(self, title: str, text: str) -> None: + if not self._notify or not hasattr(self, "post_message"): + return + try: + if NotificationType is not None: + self.post_message(mtype=NotificationType.SiteMessage, title=title, text=text) + else: + self.post_message(title=title, text=text) + except Exception as exc: + logger.warning(f"[QuarkShareSaver] 发送通知失败: {exc}") + + def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: + if settings is None: + return "", "未获取到系统设置" + if CryptoJsUtils is None: + return "", "运行环境缺少 CookieCloud 解密依赖" + + key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) + password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) + cookie_path = getattr(settings, "COOKIE_PATH", None) + if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): + return "", "未启用本地 CookieCloud" + if not key or not password or not cookie_path: + return "", "CookieCloud 参数不完整" + + file_path = Path(cookie_path) / f"{key}.json" + if not file_path.exists(): + return "", f"未找到 CookieCloud 文件: {file_path.name}" + + try: + encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) + encrypted = encrypted_data.get("encrypted") + if not encrypted: + return "", "CookieCloud 文件缺少 encrypted 字段" + crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") + decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") + payload = json.loads(decrypted) + except Exception as exc: + return "", f"CookieCloud 解密失败: {exc}" + + contents = payload.get("cookie_data") if isinstance(payload, dict) else None + if not isinstance(contents, dict): + contents = payload if isinstance(payload, dict) else {} + + merged: Dict[str, str] = {} + for cookie_items in contents.values(): + if not isinstance(cookie_items, list): + continue + for item in cookie_items: + if not isinstance(item, dict): + continue + domain = self._clean_text(item.get("domain")).lower() + name = self._clean_text(item.get("name")) + value = self._clean_text(item.get("value")) + if "quark.cn" not in domain or not name: + continue + merged[name] = value + + if not merged: + return "", "CookieCloud 中没有 quark.cn 的 Cookie" + return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" + + def _try_import_cookiecloud_cookie(self, *, force: bool = False) -> Tuple[bool, str]: + if self._cookie and not force: + return True, "已存在 Cookie,跳过自动导入" + cookie, message = self._load_cookiecloud_quark_cookie() + if not cookie: + logger.info(f"[QuarkShareSaver] CookieCloud 导入未命中: {message}") + return False, message + self._cookie = cookie + logger.info(f"[QuarkShareSaver] 已从 CookieCloud 导入夸克 Cookie,长度: {len(cookie)}") + return True, "已从 CookieCloud 导入夸克 Cookie" + + @staticmethod + def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: + header = str(request.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header.split(" ", 1)[1].strip() + if body: + token = str(body.get("apikey") or body.get("api_key") or "").strip() + if token: + return token + return str(request.query_params.get("apikey") or "").strip() + + def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: + expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") + if not expected: + return False, "服务端未配置 API Token" + actual = self._extract_apikey(request, body) + if not hmac.compare_digest(actual, expected): + return False, "API Token 无效" + return True, "" + + @staticmethod + def _extract_url(raw_text: str) -> str: + match = re.search(r"https?://[^\s<>\"']+", raw_text) + if match: + return match.group(0).rstrip(".,);]") + return "" + + def _extract_share_info(self, share_text: str, access_code: str = "") -> Tuple[str, str, str]: + raw = self._clean_text(share_text) + share_url = self._extract_url(raw) or raw + parsed = urlparse(share_url) + pwd_id_match = re.search(r"/s/([^/?#]+)", parsed.path) + pwd_id = pwd_id_match.group(1).strip() if pwd_id_match else "" + + code = self._clean_text(access_code) + if not code: + query = dict(parse_qsl(parsed.query)) + code = self._clean_text(query.get("pwd") or query.get("passcode") or query.get("code")) + if not code and raw: + for token in raw.replace(share_url, " ").split(): + text = token.strip() + if not text: + continue + if "=" in text: + key, value = text.split("=", 1) + if key.strip().lower() in {"pwd", "passcode", "code", "提取码"}: + code = self._clean_text(value) + break + elif len(text) <= 8 and not text.startswith("/"): + code = text + break + + return share_url, pwd_id, code + + @staticmethod + def _is_quark_share_url(share_url: str) -> bool: + hostname = urlparse(share_url).hostname or "" + hostname = hostname.lower().strip(".") + return hostname.endswith("quark.cn") + + def _validate_share_url(self, share_url: str) -> Tuple[bool, str]: + if not share_url: + return False, "未识别到有效夸克分享链接" + if self._is_quark_share_url(share_url): + return True, "" + hostname = urlparse(share_url).hostname or "未知域名" + return False, f"当前链接域名为 {hostname},这不是夸克分享链接,请换成 pan.quark.cn 的分享链接" + + def _build_headers(self) -> Dict[str, str]: + return { + "Cookie": self._cookie, + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/137.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Origin": "https://pan.quark.cn", + "Referer": "https://pan.quark.cn/", + "Content-Type": "application/json;charset=UTF-8", + } + + def _request( + self, + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + allow_cookiecloud_retry: bool = True, + ) -> Tuple[bool, Dict[str, Any], str]: + final_url = url + if params: + query = urlencode([(key, "" if value is None else value) for key, value in params.items()]) + final_url = f"{url}?{query}" if query else url + payload = None + if json_body is not None: + payload = json.dumps(json_body).encode("utf-8") + try: + request = UrlRequest( + url=final_url, + data=payload, + headers=self._build_headers(), + method=method.upper(), + ) + with urlopen(request, timeout=self._timeout) as response: + status_code = getattr(response, "status", 200) + raw_body = response.read() + except HTTPError as exc: + status_code = exc.code + raw_body = exc.read() if hasattr(exc, "read") else b"" + except URLError as exc: + return False, {}, f"请求失败: {exc.reason}" + except Exception as exc: + return False, {}, f"请求失败: {exc}" + + try: + data = json.loads(raw_body.decode("utf-8")) + except Exception: + text = raw_body.decode("utf-8", errors="ignore")[:300] + return False, {}, f"接口返回非 JSON: HTTP {status_code} {text}" + + if status_code == 401 and allow_cookiecloud_retry and self._auto_import_cookiecloud: + imported, _ = self._try_import_cookiecloud_cookie(force=True) + if imported: + return self._request( + method, + url, + params=params, + json_body=json_body, + allow_cookiecloud_retry=False, + ) + + if status_code != 200: + return False, data if isinstance(data, dict) else {}, f"HTTP {status_code}" + + if isinstance(data, dict): + message = str(data.get("message") or data.get("msg") or "").strip() + ok = data.get("status") == 200 or data.get("code") == 0 or message == "ok" + if ok: + return True, data, "" + return False, data, message or "接口返回失败" + + return False, {}, "接口返回格式错误" + + @staticmethod + def _common_params() -> Dict[str, Any]: + now = int(time.time() * 1000) + return { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "__dt": random.randint(100, 9999), + "__t": now, + } + + def _get_stoken(self, pwd_id: str, access_code: str = "") -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token", + params=self._common_params(), + json_body={"pwd_id": pwd_id, "passcode": access_code or ""}, + ) + if not ok: + return False, "", message + + stoken = self._clean_text((data.get("data") or {}).get("stoken")) + if not stoken: + return False, "", "未获取到 stoken,可能是提取码错误或 Cookie 失效" + return True, stoken, "" + + def _get_share_items(self, pwd_id: str, stoken: str) -> Tuple[bool, List[Dict[str, Any]], str]: + items: List[Dict[str, Any]] = [] + page = 1 + while True: + params = self._common_params() + params.update( + { + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "force": "0", + "_page": str(page), + "_size": "50", + "_sort": "file_type:asc,updated_at:desc", + } + ) + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail", + params=params, + ) + if not ok: + return False, [], message + + payload = data.get("data") or {} + meta = data.get("metadata") or {} + current = payload.get("list") or [] + for item in current: + items.append( + { + "fid": str(item.get("fid") or ""), + "file_name": str(item.get("file_name") or ""), + "dir": bool(item.get("dir")), + "file_type": item.get("file_type"), + "pdir_fid": str(item.get("pdir_fid") or ""), + "share_fid_token": str(item.get("share_fid_token") or ""), + } + ) + + total = self._safe_int(meta.get("_total"), 0) + count = self._safe_int(meta.get("_count"), len(current)) + size = max(1, self._safe_int(meta.get("_size"), 50)) + if total <= len(items) or count < size: + break + page += 1 + + if not items: + return False, [], "分享链接为空,或当前账号无权查看内容" + return True, items, "" + + def _list_children(self, parent_fid: str) -> Tuple[bool, List[Dict[str, Any]], str]: + page = 1 + result: List[Dict[str, Any]] = [] + while True: + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "pdir_fid": parent_fid, + "_page": page, + "_size": 100, + "_fetch_total": 1, + "_fetch_sub_dirs": 0, + "_sort": "file_type:asc,updated_at:desc", + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/file/sort", + params=params, + ) + if not ok: + return False, [], message + + current = ((data.get("data") or {}).get("list")) or [] + for item in current: + result.append( + { + "fid": str(item.get("fid") or ""), + "name": str(item.get("file_name") or ""), + "dir": int(item.get("file_type") or 0) == 0, + "size": item.get("size") or 0, + "updated_at": item.get("updated_at") or 0, + } + ) + if len(current) < 100: + break + page += 1 + + return True, result, "" + + def _find_child_dir(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, items, message = self._list_children(parent_fid) + if not ok: + return False, "", message + for item in items: + if item.get("dir") and item.get("name") == name: + return True, str(item.get("fid") or ""), "" + return True, "", "" + + def _create_folder(self, parent_fid: str, name: str) -> Tuple[bool, str, str]: + ok, data, message = self._request( + "POST", + "https://pan.quark.cn/1/clouddrive/file/create", + json_body={ + "pdir_fid": parent_fid, + "file_name": name, + "dir_path": "", + "dir_init_lock": False, + }, + ) + if not ok: + return False, "", message + + folder = data.get("data") or {} + folder_id = self._clean_text(folder.get("fid") or folder.get("file_id")) + if not folder_id: + return False, "", "创建目录成功但未返回 fid" + return True, folder_id, "" + + def _ensure_target_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path or self._default_target_path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + ok, found_fid, message = self._create_folder(current_fid, part) + if not ok: + return False, "", f"创建目录失败 {built}: {message}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _resolve_existing_dir(self, path: str) -> Tuple[bool, str, str]: + normalized = self._normalize_path(path) + if normalized == "/": + return True, "0", normalized + cached = self._path_cache.get(normalized) + if cached: + return True, cached, normalized + + current_fid = "0" + built = "" + for part in [segment for segment in normalized.split("/") if segment]: + built = f"{built}/{part}" if built else f"/{part}" + cached = self._path_cache.get(built) + if cached: + current_fid = cached + continue + ok, found_fid, message = self._find_child_dir(current_fid, part) + if not ok: + return False, "", message + if not found_fid: + return False, "", f"目录不存在: {built}" + self._path_cache[built] = found_fid + current_fid = found_fid + return True, current_fid, normalized + + def _create_save_task( + self, + pwd_id: str, + stoken: str, + items: List[Dict[str, Any]], + to_pdir_fid: str, + ) -> Tuple[bool, str, str]: + fid_list = [str(item.get("fid") or "") for item in items if item.get("fid")] + fid_token_list = [ + str(item.get("share_fid_token") or "") + for item in items + if item.get("fid") and item.get("share_fid_token") + ] + if not fid_list or len(fid_list) != len(fid_token_list): + return False, "", "分享内容缺少 fid 或 share_fid_token,无法转存" + + params = self._common_params() + ok, data, message = self._request( + "POST", + "https://drive.quark.cn/1/clouddrive/share/sharepage/save", + params=params, + json_body={ + "fid_list": fid_list, + "fid_token_list": fid_token_list, + "to_pdir_fid": to_pdir_fid, + "pwd_id": pwd_id, + "stoken": stoken, + "pdir_fid": "0", + "scene": "link", + }, + ) + if not ok: + return False, "", message + + task_id = self._clean_text((data.get("data") or {}).get("task_id")) + if not task_id: + return False, "", "未获取到转存任务 ID" + return True, task_id, "" + + def _wait_task(self, task_id: str, retry: int = 20) -> Tuple[bool, Dict[str, Any], str]: + for index in range(retry): + time.sleep(1.0 if index == 0 else 1.5) + params = { + "pr": "ucpro", + "fr": "pc", + "uc_param_str": "", + "task_id": task_id, + "retry_index": index, + "__dt": 21192, + "__t": int(time.time() * 1000), + } + ok, data, message = self._request( + "GET", + "https://drive-pc.quark.cn/1/clouddrive/task", + params=params, + ) + if not ok: + return False, {}, message + + task = data.get("data") or {} + status = self._safe_int(task.get("status"), -1) + if status == 2: + return True, task, "" + if status in {3, 4, 5, 6, 7}: + return False, task, self._clean_text(task.get("message")) or "夸克任务执行失败" + + return False, {}, "等待夸克转存任务超时" + + def _check_cookie(self) -> Tuple[bool, str]: + ok, _, message = self._list_children("0") + if ok: + return True, "" + return False, message or "Cookie 校验失败" + + def transfer_share( + self, + share_text: str, + access_code: str = "", + target_path: str = "", + *, + remember: bool = True, + trigger: str = "插件 API", + ) -> Tuple[bool, Dict[str, Any], str]: + share_url, pwd_id, final_code = self._extract_share_info(share_text, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return False, {}, message + if not pwd_id: + return False, {}, "未识别到有效夸克分享链接" + + if not self._enabled: + return False, {}, "插件未启用" + if not self._cookie: + return False, {}, "未配置夸克 Cookie" + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + self._remember_error("get_stoken", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, share_items, message = self._get_share_items(pwd_id, stoken) + if not ok: + self._remember_error("get_share_items", message, {"pwd_id": pwd_id}) + return False, {}, message + + ok, target_fid, normalized_path = self._ensure_target_dir(target_path or self._default_target_path) + if not ok: + self._remember_error("ensure_target_dir", target_fid, {"path": target_path or self._default_target_path}) + return False, {}, target_fid + + ok, task_id, message = self._create_save_task(pwd_id, stoken, share_items, target_fid) + if not ok: + self._remember_error("create_save_task", message, {"pwd_id": pwd_id, "path": normalized_path}) + return False, {}, message + + ok, task, message = self._wait_task(task_id) + if not ok: + self._remember_error("wait_task", message, {"task_id": task_id}) + return False, {"task_id": task_id}, message + + item_names = [str(item.get("file_name") or "") for item in share_items if item.get("file_name")] + result = { + "share_url": share_url, + "pwd_id": pwd_id, + "access_code": final_code, + "target_path": normalized_path, + "target_fid": target_fid, + "task_id": task_id, + "saved_count": len(share_items), + "items": item_names[:20], + "task": task, + "trigger": trigger, + "time": self._tz_now().strftime("%Y-%m-%d %H:%M:%S"), + } + if remember: + self._save_state(self._last_transfer_key, result) + self._notify_message( + "夸克分享转存完成", + ( + f"保存目录:{normalized_path}\n" + f"任务ID:{task_id}\n" + f"顶层条目:{len(share_items)}" + ), + ) + return True, result, "success" + + def init_plugin(self, config: dict = None): + config = config or {} + self._enabled = bool(config.get("enabled")) + self._notify = bool(config.get("notify", True)) + self._cookie = self._clean_text(config.get("cookie")) + self._default_target_path = self._normalize_path(config.get("default_target_path") or "/飞书") + self._timeout = max(10, self._safe_int(config.get("timeout"), 30)) + self._auto_import_cookiecloud = bool(config.get("auto_import_cookiecloud", True)) + self._import_cookiecloud_once = bool(config.get("import_cookiecloud_once")) + + self._share_url = self._clean_text(config.get("share_url")) + self._access_code = self._clean_text(config.get("access_code")) + self._target_path = self._normalize_path(config.get("target_path") or self._default_target_path) + self._transfer_once = bool(config.get("transfer_once")) + self._path_cache = {"/": "0"} + + if self._import_cookiecloud_once or (self._auto_import_cookiecloud and not self._cookie): + imported_cookie, message = self._try_import_cookiecloud_cookie(force=self._import_cookiecloud_once) + if self._import_cookiecloud_once: + self._import_cookiecloud_once = False + self.update_config(self._build_config({"cookie": self._cookie, "import_cookiecloud_once": False})) + elif imported_cookie: + self.update_config(self._build_config({"cookie": self._cookie})) + if imported_cookie and self._notify: + self._notify_message("夸克 Cookie 已导入", message) + + if self._transfer_once: + self._transfer_once = False + self.update_config(self._build_config({"transfer_once": False})) + if self._enabled and self._share_url: + ok, _, message = self.transfer_share( + self._share_url, + access_code=self._access_code, + target_path=self._target_path, + remember=True, + trigger="插件页面立即转存", + ) + if not ok: + self._notify_message("夸克分享转存失败", message) + + def get_state(self) -> bool: + return self._enabled and bool(self._cookie) + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [] + + def get_api(self) -> List[Dict[str, Any]]: + return [ + {"path": "/health", "endpoint": self.api_health, "methods": ["GET"], "summary": "检查 Cookie 与默认目录状态"}, + {"path": "/folders", "endpoint": self.api_folders, "methods": ["GET"], "summary": "列出夸克网盘目录"}, + {"path": "/share/info", "endpoint": self.api_share_info, "methods": ["POST"], "summary": "解析夸克分享链接顶层条目"}, + {"path": "/transfer", "endpoint": self.api_transfer, "methods": ["POST"], "summary": "把夸克分享链接转存到指定目录"}, + ] + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + return [ + { + "component": "VForm", + "content": [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "notify", "label": "发送站内通知"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VTextField", "props": {"model": "timeout", "label": "请求超时(秒)", "type": "number"}} + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "auto_import_cookiecloud", "label": "Cookie 为空时自动从 CookieCloud 导入"} + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VSwitch", + "props": {"model": "import_cookiecloud_once", "label": "立即从 CookieCloud 重新导入一次"} + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextarea", + "props": { + "model": "cookie", + "label": "夸克 Cookie", + "rows": 4, + "placeholder": "浏览器登录 pan.quark.cn 后复制完整 Cookie", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "default_target_path", + "label": "默认保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VAlert", + "props": { + "type": "info", + "variant": "tonal", + "text": ( + "推荐给智能体或飞书调用的接口:\n" + "POST /api/v1/plugin/QuarkShareSaver/transfer\n" + "参数:url, access_code, path。\n" + "飞书建议命令:夸克转存 分享链接 pwd=提取码 path=/最新动画\n" + "如果你启用了本地 CookieCloud,插件可以自动导入 quark.cn Cookie。" + ), + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [ + {"component": "VSwitch", "props": {"model": "transfer_once", "label": "立即转存一次"}} + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 8}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "target_path", + "label": "本次保存目录", + "placeholder": "/来自分享/夸克", + }, + } + ], + }, + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "share_url", + "label": "夸克分享链接", + "placeholder": "https://pan.quark.cn/s/xxxx", + }, + } + ], + } + ], + }, + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VTextField", + "props": { + "model": "access_code", + "label": "提取码(可留空)", + "placeholder": "abcd", + }, + } + ], + } + ], + }, + ], + } + ], self._build_config() + + def get_page(self) -> List[dict]: + last_transfer = self._load_state(self._last_transfer_key, default={}) or {} + last_error = self._load_state(self._last_error_key, default={}) or {} + + transfer_lines = [ + f"最近一次:{last_transfer.get('time') or '暂无'}", + f"保存目录:{last_transfer.get('target_path') or '-'}", + f"任务ID:{last_transfer.get('task_id') or '-'}", + f"顶层条目:{last_transfer.get('saved_count') or 0}", + ] + if last_transfer.get("items"): + transfer_lines.append("示例条目:" + ", ".join(last_transfer.get("items")[:5])) + + error_lines = [ + f"最近错误动作:{last_error.get('action') or '暂无'}", + f"错误时间:{last_error.get('time') or '-'}", + f"错误信息:{last_error.get('message') or '-'}", + ] + + return [ + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VCard", + "props": {"variant": "tonal"}, + "content": [ + { + "component": "VCardText", + "text": ( + "夸克分享转存插件负责做一件事:把夸克分享链接稳定转存到自己的夸克网盘。" + "推荐让智能体和飞书只调用这一个稳定入口,不要自己拼夸克接口。" + ), + } + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近转存"}, + {"component": "VCardText", "text": "\n".join(transfer_lines)}, + ], + } + ], + }, + { + "component": "VCol", + "props": {"cols": 12, "md": 6}, + "content": [ + { + "component": "VCard", + "content": [ + {"component": "VCardTitle", "text": "最近错误"}, + {"component": "VCardText", "text": "\n".join(error_lines)}, + ], + } + ], + }, + ], + } + ] + + def get_service(self) -> List[Dict[str, Any]]: + return [] + + def stop_service(self): + pass + + async def api_health(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok = False + message = "" + if self._enabled and self._cookie: + ok, message = self._check_cookie() + return { + "success": ok if self._enabled and self._cookie else False, + "message": "success" if ok else (message or "插件未启用或未配置 Cookie"), + "data": { + "plugin_enabled": self._enabled, + "cookie_configured": bool(self._cookie), + "default_target_path": self._default_target_path, + "timeout": self._timeout, + }, + } + + async def api_folders(self, request: Request) -> Dict[str, Any]: + allowed, message = self._check_api_access(request) + if not allowed: + return {"success": False, "message": message, "data": {}} + path = self._normalize_path(request.query_params.get("path") or "/") + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"path": path, "items": []}} + + ok, folder_id, normalized = self._resolve_existing_dir(path) + if not ok: + return {"success": False, "message": folder_id or "目录不存在", "data": {"path": path, "items": []}} + + ok, items, message = self._list_children(folder_id) + dirs = [ + {"fid": item.get("fid"), "name": item.get("name"), "path": f"{normalized.rstrip('/')}/{item.get('name')}".replace("//", "/")} + for item in items + if item.get("dir") + ] + return {"success": ok, "message": "success" if ok else message, "data": {"path": normalized, "items": dirs}} + + async def api_share_info(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + share_url = body.get("url") or body.get("share_url") or "" + access_code = body.get("access_code") or body.get("pwd") or "" + share_url, pwd_id, final_code = self._extract_share_info(share_url, access_code) + ok, message = self._validate_share_url(share_url) + if not ok: + return {"success": False, "message": message, "data": {}} + if not pwd_id: + return {"success": False, "message": "未识别到有效夸克分享链接", "data": {}} + + if not self._enabled or not self._cookie: + return {"success": False, "message": "插件未启用或未配置 Cookie", "data": {"pwd_id": pwd_id}} + + ok, stoken, message = self._get_stoken(pwd_id, final_code) + if not ok: + return {"success": False, "message": message, "data": {"pwd_id": pwd_id}} + + ok, items, message = self._get_share_items(pwd_id, stoken) + return { + "success": ok, + "message": "success" if ok else message, + "data": { + "pwd_id": pwd_id, + "access_code": final_code, + "items": items[:20], + "count": len(items), + }, + } + + async def api_transfer(self, request: Request) -> Dict[str, Any]: + try: + body = await request.json() + except Exception: + body = {} + allowed, message = self._check_api_access(request, body) + if not allowed: + return {"success": False, "message": message, "data": {}} + ok, result, message = self.transfer_share( + share_text=body.get("url") or body.get("share_url") or "", + access_code=body.get("access_code") or body.get("pwd") or "", + target_path=body.get("path") or body.get("target_path") or self._default_target_path, + remember=True, + trigger="插件 API", + ) + return {"success": ok, "message": message, "data": result} diff --git a/plugins/skills/agent-resource-officer/README.md b/plugins/skills/agent-resource-officer/README.md new file mode 100644 index 0000000..9a937a9 --- /dev/null +++ b/plugins/skills/agent-resource-officer/README.md @@ -0,0 +1,630 @@ +# agent-resource-officer + +公开版 AgentResourceOfficer Skill 模板,用来让外部智能体通过 MoviePilot 插件接口控制 115 云盘、夸克云盘等云盘资源工作流。插件是服务端执行层;Skill/helper 是客户端调度层。 + +当前 helper 版本:`0.1.46` + +## 当前状态 + +- 当前插件版本:`Agent影视助手 0.2.68` +- 当前最小循环:`startup -> decide --summary-only -> route --summary-only -> followup --summary-only` +- 当前优先读取字段:`recommended_agent_behavior`、`auto_run_command`、`confirm_command`、`display_command` +- 当前 AI 失败样本只读诊断入口: + - `python3 scripts/aro_request.py route --text "失败样本 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "工作清单 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "样本洞察 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "重放样本 3" --summary-only` + - `python3 scripts/aro_request.py route --text "重放 3" --summary-only` + - `python3 scripts/aro_request.py route --text "确认" --summary-only` + - `python3 scripts/aro_request.py templates --recipe ai_reingest --compact` +- 当前最低成本入口: + - `python3 scripts/aro_request.py readiness` + - `python3 scripts/aro_request.py external-agent` + - `python3 scripts/aro_request.py decide --summary-only` + - `python3 scripts/aro_request.py route --text "智能搜索 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "资源决策 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "资源决策 蜘蛛侠 详情" --summary-only` +- 当前搜索口径: + - `搜索 <片名>` / `找 <片名>` 默认先走盘搜 + - `云盘搜索 <片名>` 固定走盘搜 + 影巢 + - `影巢搜索 <片名>` 明确走影巢直接列表 + - `MP搜索 <片名>` / `PT搜索 <片名>` 明确走 MoviePilot 原生 PT 搜索 +- `转存 <片名>` 走云盘资源一条龙转存,优先盘搜 + 影巢 +- `下载 <片名>` 走 MP/PT 直接下载 +- 当前更新口径: + - `更新 <片名>` / `更新检查 <片名>` / `检查 <片名>` 先走更新检查 + - 直接展示官方参考进度、盘搜最新集资源、影巢最新集资源 + - 不要先清空会话,不要先改走影巢候选 + - 资源列表必须保留原始编号,方便后续直接回编号 +- 当前破坏性目录命令: + - `清空夸克默认转存目录` + - `清空夸克默认目录` + - `清空115转存目录` + - `清空115默认转存目录` + - `清空115默认目录` + - 只在用户原话明确提出时执行,不要从模糊“清理一下”里自行推断 +- 当前影巢签到修复入口: + - `python3 scripts/aro_request.py hdhive-cookie-refresh` + - `python3 scripts/aro_request.py hdhive-checkin-repair` + - 推荐做法:先确保 Edge 已登录 `https://hdhive.com`,再用上面两条命令自动写回完整 Cookie,不要手工复制 Cookie +- 当前夸克登录修复入口: + - `python3 scripts/aro_request.py quark-cookie-refresh` + - `python3 scripts/aro_request.py quark-transfer-repair` + - 推荐做法:先确保 Edge 已登录 `https://pan.quark.cn`,登录态失效时优先刷新 Cookie;只有明确是 `require login [guest]` 这类登录态问题时才自动修复 + +公开仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +## 使用方式 + +1. 获取仓库: + +```bash +git clone https://github.com/liuyuexi1987/MoviePilot-Plugins.git +cd MoviePilot-Plugins +``` + +2. 把整个目录复制到自己的 Skill 搜索路径,例如: + +```text +/agent-resource-officer +``` + +也可以直接运行安装脚本: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/agent-resource-officer +``` + +3. 配置连接信息: + +```text +~/.config/agent-resource-officer/config +``` + +示例: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=your_moviepilot_api_token +ARO_HDHIVE_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/hdhive-cookie-export +ARO_QUARK_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/quark-cookie-export +``` + +`ARO_BASE_URL` 按实际部署填写:同机可以用 `http://127.0.0.1:3000`,局域网可以用 `http://你的局域网IP:3000`,公网反代可以用自己的 HTTPS 域名。 + +如果你要让 helper 直接调用本机“影巢 Cookie 导出”工具,可选配置: + +```text +ARO_HDHIVE_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/hdhive-cookie-export +ARO_HDHIVE_COOKIE_EXPORT_PYTHON=/绝对路径/python +ARO_HDHIVE_COOKIE_BROWSER=edge +ARO_HDHIVE_COOKIE_SITE_URL=https://hdhive.com +ARO_HDHIVE_COOKIE_RESTART_CONTAINER=moviepilot-v2 +ARO_QUARK_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/quark-cookie-export +ARO_QUARK_COOKIE_EXPORT_PYTHON=/绝对路径/python +ARO_QUARK_COOKIE_BROWSER=edge +ARO_QUARK_COOKIE_SITE_URL=https://pan.quark.cn +ARO_QUARK_COOKIE_RESTART_CONTAINER=moviepilot-v2 +``` + +如果你直接使用本仓库,helper 也会优先自动尝试仓库里的: + +- `tools/hdhive-cookie-export/` +- `tools/quark-cookie-export/` + +`route` 支持两种写法: + +- `python3 scripts/aro_request.py route "盘搜搜索 大君夫人"` +- `python3 scripts/aro_request.py route --text "盘搜搜索 大君夫人"` +- `python3 scripts/aro_request.py route "云盘搜索 大君夫人"` +- `python3 scripts/aro_request.py route "智能搜索 蜘蛛侠"` + +`route`、`pick`、`workflow`、`plan-execute`、`followup` 还支持: + +- `--summary-only` +- `--command-only` + +适合外部智能体只拿“下一步怎么做”的最小结果。 + +夸克默认目录清空入口: + +```bash +python3 scripts/aro_request.py route "清空夸克默认转存目录" +``` + +这条命令只针对当前配置的夸克默认转存目录,按当前层项目执行清空:当前层文件会直接删除,当前层文件夹也会一并删除(删除文件夹时会连同文件夹内内容一起清掉)。不要把它当成 115 清理,也不要从普通清理意图里自动触发,更不要先 grep helper 源码判断“支不支持”。 + +115 默认目录清空入口: + +```bash +python3 scripts/aro_request.py route "清空115转存目录" +python3 scripts/aro_request.py route "清空115默认转存目录" +``` + +这条命令只针对当前配置的 115 默认转存目录,按当前层项目执行清空:当前层文件会直接删除,当前层文件夹也会一并删除(删除文件夹时会连同文件夹内内容一起清掉)。它是显式破坏性命令,不要从普通清理意图里自动触发,也不要先 grep helper 源码判断“支不支持”。 + +`pick`、`plan-execute`、`followup` 也支持更短的位置参数写法: + +- `python3 scripts/aro_request.py pick 1` +- `python3 scripts/aro_request.py pick 1 详情` +- `python3 scripts/aro_request.py plan-execute plan-xxx` +- `python3 scripts/aro_request.py followup plan-xxx` + +影巢 Cookie 刷新与签到修复: + +```bash +python3 scripts/aro_request.py hdhive-cookie-refresh +python3 scripts/aro_request.py hdhive-checkin-repair +``` + +前者会从本机浏览器导出完整网页 Cookie 并自动写回 MoviePilot/AgentResourceOfficer;后者会在刷新 Cookie 后直接再跑一次 `影巢签到`。当 `影巢签到` 或 `影巢签到日志` 明确提示网页登录态失效时,优先使用这两条命令,不要手工复制 Cookie。 + +夸克 Cookie 刷新与转存修复: + +```bash +python3 scripts/aro_request.py quark-cookie-refresh +python3 scripts/aro_request.py quark-transfer-repair +python3 scripts/aro_request.py quark-transfer-repair --retry-text "选择 7" --session default +``` + +前者会从本机浏览器导出夸克 Cookie 并自动写回 `AgentResourceOfficer` / `QuarkShareSaver`;后者会在刷新 Cookie 后检查夸克健康状态,必要时还能顺手重试一条刚才失败的转存命令。只有明确报出 `require login [guest]`、`夸克登录态已过期` 这类登录态问题时,才建议走这条修复链;分享受限、分享者封禁等错误不要误判成 Cookie 失效。 + +`plan-execute` 返回里会保留插件给出的 `recommended_action` 和 `follow_up_hint`。如果不想自己解析下一步,也可以直接执行 `python3 scripts/aro_request.py followup --session 'agent:<会话ID>'`。 + +`workflow`、`session`、`history`、`plans` 也支持常用短写法: + +- `python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠` +- `python3 scripts/aro_request.py session agent:demo` +- `python3 scripts/aro_request.py history agent:demo` +- `python3 scripts/aro_request.py plans plan-xxx` + +4. 让外部智能体使用本 Skill。 + +## 推荐入口 + +```bash +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py doctor --limit 5 +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py feishu-health +python3 scripts/aro_request.py recover --summary-only +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py templates --recipe followup --compact +python3 scripts/aro_request.py templates --recipe ai_reingest --compact +python3 scripts/aro_request.py version +python3 scripts/aro_request.py selftest +python3 scripts/aro_request.py commands +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py config-check +python3 scripts/aro_request.py readiness +python3 scripts/aro_request.py startup +python3 scripts/aro_request.py templates --recipe bootstrap +python3 scripts/aro_request.py templates --recipe mp_pt +python3 scripts/aro_request.py templates --recipe recommend +python3 scripts/aro_request.py preferences --session agent:demo +python3 scripts/aro_request.py selfcheck +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py route "盘搜搜索 大君夫人" +python3 scripts/aro_request.py route "智能搜索 蜘蛛侠" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 详情" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 计划" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 确认" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 直接执行" +python3 scripts/aro_request.py route "失败样本 蜘蛛侠" +python3 scripts/aro_request.py route "工作清单 蜘蛛侠" +python3 scripts/aro_request.py route "样本洞察 蜘蛛侠" +python3 scripts/aro_request.py route "重放样本 3" +python3 scripts/aro_request.py route "重放 3" +python3 scripts/aro_request.py route "确认" +python3 scripts/aro_request.py route "先计划" +python3 scripts/aro_request.py route "确认执行" +python3 scripts/aro_request.py route "先看详情" +python3 scripts/aro_request.py route "计划" +python3 scripts/aro_request.py route "详情" +python3 scripts/aro_request.py route "智能计划 蜘蛛侠" +python3 scripts/aro_request.py route "智能执行 蜘蛛侠" +python3 scripts/aro_request.py route "计划最佳" +python3 scripts/aro_request.py route "执行最佳" +python3 scripts/aro_request.py pick 1 +``` + +`auto` 会先读取 `startup.recommended_request_templates`,再自动拉取推荐的低 token recipe。 + +`selftest` 不连接 MoviePilot,只验证本地 helper 的决策和命令生成逻辑。 + +`version` 会输出当前 helper 版本。 + +`commands` 会输出 helper 命令目录、是否联网、是否可能写入。`writes` 固定为布尔值,具体触发条件在 `write_condition`。 + +`external-agent` 会输出可直接交给 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体的系统提示词和最小工具约定;`external-agent --full` 会输出完整接入说明。输出中会明确给出 `compat_aliases` 和 `deprecated_aliases`。旧命令 `workbuddy` 仍保留为兼容别名,但已标记为 deprecated。 + +如果你对接的是 MP 内置智能体,优先读取 `request_templates` 和原生 Agent Tool,不要让模型自己拼底层影巢、盘搜、115、夸克接口。飞书入口同样复用 `route / pick / followup`,只是消息来源不同。 + +从 `0.2.66` 开始,`request_templates` 还会直接给出 `entry_playbooks`,把外部智能体、MP 内置智能体、飞书入口各自该调什么 helper / HTTP / Tool 以及优先读取哪些字段直接列出来。新接入方优先读这个结构,不要再自己拼第二套启动脚手架。 + +如果外部智能体已经确定是 MP 原生 PT 搜索/下载/订阅任务,优先拉 `mp_pt` recipe;如果是热门推荐、豆瓣热映、Bangumi 番剧续接,优先拉 `recommend` recipe。推荐列表里的条目现在支持: +- `选择 1 决策` +- `选择 1 计划` +- `选择 1 确认` +- `详情 1` +也支持直接对当前榜单首项继续发: +- `详情` +- `计划` +- `确认` +也支持会话内短命令: +- `决策 1` +- `计划 1` +- `确认 1` +也支持单句直达当前榜单首项: +- `智能发现 热门电影 详情` +- `智能发现 热门电影 计划` +- `智能发现 热门电影 确认` +以及单句直达具体来源: +- `智能发现 热门电影 盘搜` +- `智能发现 热门电影 影巢` +- `智能发现 热门电影 原生` +如果已经从推荐会话切到了 `盘搜 / 影巢 / 原生`,也可以直接发: +- `回推荐` +- `盘搜 / 影巢 / 原生` +- 在 `盘搜 / 原生` handoff 会话里,也支持: + - `详情 / 计划 / 确认 / 决策` +如果先看了 `详情 1`,之后还可以直接继续发: +- `详情` +- `决策` +- `计划` +- `确认` +- `盘搜` +- `影巢` +- `原生` +以及推荐会话内 follow-up: +- `电影` +- `电视剧` +- `豆瓣` +- `热映` +- `番剧` + +注意:`workflow` 会直接执行只读工作流;涉及下载、订阅、解锁或转存的写入工作流会默认保存待确认执行的 `plan_id`。 + +当前 PT 主线默认仍走 `plan_id` 确认链路。即使偏好里开启了 `auto_ingest_enabled=true`,外部智能体也应先展示评分和风险,再等待用户确认执行计划。 + +首次交给外部智能体使用时,建议先运行 `preferences`。如果返回需要初始化偏好,智能体应询问用户:清晰度、杜比视界/HDR、字幕、电视剧是否全集优先、PT 最低做种、影巢积分上限、默认目录、是否允许高分资源自动入库。偏好会用于云盘和 PT 分源评分。 + +如果你希望“新会话默认就更保守或更激进”,不要在智能体侧硬编码阈值,直接到 Agent影视助手 插件设置里修改默认评分策略:`PT 最低做种数`、`建议确认分数线`、`自动入库分数线`、`默认允许高分自动入库`。 + +`route`、`pick`、`workflow` 等主响应会带上低 token 的 `preference_status`。如果其中 `needs_onboarding=true`,智能体应先完成偏好询问与保存,再继续自动选择或入库。 + +偏好也可以直接走主入口自然语言:`偏好` 查看,`保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库` 写入,`重置偏好` 清除。 + +如果用户已经提前说明“只用夸克”“没有 115”“不用盘搜”“只用 MP/PT”,也可以直接保存进偏好,例如: + +- `保存偏好 只有夸克 不用115` +- `保存偏好 只用盘搜 不用影巢` +- `保存偏好 只用 MP/PT` + +之后优先用 `智能搜索`: + +- `python3 scripts/aro_request.py route "智能搜索 蜘蛛侠"` +- `python3 scripts/aro_request.py route "资源决策 蜘蛛侠"` +- `python3 scripts/aro_request.py route "智能计划 蜘蛛侠"` +- `python3 scripts/aro_request.py route "智能执行 蜘蛛侠"` + +这条入口会先按偏好过滤可用源和可用云盘,再按默认顺序 `盘搜 -> 影巢 -> MP/PT` 做统一搜索决策;如果前面某一源已经给出足够高分、风险可控的候选,就不会继续无意义展开后面的源。 + +如果你已经做过一次 `智能搜索`,也可以直接在当前会话里发: + +- `python3 scripts/aro_request.py route "计划最佳"` +- `python3 scripts/aro_request.py route "执行最佳"` +- `python3 scripts/aro_request.py route "换影巢"` +- `python3 scripts/aro_request.py route "换盘搜"` +- `python3 scripts/aro_request.py route "换PT"` +- `python3 scripts/aro_request.py route "保守一点"` +- `python3 scripts/aro_request.py route "激进一点"` +- `python3 scripts/aro_request.py route "只用夸克"` +- `python3 scripts/aro_request.py route "只用115"` +- `python3 scripts/aro_request.py route "只走PT"` +- `python3 scripts/aro_request.py route "不用影巢"` +- `python3 scripts/aro_request.py route "按保存偏好"` + +它会按当前智能搜索会话里的首选结果,直接生成待确认 `plan_id`,但不会立刻执行下载、解锁或转存。 +如果用户已经明确要求立即执行,再用 `智能执行` 或 `执行最佳`;这两个入口会直接走写入链。 + +AI 失败样本链现在分两步: + +- `失败样本 / 工作清单 / 样本洞察`:只读诊断 +- `重放样本 3` 或会话内 `重放 3`:只生成待确认计划 +- `确认`:执行当前会话里最近一条 AI 重放计划 +- 重放后可直接继续:`诊断`、`入库状态` + +真正执行仍然要回复 `执行计划 `,不会直接裸重放。 + +搜索类响应可能带有 `score_summary`,包含 `best` 和 `top_recommendations`。外部智能体应优先读取这个结构化摘要,而不是解析长文本;存在 `hard_risk_reasons` 时不要自动执行,`risk_reasons` 只作为确认前需要解释的提醒。 + +`score_summary.decision` 是优先读取的下一步建议层,里面会给出 `label`、`decision_hint`、`preferred_command`、`fallback_command`、`compact_commands` 和 `recommended_commands`。外部智能体应优先复用前两档短命令,不要自己再拼另一套确认话术。 + +执行计划后的回执,以及后续的 `execution_followup`、`smart_followup`、`mp_lifecycle_status`、`mp_ingest_status`、`mp_recent_activity`,现在会统一附带 `followup_summary`。外部智能体应优先读取 `preferred_command`、`fallback_command` 和 `compact_commands` 来决定“接下来查下载、查入库还是查诊断”,不要再靠不同 message 文案分支判断。 + +从 `0.2.63` 开始,compact 主响应顶层也会直接给出统一的 `command_source`、`command_policy`、`preferred_requires_confirmation`、`fallback_requires_confirmation`、`can_auto_run_preferred`、`preferred_command`、`fallback_command`、`compact_commands`。优先级已经固定为: + +1. `error_summary` +2. `followup_summary` +3. `score_summary.decision` + +外部智能体如果只想要“下一条最短命令”,直接读取顶层字段即可,不必自己再判断嵌套结构来源;如果还要判断“这一步能不能直接执行”,则读取 `command_policy` 和两个 `*_requires_confirmation` 标志。 + +从 helper `0.1.30` 开始,`route / pick / workflow / plan-execute / followup` 也能直接把这层顶层字段压成 `--summary-only` / `--command-only` 输出。外部智能体如果不想自己解析 JSON,可以直接调用 helper。 + +从 helper `0.1.31` 开始,这些摘要还会继续保留: + +- `command_policy` +- `preferred_requires_confirmation` +- `fallback_requires_confirmation` +- `can_auto_run_preferred` + +也就是外部智能体不只知道“下一条命令是什么”,还知道“这条命令能不能直接跑,还是该先停下来确认”。 + +从 helper `0.1.32` 开始,`--summary-only` 会直接给出一层更适合自动续跑的决策字段: + +- `recommended_agent_behavior` +- `auto_run_command` +- `confirm_command` +- `display_command` +- `stop_after_auto` +- `reason` + +推荐解释: + +- `auto_continue`:可以直接执行 `auto_run_command` +- `auto_continue_then_wait_confirmation`:先执行 `auto_run_command`,然后停下来把 `confirm_command` 展示给用户确认 +- `wait_user_confirmation`:不要自动执行,先让用户确认 `confirm_command` +- `show_only`:只展示 `display_command` +- `stop`:当前没有适合继续自动执行的短命令 + +从 helper `0.1.33` 开始,这套决策字段不只覆盖 `route / pick / workflow / plan-execute / followup`,也会覆盖 `decide / auto / doctor / recover` 这类老摘要入口。外部智能体可以统一只读: + +- `recommended_agent_behavior` +- `auto_run_command` +- `confirm_command` + +如果原摘要本身已经带业务层 `reason`,helper 会额外补 `execution_reason`,避免把原原因覆盖掉。 + +推荐把外部智能体的执行分支压成这 5 类: + +- `auto_continue`:直接执行 `auto_run_command` +- `auto_continue_then_wait_confirmation`:先执行 `auto_run_command`,再向用户确认 `confirm_command` +- `wait_user_confirmation`:不要自动执行,先展示 `confirm_command` +- `show_only`:只展示 `display_command` +- `stop`:当前不要继续自动执行 + +推荐的最小启动流也已经固定: + +1. `startup` +2. `decide --summary-only` +3. `route "<用户原始指令>" --summary-only` +4. 按 `recommended_agent_behavior` 决定自动继续、确认或停止 +5. 涉及执行计划后,再走 `followup --summary-only` + +评分由插件内置规则执行。外部智能体如需解释规则,可读取 `scoring-policy` 或 `capabilities.scoring_policy`;不要在智能体侧重新打分,也不要绕过 `hard_risk_reasons`。 + +`config-check` 只检查连接配置来源和是否存在,不输出真实 API Key。 + +`readiness` 会一次运行配置检查、本地 selftest 和 MoviePilot 插件 selfcheck。 + +WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体接入时,可以直接复用: + +- [外部智能体接入 Agent影视助手](../../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- Skill 包内外部智能体接入文件:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` +- `PROMPTS.md` 里的外部智能体提示词段落 + +`decide` 是单次决策入口: + +- 有可恢复会话时,返回 `decision=continue_session` +- 没有可恢复会话时,返回 `decision=start_recipe` + +无论落到哪一边,低 token 摘要都会尽量附带下一步 helper 命令。 + +只需要下一步命令时,用: + +```bash +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py decide --command-only --confirmed +``` + +默认会在需要确认的场景输出查看命令;已经获得用户确认后,再加 `--confirmed` 输出执行命令。 + +如果已确定任务类型,可以直接指定 recipe 获取更具体的下一步命令: + +```bash +python3 scripts/aro_request.py decide --recipe mp_pt --command-only +python3 scripts/aro_request.py decide --recipe recommend --command-only +``` + +如果只想拿自动启动流的最小决策结果,直接用: + +```bash +python3 scripts/aro_request.py auto --summary-only +``` + +`doctor` 是只读诊断入口,会一次返回 `startup + selfcheck + sessions + recover` 的压缩结果,适合外部智能体在真正执行前做开场检查。 + +`feishu-health` 会检查 `AgentResourceOfficer` 内置飞书入口是否启用、长连接是否运行,以及飞书 SDK / 白名单 / 回复配置状态;MP 内置智能助手可直接使用 `agent_resource_officer_feishu_health`。 + +如果只想拿最省 token 的决策结果,直接用: + +```bash +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py recover --summary-only +``` + +它还会直接给出: + +- `helper_commands.inspect_helper_command` +- `helper_commands.execute_helper_command` + +## 恢复与排查 + +```bash +python3 scripts/aro_request.py sessions --limit 10 +python3 scripts/aro_request.py sessions --kind assistant_hdhive --limit 5 +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py recover --execute +python3 scripts/aro_request.py history --limit 10 +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans --limit 10 +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans --executed --include-actions --limit 5 +python3 scripts/aro_request.py plan-execute plan-xxx +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py followup plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +- `sessions` / `history` / `plans` / `recover` 默认不再强制绑到 `default` 会话。 +- 只有显式传 `--session` 或 `--session-id` 时,才会收窄到单个会话。 +- `followup` 会按最近已执行计划自动选择合适的只读后续动作,适合接在 `plan-execute` 后面。 +- `session-clear` / `sessions-clear` 是写入型清理命令,用于清理放弃的会话或 pending 115 恢复状态。 +- `plans-clear` 是写入型清理命令,优先使用 `--plan-id` 精确清理;批量清理时再使用 `--session`、`--executed`、`--unexecuted` 或 `--all-plans`。 + +## 偏好与评分 + +```bash +python3 scripts/aro_request.py preferences --session agent:demo +python3 scripts/aro_request.py preferences --session agent:demo --preferences-json '{"prefer_resolution":"4K","prefer_dolby_vision":true,"prefer_hdr":true,"prefer_chinese_subtitle":true,"prefer_complete_series":true,"pt_min_seeders":3,"hdhive_max_unlock_points":20,"auto_ingest_enabled":false}' +python3 scripts/aro_request.py route --text "保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库" --session agent:demo +python3 scripts/aro_request.py workflow --workflow mp_search --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode mp +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode hdhive +``` + +智能体也可以直接走自然语言路由: + +```bash +python3 scripts/aro_request.py route --text "看看最近有什么热门影视" +python3 scripts/aro_request.py route --text "豆瓣热门电影" +python3 scripts/aro_request.py route --text "今日番剧" +``` + +推荐列表出来后,可以用自然语言继续: + +```bash +python3 scripts/aro_request.py route --text "选择 1" +python3 scripts/aro_request.py route --text "选择 1 盘搜" +python3 scripts/aro_request.py route --text "选择1影巢" +``` + +MP 原生搜索结果出来后,也可以直接: + +```bash +python3 scripts/aro_request.py route --text "下载1" +python3 scripts/aro_request.py route --text "下载第1个" +python3 scripts/aro_request.py route --text "订阅蜘蛛侠" +python3 scripts/aro_request.py route --text "订阅并搜索蜘蛛侠" +python3 scripts/aro_request.py route --text "MP搜索 蜘蛛侠" --session agent:demo +python3 scripts/aro_request.py pick --choice 1 --session agent:demo +python3 scripts/aro_request.py route --text "计划选择 1" --session agent:demo +python3 scripts/aro_request.py route --text "最佳片源" --session agent:demo +python3 scripts/aro_request.py route --text "下载最佳" --session agent:demo +python3 scripts/aro_request.py route --text "执行计划" --session agent:demo +python3 scripts/aro_request.py route --text "执行 plan-xxxx" --session agent:demo +``` + +盘搜和影巢资源列表里的 `最佳片源`、`选择 1 详情` 是只读查看,不会转存或解锁。普通 `搜索/找 <片名>` 返回的盘搜列表,默认先按编号直接选;想先确认时再发 `选择 1 详情`。只有用户明确要求保留计划确认链时,才发 `计划选择 1`。 + +普通 `搜索/找 <片名>` 的返回应尽量原样展示资源官给出的编号列表,不要再二次改写成“资源状态”“推荐清单”“费用/评分/推荐星级”之类的摘要。最好的做法是保留原列表和下一步提示,只在前后补一两句极短说明。 + +`云盘搜索 <片名>` 也应尽量原样展示资源官给出的组合结果。不要把 `云盘搜索` 偷换成 `盘搜搜索`,也不要把插件已经给出的 `盘搜结果 / 影巢结果` 两段重新压成“剧集信息 / 推荐资源 / 分析结论”的导购摘要。优先保留: +- `盘搜结果` +- `影巢结果` +- 原始编号 +- 盘搜原始链接 +- 插件原生下一步提示 + +`云盘搜索` 返回后,不要自行改写成每个来源各自从 `1` 开始编号的小表格,也不要只摘“亮点”。如果插件返回了全局编号,就保留全局编号;如果插件提示“影巢候选未自动展开”,也应原样保留这句,而不是把它改成一句“影巢还有候选,需要可发影巢搜索”然后丢掉上文结构。 + +夸克转存失败时,不要自己补一段“可能是默认转存目录不存在或有问题”“换个 path=/ 试试”这类猜测。只有当插件明确指出路径问题时,才建议改路径;如果插件只返回 `夸克转存失败:无法转存到 /飞书`,更稳妥的表述应是“原因未明,先不要自行推断路径问题”。 + +下载任务也可以走同一入口。查询是读操作;暂停、恢复、删除会先返回 `plan_id`,确认后再执行: + +```bash +python3 scripts/aro_request.py route --text "下载任务" +python3 scripts/aro_request.py route --text "记录" +python3 scripts/aro_request.py route --text "记录 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_download_history --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py route --text "状态 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_lifecycle_status --keyword "蜘蛛侠" --limit 5 +python3 scripts/aro_request.py route --text "后续" +python3 scripts/aro_request.py route --text "跟进" +python3 scripts/aro_request.py route --text "跟进 蜘蛛侠" +python3 scripts/aro_request.py route --text "入库 蜘蛛侠" +python3 scripts/aro_request.py route --text "诊断 蜘蛛侠" +python3 scripts/aro_request.py route --text "最近" +python3 scripts/aro_request.py route --text "识别 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py route --text "暂停下载 1" +python3 scripts/aro_request.py route --text "恢复下载 1" +python3 scripts/aro_request.py route --text "删除下载 1" +``` + +PT 环境诊断也可以直接询问;站点结果只返回脱敏摘要,不会暴露 Cookie: + +```bash +python3 scripts/aro_request.py route --text "站点状态" +python3 scripts/aro_request.py route --text "下载器状态" +python3 scripts/aro_request.py workflow --workflow mp_sites --status active --limit 30 +python3 scripts/aro_request.py workflow --workflow mp_downloaders +``` + +MP 订阅也可以交给 Agent影视助手统一调度。查询是读操作;搜索、暂停、恢复、删除订阅会先返回 `plan_id`: + +```bash +python3 scripts/aro_request.py route --text "订阅列表" +python3 scripts/aro_request.py route --text "搜索订阅 1" +python3 scripts/aro_request.py route --text "暂停订阅 1" +python3 scripts/aro_request.py route --text "恢复订阅 1" +python3 scripts/aro_request.py route --text "删除订阅 1" +python3 scripts/aro_request.py workflow --workflow mp_subscribes --status all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_subscribe_control --control search --target 1 +``` + +MP 整理/入库历史是只读查询,适合让智能体确认下载后是否已经落库: + +```bash +python3 scripts/aro_request.py route --text "入库历史" +python3 scripts/aro_request.py route --text "入库失败 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_transfer_history --keyword "蜘蛛侠" --status all --limit 10 +``` + +- 云盘资源按清晰度、HDR/DV、字幕、完整度、目录和网盘类型评分;影巢额外受积分上限保护。 +- PT 资源按做种数、免费/促销、下载热度、清晰度、HDR/DV、字幕、标题匹配、站点和发布组评分;高分也默认先返回 `plan_id`,不会直接下载。 +- 下载、订阅、影巢解锁、网盘转存默认先生成 `plan_id`,确认后再执行。 + +## 说明 + +- 这是面向公开仓库的通用模板。 +- 重点使用 `AgentResourceOfficer` 的 `assistant/startup` 和 `assistant/request_templates`。 +- HTTP 调用使用 `?apikey=MP_API_TOKEN`。 +- 不包含个人路径、API Key、Cookie 或 Token。 +- 推荐搭配支持 Skill 和工具调度的外部智能体使用,例如腾讯 WorkBuddy、Hermes、OpenClaw(小龙虾),或其他兼容 Skill 工作流的客户端。 +- 版本记录见:`skills/agent-resource-officer/CHANGELOG.md`。 diff --git a/plugins/skills/agent-resource-officer/SKILL.md b/plugins/skills/agent-resource-officer/SKILL.md new file mode 100644 index 0000000..e0e5ca8 --- /dev/null +++ b/plugins/skills/agent-resource-officer/SKILL.md @@ -0,0 +1,736 @@ +--- +name: agent-resource-officer +description: Control AgentResourceOfficer, the MoviePilot resource workflow hub, from an external agent. Use when an agent should route title-based resource commands including PanSou, HDHive, 115, Quark, MP/PT search, downloads, update checks, numbered choices, paging, cookie repair, startup/recovery state, request templates, or saved plans through AgentResourceOfficer instead of calling MoviePilot MCP search tools, TMDB, HDHive, 115, Quark, or PanSou APIs directly. +--- + +# AgentResourceOfficer Skill + +Use this skill when the user wants an external agent to operate MoviePilot title-based resource workflows through `AgentResourceOfficer`, including PanSou, HDHive, 115, Quark, MP/PT search, download, update-check, numbered picking, paging, and repair flows. + +The plugin is the capability layer. The agent should orchestrate, display choices, ask for confirmation when required, and call the stable assistant endpoints. + +## Configuration + +Public repository: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +To reproduce this skill on another machine, clone the repository and install the bundled skill: + +```bash +git clone https://github.com/liuyuexi1987/MoviePilot-Plugins.git +cd MoviePilot-Plugins +bash skills/agent-resource-officer/install.sh --dry-run +bash skills/agent-resource-officer/install.sh +``` + +Preferred local config: + +```text +~/.config/agent-resource-officer/config +``` + +Format: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=your_moviepilot_api_token +``` + +Set `ARO_BASE_URL` to the MoviePilot address reachable from the machine running the external agent. Use `http://127.0.0.1:3000` only when MoviePilot is on the same machine. + +If the user has multiple MoviePilot instances, `ARO_BASE_URL` decides which instance receives every resource command. Be especially careful with `下载` / `MP搜索` / `PT搜索`: they use the downloader configured inside that MoviePilot instance. A local Mac/Win MoviePilot can still control a NAS qBittorrent if its downloader points to the NAS, so do not assume "local MP" means "local download". If the current MoviePilot is only for cloud-drive/STRM workflows and its `/待整理` path is not a real PT download directory, do not execute PT download confirmations through it; ask the user to switch `ARO_BASE_URL` to the NAS MoviePilot that owns the normal download workflow. + +If the target MoviePilot plugin config sets `mp_download_save_path`, PT downloads will be submitted with that explicit MoviePilot `save_path`. Treat it as a server-side safety/configuration knob: do not invent this path in chat. It must match the target MoviePilot/NAS storage mapping, for example a valid `local:/...` path or another storage-prefixed path accepted by MoviePilot. + +## Routing Boundary + +MoviePilot official MCP is optional, not assumed. + +- Only use MoviePilot MCP when the current client has already connected the MCP endpoint and can actually see MoviePilot MCP tools in the active tool list. +- If MCP is not explicitly connected in the current client, continue to use `agent-resource-officer` helper/HTTP route flow and do not pretend MCP is available. +- Do not tell the user you are using MCP unless you truly invoked MoviePilot MCP tools in this session. +- MCP is only the preferred path for MoviePilot management/read-only queries, not for title-based resource workflow commands. +- For MoviePilot native read-only or light management tasks, prefer MCP first and do not probe old HTTP endpoints, `curl`, `raw GET`, or helper fallback before trying the matching `mcp__moviepilot__*` tool. +- Typical MCP-first tasks include: installed plugins, downloader status, site status, site userdata, download tasks, download history, transfer history, subscribe history, library latest, library exists, directory settings, workflows, schedulers, media detail, episode schedule, and similar MP-native queries. +- Keep title-based resource workflow abilities on the existing stable path: PanSou, HDHive, 115, Quark, MP/PT search commands, numbered picking, paging, cookie repair, update-check orchestration, and Feishu entry must use `agent-resource-officer` skill/helper unless the user explicitly asks for another route. +- If the user command is clearly a resource workflow command, do not call MCP, tool_search, curl, raw API probes, or MoviePilot native search first. Directly run the helper route/pick. This includes: `搜索`, `找`, `盘搜`, `盘搜搜索`, `云盘搜索`, `影巢`, `影巢搜索`, `MP搜索`, `PT搜索`, `转存`, `夸克转存`, `115转存`, `下载`, `更新`, `更新检查`, `检查`, `选择`, `详情`, `n`, `下一页`, and numbered follow-ups. +- If the user says `校准影视技能`, run `python3 scripts/aro_request.py calibrate` or `python3 scripts/aro_request.py route "校准影视技能"` first, apply the returned hard rules to the current session, then reply only `影视技能已校准。`. +- Preserve the title-confirmation gate for write commands. `下载 蜘蛛侠`, `转存 蜘蛛侠`, `夸克转存 蜘蛛侠`, and `115转存 蜘蛛侠` must first resolve MoviePilot/TMDB candidates when the title is ambiguous. Plain `转存 <片名>` means `115转存 <片名>`; only explicit `夸克转存 <片名>` should use Quark. `下载 <片名>` means MP/PT only: show candidates or PT resources first, and never auto-submit a download from the title command. If there are multiple title candidates, wait for the user to choose one; after selection, continue with the exact selected title and year, then search PT/PanSou/HDHive according to the original command. +- Before executing any confirmed PT download, remember that the actual save path is controlled by the target MoviePilot/qBittorrent downloader configuration, not by AgentResourceOfficer's 115/Quark transfer directory. If the connected MoviePilot is known to be a cloud-drive/STRM-only instance, or the user says its download path is the same `/待整理` used for cloud transfers, stop and ask them to switch to the NAS download MoviePilot before executing. +- Preserve detail intent in numbered follow-ups. If the user says `15详情`, `15 的详情`, `我要看看 15 的详情`, `十六详情`, or `详情十六`, call route/pick with that detail intent intact. Do not simplify it to `15`, `选择 15`, or any command that executes transfer/download. If you must normalize the text, normalize only to `选择 15 详情`. +- For explicit title searches such as `MP 搜索 罪无可逃`, the first and only initial command should be `python3 scripts/aro_request.py route "MP 搜索 罪无可逃" --session `. Do not call `search_media`, `search_torrents`, TMDB APIs, MoviePilot raw APIs, or MCP before this helper call. + +Environment overrides: + +- `ARO_BASE_URL` +- `MP_BASE_URL` +- `MOVIEPILOT_URL` +- `ARO_API_KEY` +- `MP_API_TOKEN` +- `ARO_HDHIVE_COOKIE_EXPORT_DIR` +- `ARO_HDHIVE_COOKIE_EXPORT_PYTHON` +- `ARO_HDHIVE_COOKIE_BROWSER` +- `ARO_HDHIVE_COOKIE_SITE_URL` +- `ARO_HDHIVE_COOKIE_RESTART_CONTAINER` +- `ARO_QUARK_COOKIE_EXPORT_DIR` +- `ARO_QUARK_COOKIE_EXPORT_PYTHON` +- `ARO_QUARK_COOKIE_BROWSER` +- `ARO_QUARK_COOKIE_SITE_URL` +- `ARO_QUARK_COOKIE_RESTART_CONTAINER` + +Never print API keys, cookies, or tokens back to the user. + +If this skill is installed from the `MoviePilot-Plugins` repository checkout, the helper will first try the bundled cookie export tools in: + +- `tools/hdhive-cookie-export/` +- `tools/quark-cookie-export/` + +The install helper copies these tools into the installed skill directory as `tools/...`, so a standalone installed skill can call `hdhive-cookie-refresh`, `hdhive-checkin-repair`, `quark-cookie-refresh`, and `quark-transfer-repair` directly. You can still override them with `ARO_HDHIVE_COOKIE_EXPORT_DIR` and `ARO_QUARK_COOKIE_EXPORT_DIR`. + +Optional install helper: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/agent-resource-officer +``` + +## Request Helper + +Prefer the bundled helper: + +```bash +python3 scripts/aro_request.py startup +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py doctor --limit 5 +python3 scripts/aro_request.py doctor --limit 5 --summary-only +python3 scripts/aro_request.py feishu-health +python3 scripts/aro_request.py recover --summary-only +python3 scripts/aro_request.py version +python3 scripts/aro_request.py selftest +python3 scripts/aro_request.py hdhive-cookie-refresh +python3 scripts/aro_request.py hdhive-checkin-repair +python3 scripts/aro_request.py quark-cookie-refresh +python3 scripts/aro_request.py quark-transfer-repair +python3 scripts/aro_request.py commands +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py config-check +python3 scripts/aro_request.py readiness +python3 scripts/aro_request.py selfcheck +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py sessions --kind assistant_hdhive --limit 5 +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py templates --recipe bootstrap +python3 scripts/aro_request.py route "盘搜搜索 大君夫人" +python3 scripts/aro_request.py pick 1 +``` + +The helper uses `?apikey=...`, which is the recommended HTTP auth mode for plugin assistant endpoints. + +Use `selftest` to validate local helper logic without connecting to MoviePilot: + +```bash +python3 scripts/aro_request.py selftest +``` + +Use `version` to print the local helper version: + +```bash +python3 scripts/aro_request.py version +``` + +Use `commands` when an external agent needs the local helper command catalog: + +```bash +python3 scripts/aro_request.py commands +``` + +The command catalog uses `schema_version=commands.v1`; `writes` is always boolean and details live in `write_condition`. + +Use `external-agent` when handing this Skill to WorkBuddy, Hermes, OpenClaw(小龙虾), a WeChat-side agent, or another external agent: + +```bash +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py calibrate +``` + +`external-agent` prints the compact prompt and minimal tool contract. `external-agent --full` prints the full bundled handoff guide. `workbuddy` remains a compatibility alias only; new integrations should use `external-agent`. + +`calibrate` prints a compact calibration card for long-lived external-agent threads. Use it when a WeChat/WorkBuddy/Claw/Hermes/OpenClaw session has been compressed or starts rewriting commands incorrectly, for example changing `下载 <片名>` into cloud transfer or changing `15详情` into execution. + +When a user says plain `搜索 <片名>` or `找 <片名>`, pass that text through to `route` first. Do not guess that the user meant HDHive, and do not continue an old result session by sending `选择 1` unless the user actually chose an item in the current round. Default plain search should start from PanSou. + +When the user clearly refers to a previously shown numbered result, for example `刚才那个 22`、`上次的 #22`、`把原来的 22 转存`、`下载 10`、`选择 14`, do not restart search first. Reuse the current session, or recover the latest matching session with `decide --summary-only` / `sessions` / `session`, then continue with `pick`. Only restart the search when the old session is truly gone and cannot be recovered. + +When a user says `转存 <片名>`, route that text directly first. Treat it as a 115-transfer intent, equivalent to `115转存 <片名>`: prefer PanSou + HDHive 115 resources, and let AgentResourceOfficer execute the one-stop transfer flow instead of rewriting it into a PT download request. Only use Quark when the user explicitly says `夸克转存`. + +When a user says `下载 <片名>`, route that text directly first. Treat it as an MP/PT search-and-download intent, not a browsing/listing intent. If the title is ambiguous, show MoviePilot/TMDB title candidates first. Once the title is unambiguous, the plugin should search PT internally and directly return up to three pending download plans for the best PT candidates instead of showing the full PT list. It must not auto-submit a real download from the title command. Only after the plugin has returned pending plans may the user confirm by replying the displayed方案编号 such as `1`, `2`, or `3`, or `执行计划`; route that reply as-is so the plugin can execute the matching pending plan. `下载1` means "generate/select download plan for result 1", not confirmation for an older saved plan. If there is no pending plan in the current session, a bare number must be treated as the current result-list continuation. + +When a user says `MP搜索 <片名>`, `MP 搜索 <片名>`, `PT搜索 <片名>`, or `PT 搜索 <片名>`, route that exact text directly first. Treat it as an explicit MoviePilot native/PT search request. Do not rewrite it into `搜索 <片名>`, `盘搜搜索 <片名>`, `云盘搜索 <片名>`, or smart search. + +If the same command includes a natural-language latest-episode intent such as `给我最新集`, `最新集`, or `最新一集`, still route the original text directly. AgentResourceOfficer will strip that suffix from the title, detect the highest episode in PT results, and show only candidates containing that latest episode. Do not add older episode batches back into the summary unless the user asks for all results. + +If the same command includes a clear episode filter such as `第4集`, `第四集`, `E04`, or `S01E04`, still route the original text directly. AgentResourceOfficer will strip the episode suffix from the title and show only candidates containing that target episode, then renumber the filtered list safely. Do not remove this intent or rewrite it as a generic title search. + +For `下载 <片名>` results, relay the plugin's returned message exactly like `MP搜索` / `PT搜索`. If the plugin returns a PT resource list, show the numbered resources, score lines, recommendation, and next-step hints. Never replace the list with a one-line summary such as `PT资源已列出,回编号选详情或下载`. In PT result lists, keep the distinction clear: `选择 N` is read-only detail/review, `下载N` generates a pending download plan, and only a later `执行计划` or matching number after that pending plan executes it. + +If `MP搜索` / `PT搜索` returns a MoviePilot media candidate list, do not choose for the user. Show the candidates, ask the user to reply with a number, then call `pick ` to continue the PT search. For ambiguous titles such as `蜘蛛侠`, this candidate step is expected and safer than assuming the 2002 movie. + +If the original `MP搜索` / `PT搜索` command included `最新集` / `给我最新集` and then returned a media candidate list, the user's numeric reply must be routed in the same helper session. Do not run a fresh bare `route "1"` in another/default session, and do not summarize older episode batches as latest results. The plugin will preserve the latest-episode filter after the candidate is selected; relay that returned message as-is. + +After `下载 <片名>` returns a title candidate list, preserve the same helper session and route the user's numeric reply exactly as the reply text, for example `python3 scripts/aro_request.py route "5" --session `. Do not reconstruct it as `下载 <候选标题 年份>`, because that loses the candidate session and can change behavior. If the selected title has no PT resources, say that MP/PT currently has no downloadable result; do not silently fall back to PanSou, HDHive, Quark, 115, or cloud transfer. Cloud resources require an explicit `云盘搜索` / `转存` / `夸克转存` / `115转存` command. + +For `MP搜索` / `PT搜索` results, relay the plugin's returned message exactly. Do not compress it into a new custom list such as `PT 资源共 N 条`, and do not rewrite release titles. Preserve the plugin's emoji markers (`🧲`, `🌱`, `🎁`, `💾`, `⭐`, etc.) and invisible breaks in dotted release names; they are intentional for WeChat/mobile readability. + +Do not renumber MP/PT result lists. If the plugin returns visible item numbers like `2, 4, 21, 29`, keep those exact numbers in the user-facing reply and in follow-up commands such as `选择 2` or `下载2`. Never rewrite them to `1, 2, 3, 4`, because MP/PT detail and download actions are keyed to the plugin's visible numbers. Do not append your own “当前最高分候选” or “回复选择 N” footer when the plugin message already includes recommendation and next-step hints. + +When the current client has no MoviePilot MCP tools, do not announce an MCP fallback for `MP搜索` / `PT搜索`. Just call `python3 scripts/aro_request.py route "<原始用户命令>" --session ` and relay the returned message. + +When a user says `云盘搜索 <片名>`, route that exact text first. Do not silently replace it with `盘搜搜索 <片名>`. Cloud search is a distinct entry that should compare PanSou and HDHive together; if HDHive stays ambiguous, preserve the plugin's own `影巢结果` hint instead of collapsing everything into a PanSou-only recommendation. + +When a user says `更新 <片名>`, `更新检查 <片名>`, `查更新 <片名>`, or `检查 <片名>`, route that text directly first and treat it as the update-check entry. Do not clear the session first, do not guess that the user meant HDHive candidate search, and do not replace it with a generic search flow. The update flow should first show official reference progress plus PanSou and HDHive latest-episode resources, then let the user choose a numbered resource if needed. + +For update-check results, relay the plugin's returned message exactly. Preserve the emoji sections and item lines such as `🟨 盘搜结果`, `🟦 影巢结果`, `🗄 #25 夸克`, `📺 #1 115`, `🕒05/02`, and `📌 E01-E09`. Do not transform them into field-table prose like `#: ... 来源: ... 详情: ... 日期: ...`, and do not replace the list with a summary. + +When a user says `刷新影巢Cookie`, do not route that phrase into AgentResourceOfficer. Treat it as a host-side repair action and run: + +```bash +python3 scripts/aro_request.py hdhive-cookie-refresh +``` + +This command exports the current HDHive webpage cookie from the local browser, writes it back into MoviePilot and AgentResourceOfficer, and restarts `moviepilot-v2`. + +When a user says `修复影巢签到`, do not route that phrase directly. Run: + +```bash +python3 scripts/aro_request.py hdhive-checkin-repair +``` + +This command refreshes the HDHive webpage cookie from the local browser export tool, restarts `moviepilot-v2`, then retries one HDHive sign-in through AgentResourceOfficer. + +When `影巢签到` or `影巢签到日志` clearly shows cookie/login failure, prefer the automatic repair flow instead of asking the user to hand-copy cookies. First remind the user to ensure they are logged into `https://hdhive.com` in Edge, then run `hdhive-checkin-repair`, and finally show the new sign-in result. + +When a user says `刷新夸克Cookie`, do not route that phrase into AgentResourceOfficer. Treat it as a host-side repair action and run: + +```bash +python3 scripts/aro_request.py quark-cookie-refresh +``` + +This command exports the current Quark webpage cookie from the local browser, writes it back into MoviePilot and AgentResourceOfficer, and restarts `moviepilot-v2`. + +When a user says `修复夸克转存`, do not route that phrase directly. Prefer: + +```bash +python3 scripts/aro_request.py quark-transfer-repair --retry-text "<刚才失败的原始转存命令>" +``` + +If there is no safe transfer command to retry, run `python3 scripts/aro_request.py quark-transfer-repair` first to refresh the cookie and verify Quark health, then ask the user to retry the original transfer. + +Only use the Quark automatic repair flow when the failure clearly points to login/cookie problems, for example `require login [guest]`, `夸克登录态已过期`, or `当前夸克登录态不足`. Do not trigger it for share-link restrictions, deleted links, or ordinary 403/41031 share bans. + +For ordinary search, cloud search, HDHive resource lists, and update-check lists, preserve the plugin's original numbering exactly. Do not reformat a numbered resource list into unnumbered prose, do not collapse numbered items into a separate summary, and do not move the actionable numbers only into a later recommendation paragraph. Smart recommendations are welcome after the original list, and can be as detailed as useful, as long as they reference the original item numbers and do not replace the list. + +The helper's default `route` and `pick` commands print a chat-friendly plain text `message`. Relay that output directly to the user. If you need to parse structured fields programmatically, add `--json-output`; do not parse the plain display text and then reconstruct your own resource list. + +For numbered detail follow-ups, keep the detail action. `15详情`, `15 的详情`, `我要看看 15 的详情`, `十六详情`, and `详情十六` are read-only detail requests. They must not be changed into `选择 15` or a direct transfer/download command. + +For PanSou result lists, keep the source section headings (`🟦 115 结果`, `🟨 夸克结果`) and do not repeat provider tags inside every item. Display items as `编号. emoji 标题` rather than `编号. [115] ...` or `编号. [quark] ...`. Dates should keep the clock marker, for example `— 🕒05/07` or the returned `display_datetime`. Preserve physical line breaks between the source heading and each numbered item; if the chat frontend renders Markdown and may collapse normal line breaks, wrap the resource list itself in a fenced `text` block or insert real blank lines after each source heading so Quark items do not collapse into one paragraph. + +Do not show raw 115/Quark share links in search result lists. Links belong in the copy-friendly detail card returned by `选择 编号 详情`. + +For HDHive/影巢 resource lists, use the same source grouping style: `🟦 115 结果` and `🟨 夸克结果`. Keep each resource as plain numbered items like `1. emoji 标题 · 积分 · 大小 · 集数 · 规格`, not `#1`. Put a real blank line between resource items in Markdown-like chat frontends so WorkBuddy does not collapse the list into one paragraph. A recommendation section is allowed at the end, but keep it after the original list and reference original numbers. If the user needs a shareable link or full metadata, tell them to use `选择 编号 详情`; the detail card is the copy-friendly view. + +After displaying a resource list, add or preserve a `智能建议` section when the data is enough to compare quality. Do not over-constrain the recommendation length; explain the tradeoffs naturally around common viewing and storage decisions such as picture quality, episode completeness, subtitle clarity, file size, source reliability, and whether the user explicitly wants 115 or Quark. Do not expose raw score formulas such as `4K +25` as the main explanation. The only hard rule is that recommendations must reference the original item numbers and must not replace or renumber the original list. + +For cloud search results, prefer the plugin's raw combined layout: keep the `盘搜结果` section, keep the `影巢结果` section, and keep raw links when the plugin returned them. Do not hide the source-specific sections behind your own summary. A short recommendation is allowed only after the raw list and next-step hint. + +For cloud search, never renumber items per source in your own prose. If the plugin returned global numbering like `1..16` plus `17..24`, preserve that exact numbering. Do not convert it into separate `115 1..6 / 夸克 1..10` local indices, and do not collapse the response into a custom “标题/画质/日期/链接” table that drops the plugin's next-step instructions. + +When `影巢搜索` or `云盘搜索` falls back to PanSou because HDHive returned no usable result, keep the plugin's original fallback text and numbered resource list. Do not rewrite it into your own progress bulletin like “有新集了”“现在两边都有了” or a custom compact table that hides links, numbering, or next-step hints. + +When a Quark transfer fails, do not invent a path diagnosis unless the plugin explicitly said so. If the plugin only returned `夸克转存失败:无法转存到 /飞书`, treat the cause as unknown and do not add guesses like “默认转存目录不存在” or “换成 path=/ 试试” on your own. Only recommend a different path when the plugin itself clearly pointed to a path problem or the user explicitly asked to try another path. + +Use `config-check` to verify connection settings without printing secrets: + +```bash +python3 scripts/aro_request.py config-check +``` + +Use `readiness` after configuration to run config check, local selftest, and live plugin selfcheck together: + +```bash +python3 scripts/aro_request.py readiness +``` + +Update-check examples: + +```bash +python3 scripts/aro_request.py route "更新 大君夫人" +python3 scripts/aro_request.py route "更新检查 大君夫人" +python3 scripts/aro_request.py route "检查 大君夫人" +``` + +Quark cleanup examples: + +```bash +python3 scripts/aro_request.py route "清空夸克默认转存目录" +python3 scripts/aro_request.py route "清空夸克默认目录" +``` + +Use Quark cleanup only when the user explicitly asked to clear the Quark default transfer directory. Treat it as a destructive cloud-drive write. It targets the current layer entries of the configured Quark default directory: files are deleted directly, and current-layer folders are deleted together with their contents. Do not infer it from vague cleanup requests, do not silently replace it with 115 cleanup, and do not grep helper source to decide whether this command is supported. + +115 cleanup examples: + +```bash +python3 scripts/aro_request.py route "清空115转存目录" +python3 scripts/aro_request.py route "清空115默认转存目录" +python3 scripts/aro_request.py route "清空115默认目录" +``` + +Use 115 cleanup only when the user explicitly asked to clear the 115 default transfer directory. Treat it as a destructive cloud-drive write. It targets the current layer entries of the configured 115 default directory: files are deleted directly, and current-layer folders are deleted together with their contents. Do not grep helper source to decide whether this command is supported; route the original phrase directly. + +For update requests, do not start with: + +```bash +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py route "影巢搜索 大君夫人" +``` + +unless the user explicitly asked to abandon the current state or explicitly asked for HDHive-only search. + +For ordinary search and update requests, do not start with: + +```bash +python3 scripts/aro_request.py session-clear default +``` + +unless the user explicitly asked to clear or reset the session. + +Use `feishu-health` only when diagnosing the built-in AgentResourceOfficer Feishu Channel: + +```bash +python3 scripts/aro_request.py feishu-health +``` + +For MoviePilot's built-in Agent, use the native tool `agent_resource_officer_feishu_health` instead of calling the Feishu health API manually. + +## Core Startup Flow + +Fast path: + +```bash +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py doctor --limit 5 +``` + +`auto` calls `startup`, reads `recommended_request_templates`, then fetches the recommended low-token recipe. + +`decide` is the single low-token decision entry: + +- if there is a resumable session, it returns `decision=continue_session` +- otherwise it returns `decision=start_recipe` + +If you want the automatic flow but only need the decision summary, prefer: + +```bash +python3 scripts/aro_request.py auto --summary-only +``` + +`doctor` is the read-only diagnostic entry. It combines: + +- `assistant/startup` +- `assistant/selfcheck` +- `assistant/sessions` +- `assistant/recover` + +Use it when an external agent needs one compact bootstrap/health/recovery snapshot before deciding whether to start a new task or continue an old one. + +It also returns local helper suggestions: + +- `helper_commands.inspect_helper_command` +- `helper_commands.execute_helper_command` + +For `auto --summary-only` and `decide --summary-only`, the start-recipe branch also returns: + +- `inspect_helper_command` +- `execute_helper_command` + +If a caller only wants the next helper command, use: + +```bash +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py auto --command-only +python3 scripts/aro_request.py recover --command-only +python3 scripts/aro_request.py decide --command-only --confirmed +``` + +`--command-only` prints an inspect command only when the next action itself requires confirmation. If the current recipe starts with a safe read step, such as `mp_pt` or `recommend`, it prints that executable read command directly even when later write steps still require confirmation. + +If token budget is tight, prefer: + +```bash +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py recover --summary-only +``` + +Manual path: + +1. Call startup: + +```bash +python3 scripts/aro_request.py startup +``` + +2. Read `recommended_request_templates`. + +3. Fetch templates by the recommended recipe: + +```bash +python3 scripts/aro_request.py templates --recipe continue +``` + +If startup has recoverable state, it may recommend `continue`. Otherwise it normally recommends `bootstrap`. + +## Recipes + +Supported recipe names and short aliases: + +- `bootstrap` -> `safe_bootstrap` +- `plan` -> `plan_then_confirm` +- `maintain` -> `maintenance_cycle` +- `continue` -> `continue_existing_session` +- `preferences` / `prefs` / `片源偏好` / `偏好画像` -> `preferences_onboarding` +- `mp_pt` / `mp` / `pt` -> `mp_pt_mainline` +- `recommend` / `热门` / `推荐` -> `mp_recommendation` +- `local_ingest` / `ingest` / `local` / `本地入库` / `入库诊断` -> `local_ingest` + +Use: + +```bash +python3 scripts/aro_request.py templates --recipe plan --policy-only +python3 scripts/aro_request.py templates --recipe preferences --policy-only +python3 scripts/aro_request.py templates --recipe mp_pt --policy-only +python3 scripts/aro_request.py templates --recipe recommend --policy-only +``` + +The response includes: + +- `recommended_recipe` +- `recommended_recipe_detail.first_call` +- `recommended_recipe_detail.calls` +- `first_confirmation_template` +- `confirmation_message` +- `auth.mode=query_apikey` +- `url_template` + +## Main Interaction Flow + +For natural-language resource work, use `route`: + +```bash +python3 scripts/aro_request.py route --text "MP搜索 蜘蛛侠" +python3 scripts/aro_request.py route --text "影巢搜索 蜘蛛侠" +python3 scripts/aro_request.py route --text "盘搜搜索 大君夫人" +python3 scripts/aro_request.py route --text "链接 https://pan.quark.cn/s/xxxx path=/飞书" +``` + +For numbered continuation, use `pick`. Positional and flagged forms are both supported: + +```bash +python3 scripts/aro_request.py pick 1 +python3 scripts/aro_request.py pick 11 --path /飞书 +python3 scripts/aro_request.py pick 1 详情 +python3 scripts/aro_request.py pick 详情 +python3 scripts/aro_request.py pick 下一页 +``` + +Common diagnostic helpers also support shorter positional forms: + +```bash +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +For session inspection and recovery: + +```bash +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py recover --execute +python3 scripts/aro_request.py templates --recipe followup --compact +python3 scripts/aro_request.py history --limit 10 +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans --limit 10 +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans --executed --include-actions --limit 5 +python3 scripts/aro_request.py plan-execute plan-xxx +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py followup plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +Notes: + +- `sessions`, `history`, `plans`, and `recover` no longer force `session=default` when you do not pass `--session`. +- Use `--session` or `--session-id` only when you want to narrow to one conversation. +- Use `sessions --kind ...` or `sessions --has-pending-p115` when you want recovery-oriented filtering. +- Use `followup` after `plan-execute` when you want the plugin to choose the correct read-only next step automatically. +- Use `session-clear` or `sessions-clear` to clear abandoned assistant state after user confirmation. +- Use `plans-clear --plan-id ...` for exact saved-plan cleanup. Treat bulk cleanup flags as write-side-effect operations requiring confirmation. +- For long-lived WeChat, WorkBuddy, Claw, Hermes, or OpenClaw threads, stale compressed context can cause bad rewrites such as changing `15详情` into `选择 15`. When that happens, clear the current ARO session and saved plans, then reload this skill. Do not run session cleanup before ordinary search or update-check commands, because normal numbered follow-up depends on session continuity. + +Long-thread cleanup example: + +```bash +python3 scripts/aro_request.py session-clear --session default +python3 scripts/aro_request.py plans-clear --session default +``` + +## Preferences And Scoring + +Before the first automated resource task in a new user profile, check preferences: + +```bash +python3 scripts/aro_request.py preferences --session agent:<用户ID> +python3 scripts/aro_request.py templates --recipe preferences --compact +python3 scripts/aro_request.py scoring-policy +``` + +Most assistant responses also include compact `preference_status`. If `preference_status.needs_onboarding=true`, pause automation, ask the user for preferences, then save them before choosing downloads, unlocks, or transfers. + +Search responses may include compact `score_summary`. Prefer `score_summary.best` and `score_summary.top_recommendations` over parsing the natural-language message. Treat `hard_risk_reasons` as blocking automation; treat `risk_reasons` as warnings to explain before asking for confirmation. If `score_level=confirm`, explain the reasons and ask the user before executing. + +If `needs_onboarding=true`, ask the user for a compact preference profile and save it: + +```bash +python3 scripts/aro_request.py preferences --session agent:<用户ID> --preferences-json '{"prefer_resolution":"4K","prefer_dolby_vision":true,"prefer_hdr":true,"prefer_chinese_subtitle":true,"prefer_complete_series":true,"prefer_cloud_provider":"115","pt_require_free":false,"pt_min_seeders":3,"hdhive_max_unlock_points":20,"p115_default_path":"/待整理","quark_default_path":"/飞书","auto_ingest_enabled":false,"auto_ingest_score_threshold":90}' +``` + +You may also manage preferences through the main natural-language route: + +```bash +python3 scripts/aro_request.py route --text "偏好" --session agent:<用户ID> +python3 scripts/aro_request.py route --text "保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库" --session agent:<用户ID> +python3 scripts/aro_request.py route --text "重置偏好" --session agent:<用户ID> +``` + +Scoring rules are source-specific and plugin-owned. Use `scoring-policy` or `capabilities` to read the current policy when you need to explain the rules to the user. Do not invent a separate score in the agent. + +- Cloud resources: HDHive, PanSou 115, PanSou Quark, direct 115/Quark links. Score quality, Dolby Vision/HDR, subtitles, completeness, file size, drive preference, and target directory. HDHive also checks point cost. +- PT resources: MoviePilot native site search/download/subscribe. Score seeders, free/promo status, volume factor, resolution, Dolby Vision/HDR, subtitles, release group/site, size, and title match. +- PT seeders are a hard gate. Default minimum is `3`; seeders `0` means never auto-download. +- HDHive point cost is a hard gate. Default max is `20`; unknown points cannot auto-unlock. +- Auto ingest is off by default. Even when `can_auto_execute=true`, the current PT interaction policy should still prefer `plan_id` first unless an internal system path explicitly executes the saved plan. + +For MP native workflows: + +```bash +python3 scripts/aro_request.py workflow --workflow mp_search --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_download_history --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_lifecycle_status --keyword "蜘蛛侠" --limit 5 +python3 scripts/aro_request.py workflow --workflow mp_ingest_status --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_ingest_failures --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_recent_activity --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_local_diagnose --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_subscribe --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_transfer_history --keyword "蜘蛛侠" --status all --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode mp +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode pansou +``` + +`mp_search_download`, `mp_subscribe`, and `mp_subscribe_and_search` are write-side-effect workflows. They should return a saved `plan_id` first; execute with `plan-execute` only after the user confirms. + +`mp_transfer_history` is read-only. Use it after downloads or transfers to check whether MoviePilot has already organized the media into the library. Prefer the structured `items` fields and path previews; do not ask for full local paths unless the user explicitly needs troubleshooting detail. + +`mp_download_history` is read-only. Use it before `mp_transfer_history` when the user asks whether a PT/native MP resource was ever submitted for download. It also reports a compact transfer status when the download hash can be linked to MoviePilot transfer history. + +`mp_lifecycle_status` is read-only and should be the default troubleshooting query for “where is this resource now?”. It combines active download tasks, download history, and transfer/import history in one call. + +`mp_ingest_status` is read-only and should be the shortest answer path for “has this PT/local resource entered the library yet?”. It returns a structured `diagnosis_summary` with `stage`, `confidence`, `evidence`, `risk_reasons`, `recommended_action`, and `follow_up_hint`. + +`mp_ingest_failures` is read-only and focuses on transfer/import failures. Use it when the user asks “why did this fail to ingest?” or wants the recent failed records without reading the full transfer history. + +`mp_recent_activity` is read-only and gives a quick view of recent downloads and recent ingest activity. Use it when there is no exact title yet and the user asks what MoviePilot did recently. + +`mp_local_diagnose` is read-only and should be the one-stop path for “为什么没入库 / where is it stuck locally?”. Prefer it after `mp_ingest_status` or execution follow-up when the plugin already detected failure clues. + +`mp_media_detail` is read-only. Use it before search/download/subscribe when the title is ambiguous or the agent needs to confirm MoviePilot's native media recognition, TMDB/Douban/IMDB IDs, year, and media type. + +`mp_search_detail` is read-only. Use it after or together with MP native search when the user wants to inspect a numbered PT candidate. It shows seeders, promotion, size, score reasons, and risks. Do not download from this detail step; ask for confirmation or generate a plan before downloading. + +`mp_search_best` is read-only and token-efficient. Use it when the user asks the agent to recommend the best PT candidate after MP native search. It searches, ranks by the plugin-owned score, and returns the best candidate detail. It still does not download. + +After an MP search session, `下载最佳` generates a saved download plan for the current highest-scoring PT candidate. It does not download immediately; after user confirmation, execute the returned `plan_id` with `plan-execute`, route the natural text `执行计划` / `执行 plan-...`, or route the same resource number again when the plugin prompt says that number can confirm the pending plan. Then prefer `followup` so the plugin itself can decide whether the best next read is download history, lifecycle, subscribes, or transfer history. + +Even if a PT candidate scores high, the current default interaction policy is still `plan_id` first. Treat `can_auto_execute` as a score signal for explanation only; do not assume `下载1` or `下载最佳` will bypass confirmation. + +For cloud-drive result sessions, `最佳片源` is read-only. It returns the highest-scoring PanSou or HDHive resource detail and must not transfer or unlock by itself. `选择 N 详情` is also read-only. For ordinary `搜索/找 <片名>` sessions, prefer direct numbered picks first and use `计划选择 N` only when the user explicitly wants a saved confirmation plan. Use direct `选择 N` for immediate transfer/unlock after the user confirms that intent. + +For ordinary `搜索/找 <片名>` sessions, relay the plugin's original numbered list and next-step hints first. You may add a smart recommendation after the list, including a shortlist or tradeoff explanation, but do not replace, renumber, or hide the original list body. + +`mp_recommend_search` is the low-token recommendation chain. Without `choice`, it returns a recommendation list and stores the session. With `choice`, it immediately continues the selected title into `mode=mp`, `mode=hdhive`, or `mode=pansou`. + +After a recommendation list, natural-language picks are valid: + +```text +选择 1 +计划选择 1 +选择 1 盘搜 +选择1影巢 +选 2 mp +``` + +After an MP native search result, natural-language write commands are valid. They still follow the plugin's confirmation/plan rules: + +```text +下载1 +下载第1个 +订阅蜘蛛侠 +订阅并搜索蜘蛛侠 +``` + +Download task management also uses the same route. Querying tasks is read-only. Pausing, resuming, and deleting tasks are write actions and should return a saved `plan_id` first: + +```text +下载任务 +记录 +记录 蜘蛛侠 +状态 蜘蛛侠 +入库 蜘蛛侠 +整理失败 蜘蛛侠 +最近 +最近下载 +诊断 蜘蛛侠 +后续 +跟进 +跟进 蜘蛛侠 +识别 蜘蛛侠 +选择 1 +最佳片源 +下载最佳 +暂停下载 1 +恢复下载 1 +删除下载 1 +``` + +PT environment diagnostics are read-only and safe. Site results are sanitized and must not expose cookies: + +```text +站点状态 +下载器状态 +``` + +MP subscription management follows the same rule. Querying subscriptions is read-only; searching, pausing, resuming, and deleting subscriptions are write actions and should return a saved `plan_id` first: + +```text +订阅列表 +搜索订阅 1 +暂停订阅 1 +恢复订阅 1 +删除订阅 1 +``` + +Transfer/import history is read-only and safe. Use it to answer “did this land in the library?”: + +```text +入库历史 +入库失败 蜘蛛侠 +整理成功 地狱乐 +``` + +Natural-language route examples that should call recommendations: + +```text +看看最近有什么热门影视 +热门电影 +豆瓣热门电影 +正在热映 +今日番剧 +``` + +## Confirmation Rules + +Do not execute confirmation-required calls silently. + +If `recommended_recipe_detail.confirmation_message` says a step needs confirmation, show that message to the user before executing that step. + +Common confirmation points: + +- `saved_plan_execute` +- `maintain_execute` +- `pick_continue` +- `mp_search_download` +- `mp_subscribe` +- `mp_subscribe_and_search` + +## Maintenance And Health + +Use selfcheck for protocol health: + +```bash +python3 scripts/aro_request.py selfcheck +``` + +Preview maintenance without writing: + +```bash +python3 scripts/aro_request.py maintain +``` + +Execute maintenance only after confirmation: + +```bash +python3 scripts/aro_request.py maintain --execute +``` + +## Guardrails + +- Do not call HDHive, 115, Quark, or PanSou raw APIs directly when `AgentResourceOfficer` can handle the workflow. +- Do not unlock paid resources or execute write-side-effect calls without explicit confirmation. +- Respect `hdhive_resource_enabled` and `hdhive_max_unlock_points` returned by readiness/capabilities. The default point limit is 20. If a HDHive resource is above the limit or the plugin cannot confirm its points, tell the user the exact point cost/risk and ask them to raise the limit or set it to 0 before retrying. Do not bypass the guardrail. +- Prefer `include_templates=false` for low token startup. +- Use full templates only when parameters are unclear. +- Keep user-facing output short: show options, ask for a number, report result. + +## Relationship To MoviePilot-Skill + +`MoviePilot-Skill` is useful for MP native API operations such as subscriptions, downloads, sites, storage, and dashboard data. + +This skill is for the resource workflow hub: + +- HDHive search and unlock +- PanSou search +- 115 and Quark share routing +- MP native search/download/subscribe/recommendation orchestration +- cloud/PT scoring and preference-aware automation advice +- 115 login/status/pending tasks +- session recovery +- recipe-guided assistant calls + +Use both together when needed, but keep their auth modes separate: + +- AgentResourceOfficer plugin endpoints: `?apikey=MP_API_TOKEN` +- MP native API skill: usually `X-API-KEY` diff --git a/scripts/archive-local-branches.py b/scripts/archive-local-branches.py new file mode 100644 index 0000000..090445b --- /dev/null +++ b/scripts/archive-local-branches.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args: str) -> str: + completed = subprocess.run( + args, + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout + + +def local_branches() -> list[str]: + output = run("git", "branch", "--format", "%(refname:short)") + return [branch.strip() for branch in output.splitlines() if branch.strip() and branch.strip() != "main"] + + +def tag_exists(tag: str) -> bool: + completed = subprocess.run( + ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], + cwd=ROOT, + capture_output=True, + text=True, + ) + return completed.returncode == 0 + + +def archive_plan() -> list[dict[str, str | bool]]: + plan = [] + for branch in local_branches(): + tag = f"archive/{branch}" + plan.append( + { + "branch": branch, + "tag": tag, + "tag_exists": tag_exists(tag), + } + ) + return plan + + +def apply_plan(plan: list[dict[str, str | bool]]) -> None: + for item in plan: + branch = str(item["branch"]) + tag = str(item["tag"]) + if not item["tag_exists"]: + subprocess.run( + ["git", "tag", "-a", tag, branch, "-m", f"Archive local branch {branch} before cleanup"], + cwd=ROOT, + check=True, + ) + subprocess.run(["git", "branch", "-D", branch], cwd=ROOT, check=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Archive local non-main branches into local archive/* tags.") + parser.add_argument("--apply", action="store_true", help="Create archive tags and delete local branches.") + args = parser.parse_args() + + plan = archive_plan() + payload = { + "mode": "apply" if args.apply else "dry_run", + "count": len(plan), + "branches": plan, + } + + if args.apply: + apply_plan(plan) + payload["result"] = "applied" + + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/audit-remote-branches.py b/scripts/audit-remote-branches.py new file mode 100644 index 0000000..4adef44 --- /dev/null +++ b/scripts/audit-remote-branches.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def show_help() -> None: + print( + "Usage:\n" + " python3 scripts/audit-remote-branches.py\n\n" + "Prints JSON describing remote non-main branches and local non-main\n" + "branches, including PR linkage, ancestry, unique patch counts and\n" + "cleanup recommendations." + ) + + +def run(*args: str) -> str: + completed = subprocess.run( + args, + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + return completed.stdout + + +def ref_exists(ref: str) -> bool: + completed = subprocess.run( + ["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], + cwd=ROOT, + capture_output=True, + text=True, + ) + return completed.returncode == 0 + + +def remote_branches() -> list[str]: + output = run("git", "branch", "-r", "--format", "%(refname:short)") + return [ + branch.strip() + for branch in output.splitlines() + if branch.strip() + and "->" not in branch + and branch.strip() not in {"origin", "origin/main"} + ] + + +def local_branches() -> list[str]: + output = run("git", "branch", "--format", "%(refname:short)") + return [branch.strip() for branch in output.splitlines() if branch.strip() and branch.strip() != "main"] + + +def pr_map() -> tuple[dict[str, dict], str]: + try: + output = run( + "gh", + "pr", + "list", + "--state", + "all", + "--limit", + "100", + "--json", + "number,state,headRefName,baseRefName,title", + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return {}, "unavailable" + + items = json.loads(output) + mapping = {} + for item in items: + head = item.get("headRefName") + if head: + mapping[head] = item + return mapping, "ok" + + +def is_ancestor(branch: str) -> bool: + if not ref_exists(branch): + return False + completed = subprocess.run( + ["git", "merge-base", "--is-ancestor", branch, "main"], + cwd=ROOT, + capture_output=True, + text=True, + ) + return completed.returncode == 0 + + +def cherry_unique_count(branch: str) -> int: + if not ref_exists(branch): + return -1 + try: + output = run("git", "cherry", "main", branch) + except subprocess.CalledProcessError: + return -1 + return len([line for line in output.splitlines() if line.startswith("+ ")]) + + +def remote_recommendation(*, has_pr: bool, pr_state: str | None, ancestor: bool, unique_count: int) -> str: + if unique_count < 0: + return "unavailable" + if has_pr and pr_state == "MERGED": + return "safe_to_prune_after_fetch" + if unique_count == 0: + return "likely_stale_equivalent_history" + if ancestor: + return "likely_stale_check_history" + if has_pr: + return f"keep_{str(pr_state).lower()}" + return "manual_review" + + +def local_recommendation(*, has_remote: bool, has_pr: bool, pr_state: str | None, ancestor: bool, unique_count: int) -> str: + if unique_count < 0: + return "unavailable" + if ancestor or unique_count == 0: + return "safe_to_delete_local_copy" + if has_pr and pr_state == "OPEN": + return "keep_open_pr_branch" + if has_remote: + return "keep_tracking_remote" + return "manual_review" + + +def main() -> int: + if len(sys.argv) > 1 and sys.argv[1] in {"--help", "-h"}: + show_help() + return 0 + + prs, pr_lookup = pr_map() + remotes = remote_branches() + locals_ = local_branches() + remote_set = set(remotes) + + remote_rows = [] + for branch in remotes: + short = branch.removeprefix("origin/") + pr = prs.get(short) + ancestor = is_ancestor(branch) + unique_count = cherry_unique_count(branch) + remote_rows.append( + { + "branch": branch, + "pr": pr.get("number") if pr else None, + "pr_state": pr.get("state") if pr else None, + "ancestor_of_main": ancestor, + "unique_patch_commits_vs_main": unique_count, + "recommendation": remote_recommendation( + has_pr=bool(pr), + pr_state=pr.get("state") if pr else None, + ancestor=ancestor, + unique_count=unique_count, + ), + "title": pr.get("title") if pr else None, + } + ) + + local_rows = [] + for branch in locals_: + pr = prs.get(branch) + ancestor = is_ancestor(branch) + unique_count = cherry_unique_count(branch) + has_remote = f"origin/{branch}" in remote_set + local_rows.append( + { + "branch": branch, + "has_remote": has_remote, + "pr": pr.get("number") if pr else None, + "pr_state": pr.get("state") if pr else None, + "ancestor_of_main": ancestor, + "unique_patch_commits_vs_main": unique_count, + "recommendation": local_recommendation( + has_remote=has_remote, + has_pr=bool(pr), + pr_state=pr.get("state") if pr else None, + ancestor=ancestor, + unique_count=unique_count, + ), + "title": pr.get("title") if pr else None, + } + ) + + print( + json.dumps( + { + "pr_lookup": pr_lookup, + "remote_branches": remote_rows, + "local_branches": local_rows, + }, + ensure_ascii=False, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-agent-resource-officer-feishu.py b/scripts/check-agent-resource-officer-feishu.py new file mode 100755 index 0000000..161329a --- /dev/null +++ b/scripts/check-agent-resource-officer-feishu.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +import importlib.util +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "AgentResourceOfficer" / "feishu_channel.py" +CORE_PATH = ROOT / "AgentResourceOfficer" / "__init__.py" +FORM_PATHS = [ + ROOT / "AgentResourceOfficer" / "__init__.py", + ROOT / "plugins" / "agentresourceofficer" / "__init__.py", + ROOT / "plugins.v2" / "agentresourceofficer" / "__init__.py", +] + + +class FakePlugin: + def get_config(self): + return {} + + def get_state(self): + return True + + @staticmethod + def _extract_first_url(text): + match = re.search(r"https?://\S+", str(text or "")) + return match.group(0) if match else "" + + @staticmethod + def _is_115_url(url): + return "115cdn.com" in str(url or "") + + @staticmethod + def _is_quark_url(url): + return "pan.quark.cn" in str(url or "") + + +def load_channel_class(): + spec = importlib.util.spec_from_file_location("agent_resource_officer_feishu_channel", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.FeishuChannel + + +def check(name, condition): + if not condition: + raise AssertionError(name) + + +def main(): + channel_cls = load_channel_class() + channel = channel_cls(FakePlugin()) + channel.configure({}) + default_whitelist = set(channel_cls.default_command_whitelist()) + default_alias_targets = set(channel_cls.parse_alias_text(channel_cls.default_command_aliases()).values()) + missing_alias_targets = sorted(default_alias_targets - default_whitelist) + check("all default alias targets are whitelisted", not missing_alias_targets) + + cases = { + "yc蜘蛛侠": "/smart_entry 蜘蛛侠", + "2蜘蛛侠": "/smart_entry 蜘蛛侠", + "ps大君夫人": "/pansou_search 大君夫人", + "1大君夫人": "/pansou_search 大君夫人", + "选择 1 path=/飞书": "/smart_pick 1 path=/飞书", + "详情": "/smart_pick 详情", + "审查": "/smart_pick 审查", + "n 下一页": "/smart_pick n 下一页", + "https://pan.quark.cn/s/xxxx": "/smart_entry https://pan.quark.cn/s/xxxx", + "链接 https://115cdn.com/s/xxxx path=/待整理": "/smart_entry 链接 https://115cdn.com/s/xxxx path=/待整理", + } + for raw, expected in cases.items(): + check(f"map {raw}", channel._map_text_to_command(raw) == expected) + + health = channel.health() + check("health has legacy_bridge_running", "legacy_bridge_running" in health) + check("health has conflict_warning", "conflict_warning" in health) + check("health has safe_to_enable", "safe_to_enable" in health) + check("health has recommended_action", "recommended_action" in health) + check("health has migration_hint", "migration_hint" in health) + check("default conflict false", health["conflict_warning"] is False) + + channel.configure({"feishu_enabled": True}) + channel.is_legacy_bridge_running = lambda: True + health = channel.health() + check("conflict true when both enabled", health["legacy_bridge_running"] is True and health["conflict_warning"] is True) + check("conflict recommends disabling legacy", health["recommended_action"] == "disable_legacy_bridge_or_use_different_app") + + required_form_models = [ + '"model": "feishu_reply_receive_id_type"', + '"model": "feishu_command_whitelist"', + '"model": "feishu_command_aliases"', + ] + for path in FORM_PATHS: + text = path.read_text(encoding="utf-8") + for needle in required_form_models: + check(f"{path.relative_to(ROOT)} has {needle}", needle in text) + + core_text = CORE_PATH.read_text(encoding="utf-8") + for needle in [ + '("MP搜索", "mp")', + '("原生搜索", "mp")', + 'if mode == "mp":', + '"action": "media_search"', + ]: + check(f"core assistant route supports {needle}", needle in core_text) + + print("agent_resource_officer_feishu_channel_check_ok") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-doc-current-state.py b/scripts/check-doc-current-state.py new file mode 100644 index 0000000..2c1c3ee --- /dev/null +++ b/scripts/check-doc-current-state.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def show_help() -> None: + print( + "Usage:\n" + " python3 scripts/check-doc-current-state.py\n\n" + "Checks whether current-status documents and readmes match the live\n" + "plugin version, helper version and release URL." + ) + + +def read_text(rel_path: str) -> str: + return (ROOT / rel_path).read_text(encoding="utf-8") + + +def extract_pattern(text: str, pattern: str, label: str) -> str: + match = re.search(pattern, text, re.MULTILINE) + if not match: + raise SystemExit(f"missing_{label}") + return match.group(1) + + +plugin_init = read_text("AgentResourceOfficer/__init__.py") +helper_script = read_text("skills/agent-resource-officer/scripts/aro_request.py") +ai_plugin_init = read_text("AIRecognizerEnhancer/__init__.py") + +if len(sys.argv) > 1 and sys.argv[1] in {"--help", "-h"}: + show_help() + raise SystemExit(0) + +plugin_version = extract_pattern( + plugin_init, + r'plugin_version\s*=\s*"([^"]+)"', + "plugin_version", +) +helper_version = extract_pattern( + helper_script, + r'HELPER_VERSION\s*=\s*"([^"]+)"', + "helper_version", +) +ai_plugin_version = extract_pattern( + ai_plugin_init, + r'plugin_version\s*=\s*"([^"]+)"', + "ai_plugin_version", +) +release_url = f"https://github.com/liuyuexi1987/MoviePilot-Plugins/releases/tag/v{plugin_version}" +plugin_zip = f"AgentResourceOfficer-{plugin_version}.zip" + +checks = { + "README.md": [ + f"当前发布版本:`{plugin_version}`", + f"当前 Skill helper 版本:`{helper_version}`", + release_url, + f"当前版本:\n\n- `{plugin_version}`", + ], + "docs/PLUGIN_INSTALL.md": [ + f"资源主线:`Agent影视助手 / AgentResourceOfficer {plugin_version}`", + f"当前 Skill helper:`agent-resource-officer {helper_version}`", + release_url, + plugin_zip, + ], + "docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md": [ + f"当前插件版本:`Agent影视助手 {plugin_version}`", + f"当前 helper 版本:`agent-resource-officer {helper_version}`", + ], + "docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md": [ + f"当前插件版本:`Agent影视助手 {plugin_version}`", + f"当前 helper 版本:`agent-resource-officer {helper_version}`", + ], + "docs/MAINTENANCE_COMMANDS.md": [ + f"当前插件版本:`AgentResourceOfficer {plugin_version}`", + f"当前 Skill helper 版本:`{helper_version}`", + release_url, + ], + "skills/agent-resource-officer/README.md": [ + f"当前 helper 版本:`{helper_version}`", + f"当前插件版本:`Agent影视助手 {plugin_version}`", + ], + "skills/agent-resource-officer/EXTERNAL_AGENTS.md": [ + f"当前插件版本:`Agent影视助手 {plugin_version}`", + f"当前 helper 版本:`agent-resource-officer {helper_version}`", + ], + "AgentResourceOfficer/README.md": [ + f"当前版本:`{plugin_version}`", + f"当前 helper 版本:`{helper_version}`", + release_url, + ], + "plugins/agentresourceofficer/README.md": [ + f"当前版本:`{plugin_version}`", + f"当前 helper 版本:`{helper_version}`", + release_url, + ], + "plugins.v2/agentresourceofficer/README.md": [ + f"当前版本:`{plugin_version}`", + f"当前 helper 版本:`{helper_version}`", + release_url, + ], + "AIRecognizerEnhancer/README.md": [ + f"当前版本:`{ai_plugin_version}`", + release_url, + ], + "plugins/airecognizerenhancer/README.md": [ + f"当前版本:`{ai_plugin_version}`", + release_url, + ], + "plugins.v2/airecognizerenhancer/README.md": [ + f"当前版本:`{ai_plugin_version}`", + release_url, + ], +} + +maintenance_link_checks = { + "README.md": ["docs/MAINTENANCE_COMMANDS.md"], + "docs/INDEX.md": ["MAINTENANCE_COMMANDS.md"], + "docs/GITHUB_PUBLISH.md": ["docs/MAINTENANCE_COMMANDS.md"], + "docs/RELEASE_CHECKLIST.md": ["docs/MAINTENANCE_COMMANDS.md"], + "docs/PACKAGING.md": ["docs/MAINTENANCE_COMMANDS.md"], + "docs/PLUGIN_INSTALL.md": ["docs/MAINTENANCE_COMMANDS.md"], +} + +failures: list[str] = [] +for rel_path, required_fragments in checks.items(): + text = read_text(rel_path) + for fragment in required_fragments: + if fragment not in text: + failures.append(f"{rel_path}: missing {fragment}") + +for rel_path, required_fragments in maintenance_link_checks.items(): + text = read_text(rel_path) + for fragment in required_fragments: + if fragment not in text: + failures.append(f"{rel_path}: missing {fragment}") + +if failures: + print("doc_current_state_mismatch") + for failure in failures: + print(failure) + raise SystemExit(1) + +print( + f"doc_current_state_ok plugin={plugin_version} helper={helper_version} release={release_url}" +) diff --git a/scripts/check-maintenance-commands.py b/scripts/check-maintenance-commands.py new file mode 100644 index 0000000..227cd39 --- /dev/null +++ b/scripts/check-maintenance-commands.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + +HELP_SHELL_SCRIPTS = [ + "scripts/repo-hygiene.sh", + "scripts/release-preflight.sh", + "scripts/pre-release-check.sh", + "scripts/check-skills.sh", + "scripts/clean-generated.sh", + "scripts/package-plugin.sh", + "scripts/package-skills.sh", + "scripts/sync-repo-layout.sh", + "scripts/sync-package-v2.sh", + "scripts/create-draft-release.sh", + "scripts/update-draft-release-assets.sh", + "scripts/generate-release-notes.sh", + "scripts/write-dist-sha256.sh", + "scripts/patch-p115strmhelper-mp-compat.sh", + "scripts/verify-release-preflight-artifact.sh", + "scripts/verify-ci-artifact.sh", + "scripts/verify-release-download.sh", + "scripts/verify-release-assets.sh", + "scripts/verify-dist.sh", + "scripts/verify-skill-dist.sh", + "scripts/print-release-summary.sh", + "scripts/print-skill-release-summary.sh", +] + +HELP_PYTHON_SCRIPTS = [ + "scripts/check-doc-current-state.py", + "scripts/audit-remote-branches.py", + "scripts/archive-local-branches.py", +] + + +def show_help() -> None: + print( + "Usage:\n" + " python3 scripts/check-maintenance-commands.py\n\n" + "Checks that high-frequency maintenance scripts support --help and that\n" + "docs/MAINTENANCE_COMMANDS.md lists the same commands." + ) + + +def run_help(command: list[str]) -> None: + subprocess.run(command, cwd=ROOT, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def main() -> int: + if len(sys.argv) > 1 and sys.argv[1] in {"--help", "-h"}: + show_help() + return 0 + + maintenance_doc = (ROOT / "docs/MAINTENANCE_COMMANDS.md").read_text(encoding="utf-8") + missing: list[str] = [] + + for rel_path in HELP_SHELL_SCRIPTS: + run_help(["bash", rel_path, "--help"]) + name = Path(rel_path).name + if f"`{name}`" not in maintenance_doc: + missing.append(name) + + for rel_path in HELP_PYTHON_SCRIPTS: + run_help(["python3", rel_path, "--help"]) + name = Path(rel_path).name + if f"`{name}`" not in maintenance_doc: + missing.append(name) + + if missing: + print("docs/MAINTENANCE_COMMANDS.md 缺少帮助脚本清单:") + for name in missing: + print(name) + return 1 + + count = len(HELP_SHELL_SCRIPTS) + len(HELP_PYTHON_SCRIPTS) + print(f"maintenance_commands_ok count={count}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-skills.sh b/scripts/check-skills.sh new file mode 100755 index 0000000..f20e00d --- /dev/null +++ b/scripts/check-skills.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" +export PYTHONDONTWRITEBYTECODE=1 + +show_help() { + cat <<'EOF' +Usage: + bash scripts/check-skills.sh + +Runs public skill checks: +- required file presence +- helper selftests +- install dry-runs +- helper version drift checks +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +EXPECTED_SKILLS=( + agent-resource-officer + hdhive-search-unlock-to-115 +) + +skill_install_dry_run() { + local skill_name="$1" + bash "skills/${skill_name}/install.sh" --dry-run --target "$ROOT_DIR/.tmp-skill-install-check/${skill_name}" >/dev/null + echo "${skill_name}_install_dry_run_ok" +} + +actual_skills="$(find skills -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort | tr '\n' ' ' | sed 's/ $//')" +expected_skills="$(printf '%s\n' "${EXPECTED_SKILLS[@]}" | sort | tr '\n' ' ' | sed 's/ $//')" +if [[ "$actual_skills" != "$expected_skills" ]]; then + echo "skills/ 目录清单与发布检查清单不一致" >&2 + echo "expected: $expected_skills" >&2 + echo "actual: $actual_skills" >&2 + exit 1 +fi + +for skill_name in "${EXPECTED_SKILLS[@]}"; do + for required in SKILL.md README.md CHANGELOG.md install.sh; do + if [[ ! -f "skills/${skill_name}/${required}" ]]; then + echo "Skill 文件缺失: skills/${skill_name}/${required}" >&2 + exit 1 + fi + done +done + +python3 skills/agent-resource-officer/scripts/aro_request.py selftest >/dev/null +echo "agent_resource_officer_skill_selftest_ok" +python3 - <<'PY' +import json +import subprocess + +raw = subprocess.check_output( + ["python3", "skills/agent-resource-officer/scripts/aro_request.py", "external-agent"], + text=True, +) +payload = json.loads(raw) +if payload.get("schema_version") != "external_agent.v1": + raise SystemExit("agent-resource-officer external-agent schema_version invalid") +if not payload.get("guide_file_exists"): + raise SystemExit("agent-resource-officer external-agent guide file missing") +if len(payload.get("tools") or []) != 4: + raise SystemExit("agent-resource-officer external-agent tool contract invalid") +print("agent_resource_officer_external_agent_entry_ok") +PY +skill_install_dry_run "agent-resource-officer" + +python3 skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py selftest >/dev/null +echo "hdhive_skill_selftest_ok" +skill_install_dry_run "hdhive-search-unlock-to-115" + +python3 - <<'PY' +import ast +import subprocess +from pathlib import Path + +skills = [ + ( + "AgentResourceOfficer", + Path("skills/agent-resource-officer/scripts/aro_request.py"), + Path("skills/agent-resource-officer/README.md"), + Path("skills/agent-resource-officer/CHANGELOG.md"), + ["python3", "skills/agent-resource-officer/scripts/aro_request.py", "version"], + "agent_resource_officer_helper_version_ok", + ), + ( + "hdhive-search-unlock-to-115", + Path("skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py"), + Path("skills/hdhive-search-unlock-to-115/README.md"), + Path("skills/hdhive-search-unlock-to-115/CHANGELOG.md"), + ["python3", "skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py", "version"], + "hdhive_skill_helper_version_ok", + ), +] + + +def read_helper_version(helper_file: Path) -> str: + tree = ast.parse(helper_file.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "HELPER_VERSION" and isinstance(node.value, ast.Constant): + return str(node.value.value) + return "" + + +for display_name, helper_file, readme_file, changelog_file, version_command, ok_label in skills: + helper_version = read_helper_version(helper_file) + if not helper_version: + print(f"{display_name} helper 版本未找到") + raise SystemExit(1) + readme = readme_file.read_text(encoding="utf-8") + changelog = changelog_file.read_text(encoding="utf-8") + if f"当前 helper 版本:`{helper_version}`" not in readme: + print(f"{display_name} README helper 版本未同步") + raise SystemExit(1) + if f"## {helper_version}" not in changelog: + print(f"{display_name} CHANGELOG 缺少当前 helper 版本") + raise SystemExit(1) + subprocess.run(version_command, check=True, stdout=subprocess.DEVNULL) + print(f"{ok_label} {helper_version}") +PY + +echo "skills_check_ok" diff --git a/scripts/clean-generated.sh b/scripts/clean-generated.sh new file mode 100755 index 0000000..294153e --- /dev/null +++ b/scripts/clean-generated.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +CLEAN_DIST=0 + +show_help() { + cat <<'EOF' +Usage: + bash scripts/clean-generated.sh [--dist] + +Removes local generated files that should never be committed. + +Options: + --dist Also remove dist/ release assets. + --help Show this help. +EOF +} + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --dist) + CLEAN_DIST=1 + shift + ;; + --help|-h) + show_help + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + show_help >&2 + exit 2 + ;; + esac +done + +find . \ + -path ./.git -prune -o \ + -name '__pycache__' -type d -print -exec rm -rf {} + \ + -o -name '*.pyc' -type f -print -delete \ + -o -name '*.pyo' -type f -print -delete \ + -o -name '.DS_Store' -type f -print -delete + +if [[ "$CLEAN_DIST" == "1" ]]; then + rm -rf dist + echo "removed dist/" +fi + +echo "clean_generated_ok" diff --git a/scripts/create-draft-release.sh b/scripts/create-draft-release.sh new file mode 100755 index 0000000..a0c953e --- /dev/null +++ b/scripts/create-draft-release.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/create-draft-release.sh [--dry-run] [--skip-check] + +Options: + GitHub Release tag, for example v2026.04.28.1 + --dry-run Run checks and print the release command without creating a release + --skip-check Skip release-preflight.sh and use existing dist/ files + --help Show this help +EOF +} + +TAG="" +DRY_RUN=0 +SKIP_CHECK=0 +for arg in "$@"; do + case "$arg" in + --dry-run) + DRY_RUN=1 + ;; + --skip-check) + SKIP_CHECK=1 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + if [ -z "$TAG" ]; then + TAG="$arg" + else + echo "未知参数: $arg" >&2 + show_help >&2 + exit 1 + fi + ;; + esac +done + +if [ -z "$TAG" ]; then + echo "缺少 release tag。" >&2 + show_help >&2 + exit 1 +fi + +if [ "$SKIP_CHECK" -eq 0 ]; then + bash scripts/release-preflight.sh +else + bash scripts/verify-release-assets.sh dist +fi + +notes_file="$(mktemp)" +asset_stage_dir="$(mktemp -d)" +cleanup() { + rm -f "$notes_file" + rm -rf "$asset_stage_dir" +} +trap cleanup EXIT + +bash scripts/generate-release-notes.sh "$TAG" >"$notes_file" + +cp dist/*.zip "$asset_stage_dir/" +cp dist/SHA256SUMS.txt "$asset_stage_dir/PLUGIN_SHA256SUMS.txt" +cp dist/MANIFEST.json "$asset_stage_dir/PLUGIN_MANIFEST.json" +cp dist/skills/*.zip "$asset_stage_dir/" +cp dist/skills/SHA256SUMS.txt "$asset_stage_dir/SKILL_SHA256SUMS.txt" +cp dist/skills/MANIFEST.json "$asset_stage_dir/SKILL_MANIFEST.json" + +files=("$asset_stage_dir"/*) +for file_path in "${files[@]}"; do + if [ ! -f "$file_path" ]; then + echo "缺少发布附件: $file_path" >&2 + exit 1 + fi +done + +if [ "$DRY_RUN" -eq 1 ]; then + echo "draft_release_dry_run_ok tag=$TAG" + echo "notes_file=$notes_file" + printf 'files=%s\n' "${files[@]}" + exit 0 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "未找到 gh 命令,无法创建 GitHub Release。" >&2 + exit 1 +fi + +gh release create "$TAG" \ + --draft \ + --title "$TAG" \ + --notes-file "$notes_file" \ + "${files[@]}" diff --git a/scripts/generate-release-notes.sh b/scripts/generate-release-notes.sh new file mode 100755 index 0000000..465d0c0 --- /dev/null +++ b/scripts/generate-release-notes.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +PLUGIN_VERSION="$(python3 - <<'PY' +import json +from pathlib import Path +data = json.loads(Path('package.json').read_text()) +print(((data.get('AgentResourceOfficer') or {}).get('version')) or '') +PY +)" + +PLUGIN_HIGHLIGHT="$(python3 - <<'PY' +import json +from pathlib import Path +data = json.loads(Path('package.json').read_text()) +plugin = data.get('AgentResourceOfficer') or {} +version = plugin.get('version', '') +history = plugin.get('history') or {} +print((history.get(version) or '').strip()) +PY +)" + +HELPER_VERSION="$(python3 - <<'PY' +import re +from pathlib import Path +text = Path('skills/agent-resource-officer/scripts/aro_request.py').read_text() +match = re.search(r'HELPER_VERSION = "([^"]+)"', text) +print(match.group(1) if match else '') +PY +)" + +HELPER_HIGHLIGHT="$(python3 - <<'PY' +import re +from pathlib import Path + +helper_text = Path('skills/agent-resource-officer/scripts/aro_request.py').read_text() +match = re.search(r'HELPER_VERSION = "([^"]+)"', helper_text) +version = match.group(1) if match else '' +lines = Path('skills/agent-resource-officer/CHANGELOG.md').read_text().splitlines() +target = None +for i, line in enumerate(lines): + if line.strip() == f'## {version}': + target = i + break +if target is None: + print('') +else: + bullets = [] + for line in lines[target + 1:]: + if line.startswith('## '): + break + if line.startswith('- '): + bullets.append(line[2:].strip()) + print(';'.join(bullets[:2])) +PY +)" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/generate-release-notes.sh + +Prints the unified GitHub Release notes body for the given tag. +Requires dist/ and dist/skills/ manifests to exist. +EOF +} + +TAG="${1:-}" +if [[ "$TAG" == "--help" || "$TAG" == "-h" ]]; then + show_help + exit 0 +fi +if [[ -z "$TAG" || "$#" -ne 1 ]]; then + echo "缺少 release tag。" >&2 + show_help >&2 + exit 2 +fi + +echo "# $TAG" +echo +echo "本次 Release 附件包含 MoviePilot 本地安装 ZIP、公开 Skill ZIP、PLUGIN/SKILL SHA256SUMS 和 MANIFEST。" +echo +echo "## 本次重点" +echo +echo "- AgentResourceOfficer 是推荐主入口,统一承接影巢、盘搜、115、夸克、飞书 Channel 和智能体 Tool。" +if [[ -n "$PLUGIN_VERSION" && -n "$PLUGIN_HIGHLIGHT" ]]; then + echo "- Agent影视助手 ${PLUGIN_VERSION}:${PLUGIN_HIGHLIGHT}" +fi +echo "- agent-resource-officer Skill 已内置 external-agent / external-agent --full,可直接生成外部智能体提示词和最小工具约定。" +if [[ -n "$HELPER_VERSION" && -n "$HELPER_HIGHLIGHT" ]]; then + echo "- agent-resource-officer helper ${HELPER_VERSION}:${HELPER_HIGHLIGHT}" +fi +echo "- live smoke 已覆盖 external-agent request templates、MP搜索、盘搜、影巢别名和 115状态。" +echo "- 内置飞书入口默认关闭;新用户可优先使用本插件内置飞书,旧 FeishuCommandBridgeLong 保留为兼容/备份插件。" +echo "- 115 直转层支持扫码会话;STRM 生成、302、全量/增量同步仍建议继续交给 P115StrmHelper。" +echo "- 附件已包含插件/Skill manifest 与 SHA256 校验文件,下载后可用 verify-release-download 校验。" +echo +bash scripts/print-release-summary.sh +echo +echo "## 公开 Skill 模板" +echo +bash scripts/print-skill-release-summary.sh diff --git a/scripts/package-plugin.sh b/scripts/package-plugin.sh new file mode 100755 index 0000000..183f5e5 --- /dev/null +++ b/scripts/package-plugin.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/package-plugin.sh [PluginName] + bash scripts/package-plugin.sh --list + bash scripts/package-plugin.sh --all + bash scripts/package-plugin.sh --help + +Options: + PluginName Package one plugin. Matching is case-insensitive via package.json. + --list List packageable plugin IDs and versions. + --all Package all plugins listed in package.json. + --help Show this help. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +if [[ "${1:-}" == "--list" ]]; then + ROOT_DIR="$ROOT_DIR" python3 - <<'PY' +import json +import os +from pathlib import Path + +package_file = Path(os.environ["ROOT_DIR"]) / "package.json" +package = json.loads(package_file.read_text(encoding="utf-8")) +for plugin_id, meta in package.items(): + print(f"{plugin_id}\t{meta.get('version', 'unknown')}") +PY + exit 0 +fi + +if [[ "${1:-}" == "--all" ]]; then + mkdir -p "$DIST_DIR" + rm -f "$DIST_DIR"/*.zip "$DIST_DIR/SHA256SUMS.txt" "$DIST_DIR/MANIFEST.json" + ROOT_DIR="$ROOT_DIR" python3 - <<'PY' | while IFS= read -r plugin_name; do +import json +import os +from pathlib import Path + +package_file = Path(os.environ["ROOT_DIR"]) / "package.json" +package = json.loads(package_file.read_text(encoding="utf-8")) +for plugin_id in package: + print(plugin_id) +PY + "$0" "$plugin_name" + done + bash "$ROOT_DIR/scripts/write-dist-sha256.sh" + bash "$ROOT_DIR/scripts/verify-dist.sh" + exit 0 +fi + +REQUESTED_PLUGIN_NAME="${1:-AIRecognizerEnhancer}" +PLUGIN_NAME="$(REQUESTED_PLUGIN_NAME="$REQUESTED_PLUGIN_NAME" ROOT_DIR="$ROOT_DIR" python3 - <<'PY' +import json +import os +from pathlib import Path + +requested = os.environ["REQUESTED_PLUGIN_NAME"] +package_file = Path(os.environ["ROOT_DIR"]) / "package.json" +if package_file.exists(): + package = json.loads(package_file.read_text(encoding="utf-8")) + for plugin_id in package: + if plugin_id.lower() == requested.lower(): + print(plugin_id) + break + else: + print(requested) +else: + print(requested) +PY +)" +PLUGIN_DIR="$ROOT_DIR/$PLUGIN_NAME" +PLUGIN_KEY="$(printf '%s' "$PLUGIN_NAME" | tr '[:upper:]' '[:lower:]')" +PLUGIN_DOC_DIR="$ROOT_DIR/$PLUGIN_NAME" + +if [ -x "$ROOT_DIR/scripts/sync-repo-layout.sh" ]; then + "$ROOT_DIR/scripts/sync-repo-layout.sh" >/dev/null +fi + +if [ ! -f "$PLUGIN_DIR/__init__.py" ]; then + if [ -f "$ROOT_DIR/plugins/$PLUGIN_KEY/__init__.py" ]; then + PLUGIN_DIR="$ROOT_DIR/plugins/$PLUGIN_KEY" + elif [ -f "$ROOT_DIR/plugins.v2/$PLUGIN_KEY/__init__.py" ]; then + PLUGIN_DIR="$ROOT_DIR/plugins.v2/$PLUGIN_KEY" + fi +fi + +if [ ! -f "$PLUGIN_DIR/__init__.py" ]; then + echo "插件源码目录不存在或缺少 __init__.py: $PLUGIN_NAME" >&2 + exit 1 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "未找到 zip 命令,请先安装 zip。" >&2 + exit 1 +fi + +VERSION="$(PLUGIN_DIR="$PLUGIN_DIR" python3 - <<'PY' +from pathlib import Path +import re +import os +plugin_dir = Path(os.environ["PLUGIN_DIR"]) +text = (plugin_dir / "__init__.py").read_text(encoding="utf-8") +match = re.search(r'plugin_version\s*=\s*"([^"]+)"', text) +print(match.group(1) if match else "unknown") +PY +)" + +mkdir -p "$DIST_DIR" + +ZIP_NAME="${PLUGIN_NAME}-${VERSION}.zip" +ZIP_PATH="$DIST_DIR/$ZIP_NAME" + +rm -f "$ZIP_PATH" + +STAGE_DIR="$(mktemp -d)" +cleanup() { + rm -rf "$STAGE_DIR" +} +trap cleanup EXIT + +STAGE_PLUGIN_DIR="$STAGE_DIR/$PLUGIN_NAME" +mkdir -p "$STAGE_PLUGIN_DIR" +if command -v rsync >/dev/null 2>&1; then + rsync -a \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '*.pyo' \ + --exclude '.DS_Store' \ + "$PLUGIN_DIR/" "$STAGE_PLUGIN_DIR/" +else + cp -R "$PLUGIN_DIR/." "$STAGE_PLUGIN_DIR/" + find "$STAGE_PLUGIN_DIR" -name '__pycache__' -type d -prune -exec rm -rf {} + + find "$STAGE_PLUGIN_DIR" \( -name '*.pyc' -o -name '*.pyo' -o -name '.DS_Store' \) -delete +fi + +if [ ! -f "$STAGE_PLUGIN_DIR/README.md" ] && [ -f "$PLUGIN_DOC_DIR/README.md" ]; then + cp "$PLUGIN_DOC_DIR/README.md" "$STAGE_PLUGIN_DIR/README.md" +fi + +cd "$STAGE_DIR" +zip -r "$ZIP_PATH" "$PLUGIN_NAME" \ + -x "*/__pycache__/*" \ + -x "*.pyc" \ + -x "*.pyo" \ + -x "*.DS_Store" >/dev/null + +echo "已生成插件安装包:" +echo "$ZIP_PATH" diff --git a/scripts/package-skills.sh b/scripts/package-skills.sh new file mode 100755 index 0000000..b8c4a6a --- /dev/null +++ b/scripts/package-skills.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$ROOT_DIR/dist/skills" +cd "$ROOT_DIR" +export PYTHONDONTWRITEBYTECODE=1 + +show_help() { + cat <<'EOF' +Usage: + bash scripts/package-skills.sh + bash scripts/package-skills.sh --help + +Packages public Codex Skill templates into dist/skills. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +if [[ "$#" -gt 0 ]]; then + echo "Unknown argument: $1" >&2 + show_help >&2 + exit 2 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "未找到 zip 命令,请先安装 zip。" >&2 + exit 1 +fi + +bash scripts/check-skills.sh >/dev/null + +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +python3 - <<'PY' | while IFS=$'\t' read -r skill_name helper_file; do +import ast +from pathlib import Path + + +def has_helper_version(path: Path) -> bool: + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError: + return False + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "HELPER_VERSION": + return True + return False + + +for skill_dir in sorted(path for path in Path("skills").iterdir() if path.is_dir()): + scripts_dir = skill_dir / "scripts" + helper_files = [] + if scripts_dir.exists(): + helper_files = sorted(path for path in scripts_dir.glob("*.py") if has_helper_version(path)) + if len(helper_files) != 1: + print(f"{skill_dir} 必须且只能有一个包含 HELPER_VERSION 的 helper 脚本", flush=True) + raise SystemExit(1) + print(f"{skill_dir.name}\t{helper_files[0]}") +PY + version="$(HELPER_FILE="$helper_file" python3 - <<'PY' +import ast +import os +from pathlib import Path + +tree = ast.parse(Path(os.environ["HELPER_FILE"]).read_text(encoding="utf-8")) +for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "HELPER_VERSION" and isinstance(node.value, ast.Constant): + print(str(node.value.value)) + raise SystemExit(0) +raise SystemExit("HELPER_VERSION not found") +PY +)" + zip_path="$DIST_DIR/${skill_name}-${version}.zip" + stage_dir="$(mktemp -d)" + cleanup() { + rm -rf "$stage_dir" + } + trap cleanup EXIT + mkdir -p "$stage_dir/$skill_name" + if command -v rsync >/dev/null 2>&1; then + rsync -a \ + --exclude '.DS_Store' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '*.pyo' \ + "skills/$skill_name/" "$stage_dir/$skill_name/" + else + cp -R "skills/$skill_name/." "$stage_dir/$skill_name/" + find "$stage_dir/$skill_name" -name '.DS_Store' -delete + find "$stage_dir/$skill_name" -name '__pycache__' -type d -prune -exec rm -rf {} + + find "$stage_dir/$skill_name" \( -name '*.pyc' -o -name '*.pyo' \) -delete + fi + ( + cd "$stage_dir" + zip -r "$zip_path" "$skill_name" \ + -x "*/__pycache__/*" \ + -x "*.pyc" \ + -x "*.pyo" \ + -x "*.DS_Store" >/dev/null + ) + rm -rf "$stage_dir" + trap - EXIT + echo "已生成 Skill 安装包:" + echo "$zip_path" +done + +python3 - <<'PY' +import ast +from hashlib import sha256 +import json +from pathlib import Path + +dist_dir = Path("dist/skills") + +def read_helper_version(helper_file: Path) -> str: + tree = ast.parse(helper_file.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "HELPER_VERSION" and isinstance(node.value, ast.Constant): + return str(node.value.value) + return "" + + +expected = [] +for skill_dir in sorted(path for path in Path("skills").iterdir() if path.is_dir()): + scripts_dir = skill_dir / "scripts" + helper_files = [] + if scripts_dir.exists(): + helper_files = sorted( + path + for path in scripts_dir.glob("*.py") + if read_helper_version(path) + ) + if len(helper_files) != 1: + print(f"{skill_dir} 必须且只能有一个包含 HELPER_VERSION 的 helper 脚本") + raise SystemExit(1) + skill_id = skill_dir.name + helper_file = helper_files[0] + version = read_helper_version(helper_file) + if not version: + print(f"{helper_file} 缺少 HELPER_VERSION") + raise SystemExit(1) + expected.append((skill_id, version, dist_dir / f"{skill_id}-{version}.zip")) + +if not expected: + print("dist/skills 目录没有生成 ZIP 文件") + raise SystemExit(1) +expected_names = {zip_file.name for _, _, zip_file in expected} +actual_names = {zip_file.name for zip_file in dist_dir.glob("*.zip")} +missing = sorted(expected_names - actual_names) +extra = sorted(actual_names - expected_names) +if missing or extra: + if missing: + print("dist/skills 缺少预期 ZIP:") + print("\n".join(missing)) + if extra: + print("dist/skills 包含未知 ZIP:") + print("\n".join(extra)) + raise SystemExit(1) + +lines = [] +skills = [] +for skill_id, version, zip_file in expected: + digest = sha256(zip_file.read_bytes()).hexdigest() + lines.append(f"{digest} {zip_file.name}") + skills.append( + { + "id": skill_id, + "version": version, + "zip": zip_file.name, + "sha256": digest, + "size": zip_file.stat().st_size, + } + ) +(dist_dir / "SHA256SUMS.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") +(dist_dir / "MANIFEST.json").write_text( + json.dumps({"skills": skills}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", +) +print(f"skill_sha256_manifest_ok files={len(expected)}") +PY + +bash scripts/verify-skill-dist.sh diff --git a/scripts/patch-p115strmhelper-mp-compat.sh b/scripts/patch-p115strmhelper-mp-compat.sh new file mode 100755 index 0000000..dd50419 --- /dev/null +++ b/scripts/patch-p115strmhelper-mp-compat.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +container="${MP_CONTAINER:-moviepilot-v2}" +plugin_files=( + "/app/app/plugins/p115strmhelper/__init__.py" + "/config/plugins/p115strmhelper/__init__.py" +) +tmp_dir="$(mktemp -d)" + +show_help() { + cat <<'EOF' +Usage: + MP_CONTAINER= bash scripts/patch-p115strmhelper-mp-compat.sh + +Applies the local P115StrmHelper compatibility patch inside the target +MoviePilot container, then runs py_compile against the patched plugin file. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +cleanup() { + rm -rf "${tmp_dir}" +} +trap cleanup EXIT + +echo "P115StrmHelper compatibility patch" +echo "container: ${container}" + +patch_file() { + local plugin_file="$1" + local safe_name + safe_name="$(echo "${plugin_file}" | tr '/:' '__')" + local local_file="${tmp_dir}/${safe_name}" + + if ! docker exec "${container}" test -f "${plugin_file}"; then + echo "skip missing: ${plugin_file}" + return 0 + fi + + docker cp "${container}:${plugin_file}" "${local_file}" + + if grep -q "_optional_event_register(_TRANSFER_OVERWRITE_CHECK_EVENT)" "${local_file}"; then + echo "already patched: ${plugin_file}" + else + python3 - "${local_file}" <<'PY' +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() + +text = text.replace(" TransferOverwriteCheckEventData,\n", "") + +markers = [ + "from app.schemas.types import ChainEventType\n", + "from app.schemas.types import EventType, MessageChannel, ChainEventType, MediaType\n", +] +compat = '''from app.schemas.types import EventType, MessageChannel, ChainEventType, MediaType + +_TRANSFER_OVERWRITE_CHECK_EVENT = getattr(ChainEventType, "TransferOverwriteCheck", None) +try: + from app.schemas import TransferOverwriteCheckEventData +except Exception: + class TransferOverwriteCheckEventData: + pass + + +def _optional_event_register(event_type): + if event_type is None: + def decorator(func): + return func + return decorator + return eventmanager.register(event_type) +''' + +marker = next((item for item in markers if item in text), None) +if marker is None: + raise SystemExit("cannot find ChainEventType import marker") +text = text.replace(marker, compat, 1) + +old = "@eventmanager.register(ChainEventType.TransferOverwriteCheck)" +new = "@_optional_event_register(_TRANSFER_OVERWRITE_CHECK_EVENT)" +if old not in text: + raise SystemExit("cannot find TransferOverwriteCheck decorator") +text = text.replace(old, new, 1) + +path.write_text(text) +PY + fi + + docker cp "${local_file}" "${container}:${plugin_file}" + docker exec "${container}" /opt/venv/bin/python -m py_compile "${plugin_file}" + echo "patched and syntax check passed: ${plugin_file}" +} + +for plugin_file in "${plugin_files[@]}"; do + patch_file "${plugin_file}" +done + +echo "restart MoviePilot, then verify AgentResourceOfficer /p115/health" diff --git a/scripts/pre-release-check.sh b/scripts/pre-release-check.sh new file mode 100755 index 0000000..de0fcd0 --- /dev/null +++ b/scripts/pre-release-check.sh @@ -0,0 +1,437 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/pre-release-check.sh + +Runs the low-level repository release checks: +- sync repo layout +- ensure clean worktree +- shell/Python syntax +- skill selftests +- metadata/doc drift checks +- package build and manifest verification + +Set RUN_AGENT_RESOURCE_OFFICER_LIVE_SMOKE=1 to include live smoke. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi +export PYTHONDONTWRITEBYTECODE=1 +mkdir -p .tmp +LOCK_DIR="$ROOT_DIR/.tmp/pre-release-check.lock" +if ! mkdir "$LOCK_DIR" 2>/dev/null; then + echo "pre-release-check 已在运行,请等待当前检查结束后重试。" >&2 + exit 1 +fi +trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT + +PACKAGE_PLUGINS=( + AIRecognizerEnhancer + AgentResourceOfficer + FeishuCommandBridgeLong + HdhiveOpenApi + QuarkShareSaver +) + +release_git_status() { + git status --short -- . ':(exclude)SESSION_HANDOFF_*.md' +} + +echo "[1/6] 同步官方仓库布局..." +bash scripts/sync-repo-layout.sh >/dev/null +bash scripts/sync-package-v2.sh >/dev/null + +echo "[2/6] 检查 Git 工作区是否干净..." +if [ -n "$(release_git_status)" ]; then + echo "Git 工作区不干净,请先提交或处理变更;如果只有同步结果,请提交同步后的文件。" >&2 + release_git_status + exit 1 +fi + +echo "[3/6] 检查插件语法..." +while IFS= read -r shell_file; do + bash -n "$shell_file" +done < <(find scripts skills -name '*.sh' -type f | sort) +echo "shell_syntax_ok" +python3 scripts/check-maintenance-commands.py >/dev/null +echo "script_help_ok" +grep -Fq 'WORKFLOW_NAME="${WORKFLOW_NAME:-Release Preflight}"' scripts/verify-release-preflight-artifact.sh +grep -Fq 'WORKFLOW_FILE="${WORKFLOW_FILE:-ci.yml}"' scripts/verify-release-preflight-artifact.sh +grep -Fq 'exec bash scripts/verify-release-preflight-artifact.sh "$@"' scripts/verify-ci-artifact.sh +grep -Fq 'bash scripts/release-preflight.sh' scripts/create-draft-release.sh +grep -Fq 'bash scripts/release-preflight.sh' scripts/update-draft-release-assets.sh +echo "release_script_entrypoints_ok" +python3 - <<'PY' +from pathlib import Path + +roots = [ + Path("AIRecognizerEnhancer"), + Path("AgentResourceOfficer"), + Path("FeishuCommandBridgeLong"), + Path("QuarkShareSaver"), + Path("plugins"), + Path("plugins.v2"), + Path("skills"), +] +failed = [] +count = 0 +for root in roots: + for path in root.rglob("*.py"): + if "__pycache__" in path.parts: + continue + count += 1 + try: + compile(path.read_text(encoding="utf-8"), str(path), "exec") + except SyntaxError as exc: + failed.append(f"{path}: {exc}") +if failed: + print("\n".join(failed)) + raise SystemExit(1) +print(f"syntax_ok files={count}") +PY +bash scripts/check-skills.sh +python3 scripts/check-agent-resource-officer-feishu.py +if [[ "${RUN_AGENT_RESOURCE_OFFICER_LIVE_SMOKE:-0}" == "1" ]]; then + echo "[3.1] 执行 AgentResourceOfficer 本机 live smoke..." + python3 scripts/smoke-agent-resource-officer.py --include-search +fi + +echo "[4/6] 检查 package.json 与运行代码元数据..." +PACKAGE_PLUGIN_LIST="${PACKAGE_PLUGINS[*]}" python3 - <<'PY' +import ast +import json +import os +import re +from pathlib import Path + +pkg = json.loads(Path("package.json").read_text(encoding="utf-8")) +pkg_v2 = json.loads(Path("package.v2.json").read_text(encoding="utf-8")) +package_plugins = set(pkg) +release_plugins = set(os.environ["PACKAGE_PLUGIN_LIST"].split()) +if package_plugins != release_plugins: + missing = sorted(package_plugins - release_plugins) + extra = sorted(release_plugins - package_plugins) + if missing: + print("pre-release-check 未覆盖 package.json 插件:", ", ".join(missing)) + if extra: + print("pre-release-check 包含 package.json 之外的插件:", ", ".join(extra)) + raise SystemExit(1) +normalized_pkg_v2 = { + plugin_id: {key: value for key, value in meta.items() if key != "v2"} + for plugin_id, meta in pkg.items() +} +if normalized_pkg_v2 != pkg_v2: + print("package.v2.json 与 package.json 去除 v2 字段后的内容不一致") + raise SystemExit(1) + +failed = [] +for plugin_id, meta in pkg.items(): + missing_fields = [ + key + for key in ("name", "description", "version", "author", "icon", "labels", "level", "history") + if not str(meta.get(key) or "").strip() + ] + if missing_fields: + failed.append((plugin_id, "package.json", {"missing_fields": ",".join(missing_fields)})) + continue + if not isinstance(meta.get("version"), str) or not re.fullmatch(r"\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?", meta.get("version", "")): + failed.append((plugin_id, "package.json", {"invalid_version": meta.get("version")})) + continue + if not isinstance(meta.get("labels"), str): + failed.append((plugin_id, "package.json", {"invalid_labels_type": type(meta.get("labels")).__name__})) + continue + if not isinstance(meta.get("level"), int) or meta.get("level") < 1: + failed.append((plugin_id, "package.json", {"invalid_level": meta.get("level")})) + continue + if not isinstance(meta.get("history"), dict) or not meta.get("history"): + failed.append((plugin_id, "package.json", {"invalid_history": type(meta.get("history")).__name__})) + continue + bad_history = [ + key for key, value in meta.get("history", {}).items() + if not isinstance(key, str) or not key.strip() or not isinstance(value, str) or not value.strip() + ] + if bad_history: + failed.append((plugin_id, "package.json", {"invalid_history_items": ",".join(map(str, bad_history))})) + continue + if meta.get("v2") is not True: + failed.append((plugin_id, "package.json", {"invalid_v2": meta.get("v2")})) + continue + history = meta.get("history") if isinstance(meta.get("history"), dict) else {} + if str(meta.get("version")) not in history: + failed.append((plugin_id, "package.json", {"missing_history_for_version": meta.get("version")})) + continue + icon_file = Path("icons") / str(meta.get("icon")) + if not icon_file.exists(): + failed.append((plugin_id, "package.json", {"missing_icon": str(icon_file)})) + continue + candidates = [ + Path(plugin_id) / "__init__.py", + Path("plugins") / plugin_id.lower() / "__init__.py", + Path("plugins.v2") / plugin_id.lower() / "__init__.py", + ] + found = [item for item in candidates if item.exists()] + if not found: + failed.append((plugin_id, "source", {"missing_init": "no __init__.py found in root/plugins/plugins.v2"})) + continue + for init_file in found: + tree = ast.parse(init_file.read_text(encoding="utf-8")) + values = {} + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if not isinstance(target, ast.Name) or not isinstance(node.value, ast.Constant): + continue + if target.id in {"plugin_version", "plugin_author", "plugin_icon"}: + values[target.id] = str(node.value.value) + icon = values.get("plugin_icon", "") + expected_icon = str(meta.get("icon") or "") + icon_ok = (not icon) or icon == expected_icon or icon.endswith("/" + expected_icon) + meta_ok = values.get("plugin_version") == meta.get("version") and values.get("plugin_author") == meta.get("author") + if not (icon_ok and meta_ok): + failed.append((plugin_id, str(init_file), values)) +if failed: + for item in failed: + print(item) + raise SystemExit(1) + +install_doc = Path("docs/PLUGIN_INSTALL.md").read_text(encoding="utf-8") +missing_zip_names = [ + f"{plugin_id}-{meta.get('version')}.zip" + for plugin_id, meta in pkg.items() + if f"{plugin_id}-{meta.get('version')}.zip" not in install_doc +] +if missing_zip_names: + print("docs/PLUGIN_INSTALL.md 缺少当前 ZIP 文件名:") + print("\n".join(missing_zip_names)) + raise SystemExit(1) + +root_readme = Path("README.md").read_text(encoding="utf-8") +missing_readme_items = [] +for plugin_id, meta in pkg.items(): + for item in (plugin_id, str(meta.get("name") or ""), str(meta.get("version") or "")): + if item and item not in root_readme: + missing_readme_items.append(f"{plugin_id}: README.md missing {item}") +if missing_readme_items: + print("README.md 插件清单与 package.json 不一致:") + print("\n".join(missing_readme_items)) + raise SystemExit(1) + +changelog = Path("CHANGELOG.md").read_text(encoding="utf-8") +missing_changelog_items = [] +for plugin_id, meta in pkg.items(): + current_version_line = f"- `{plugin_id}`: `{meta.get('version')}`" + if current_version_line not in changelog: + missing_changelog_items.append(f"{plugin_id}: CHANGELOG.md missing {current_version_line}") +if missing_changelog_items: + print("CHANGELOG.md 当前核心版本与 package.json 不一致:") + print("\n".join(missing_changelog_items)) + raise SystemExit(1) + +ci_workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") +required_ci_fragments = [ + "name: Release Preflight", + "actions/upload-artifact@v7", + "fetch-depth: 0", + "scripts/release-preflight.sh", + "moviepilot-release-assets-", + "dist/*.zip", + "dist/SHA256SUMS.txt", + "dist/MANIFEST.json", + "dist/skills/*.zip", + "dist/skills/SHA256SUMS.txt", + "dist/skills/MANIFEST.json", + "if-no-files-found: error", +] +draft_release_workflow = Path(".github/workflows/draft-release.yml").read_text(encoding="utf-8") +required_draft_release_fragments = [ + "workflow_dispatch:", + "contents: write", + "fetch-depth: 0", + "scripts/create-draft-release.sh", + "dry_run", +] +missing_workflow_fragments = [] +for fragment in required_ci_fragments: + if fragment not in ci_workflow: + missing_workflow_fragments.append(f"ci.yml: {fragment}") +for fragment in required_draft_release_fragments: + if fragment not in draft_release_workflow: + missing_workflow_fragments.append(f"draft-release.yml: {fragment}") +if missing_workflow_fragments: + print(".github/workflows 缺少发布流程配置:") + print("\n".join(missing_workflow_fragments)) + raise SystemExit(1) +PY +echo "检查当前状态文档..." +python3 scripts/check-doc-current-state.py + +echo "检查 Markdown 本地链接..." +python3 - <<'PY' +import re +import urllib.parse +from pathlib import Path + +root = Path(".").resolve() +failed = [] + +def resolve_with_mirror_fallback(md_file: Path, target: str) -> Path: + direct = (md_file.parent / target).resolve() + if direct.exists(): + return direct + if md_file.parts and md_file.parts[0] in {"plugins", "plugins.v2"}: + stripped = target + while stripped.startswith("../"): + stripped = stripped[3:] + if stripped: + fallback = (root / stripped).resolve() + return fallback + return direct + +for md_file in sorted(Path(".").rglob("*.md")): + if ".git" in md_file.parts or md_file.name.startswith("SESSION_HANDOFF_"): + continue + text = md_file.read_text(encoding="utf-8", errors="ignore") + for raw_link in re.findall(r"!?\[[^\]]*\]\(([^)]+)\)", text): + link = raw_link.strip() + if not link or link.startswith(("#", "http://", "https://", "mailto:")): + continue + target = link.split("#", 1)[0].strip() + if not target: + continue + target = urllib.parse.unquote(target) + target_path = resolve_with_mirror_fallback(md_file, target) + try: + target_path.relative_to(root) + except ValueError: + continue + if not target_path.exists(): + failed.append(f"{md_file}: missing link target {link}") +if failed: + print("\n".join(failed)) + raise SystemExit(1) +print("markdown_links_ok") +PY + +echo "检查隐私尾巴..." +python3 - <<'PY' +from pathlib import Path + +forbidden = [ + "/Users/" + "jans", + "Qq-" + "342236586", + "5c0200" + "b446ee9eb94d2912d4c8b7309c", + "Authorization: Bearer " + "eyJ", +] +failed = [] +for path in sorted(Path(".").rglob("*")): + if not path.is_file(): + continue + if ".git" in path.parts or "dist" in path.parts or "__pycache__" in path.parts: + continue + if path.name.startswith("SESSION_HANDOFF_") or path.suffix in {".pyc", ".pyo"}: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for needle in forbidden: + if needle in text: + failed.append(f"{path}: contains forbidden literal {needle!r}") +if failed: + print("\n".join(failed)) + raise SystemExit(1) +print("privacy_scan_ok") +PY + +echo "[5/6] 打包本地安装 ZIP..." +mkdir -p dist +rm -f dist/*.zip dist/SHA256SUMS.txt dist/MANIFEST.json +rm -rf dist/skills +listed_plugins="$(bash scripts/package-plugin.sh --list | awk '{print $1}' | tr '\n' ' ' | sed 's/ $//')" +expected_plugins="${PACKAGE_PLUGINS[*]}" +if [ "$listed_plugins" != "$expected_plugins" ]; then + echo "package-plugin.sh --list 输出与发布插件清单不一致" >&2 + echo "expected: $expected_plugins" >&2 + echo "actual: $listed_plugins" >&2 + exit 1 +fi +echo "package_plugin_list_ok" +bash scripts/package-plugin.sh --all +bash scripts/package-skills.sh +bash scripts/verify-release-assets.sh dist >/dev/null +bash scripts/print-release-summary.sh >/dev/null +bash scripts/print-skill-release-summary.sh >/dev/null +release_notes="$(bash scripts/generate-release-notes.sh v0.0.0-dry-run)" +if [[ "$release_notes" != *"external-agent / external-agent --full"* ]]; then + echo "generate-release-notes.sh 缺少 external-agent 重点说明" >&2 + exit 1 +fi +bash scripts/create-draft-release.sh v0.0.0-dry-run --dry-run --skip-check >/dev/null + +echo "[6/6] 检查关键文件..." +test -f package.v2.json +test -f package.json +test -f dist/SHA256SUMS.txt +test -f dist/MANIFEST.json +test -f dist/skills/SHA256SUMS.txt +test -f dist/skills/MANIFEST.json +test -f scripts/generate-release-notes.sh +test -f plugins/agentresourceofficer/__init__.py +test -f plugins/agentresourceofficer/agenttool.py +test -f plugins/agentresourceofficer/schemas.py +test -f plugins/agentresourceofficer/services/p115_transfer.py +test -f plugins/airecognizerenhancer/__init__.py +test -f plugins/quarksharesaver/__init__.py +for plugin_name in "${PACKAGE_PLUGINS[@]}"; do + version="$(PLUGIN_NAME="$plugin_name" python3 - <<'PY' +import json +import os + +plugin_name = os.environ["PLUGIN_NAME"] +with open("package.json", "r", encoding="utf-8") as file_obj: + package = json.load(file_obj) +print((package.get(plugin_name) or {}).get("version") or "unknown") +PY + )" + zip_path="dist/${plugin_name}-${version}.zip" + test -f "$zip_path" + PLUGIN_NAME="$plugin_name" ZIP_PATH="$zip_path" python3 - <<'PY' +import os +import zipfile + +plugin_name = os.environ["PLUGIN_NAME"] +zip_path = os.environ["ZIP_PATH"] +required_readme = f"{plugin_name}/README.md" +required_init = f"{plugin_name}/__init__.py" +bad_entries = [] +with zipfile.ZipFile(zip_path) as zip_file: + names = set(zip_file.namelist()) + for name in names: + if "__pycache__" in name or name.endswith((".pyc", ".pyo", ".DS_Store")): + bad_entries.append(name) +if required_readme not in names: + print(f"{zip_path} 缺少 {required_readme}") + raise SystemExit(1) +if required_init not in names: + print(f"{zip_path} 缺少 {required_init}") + raise SystemExit(1) +if bad_entries: + print(f"{zip_path} 包含不应发布的生成文件:") + print("\n".join(sorted(bad_entries))) + raise SystemExit(1) +PY +done + +echo +echo "插件仓库发布前检查通过。" +echo "ZIP 包目录:$ROOT_DIR/dist" diff --git a/scripts/print-release-summary.sh b/scripts/print-release-summary.sh new file mode 100755 index 0000000..2e75a64 --- /dev/null +++ b/scripts/print-release-summary.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/print-release-summary.sh + +Prints a Markdown table for plugin ZIP release assets from dist/MANIFEST.json. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +python3 - <<'PY' +import json +from pathlib import Path + +manifest_file = Path("dist/MANIFEST.json") +if not manifest_file.exists(): + print("dist/MANIFEST.json 不存在,请先运行 bash scripts/package-plugin.sh --all") + raise SystemExit(1) + +manifest = json.loads(manifest_file.read_text(encoding="utf-8")) +plugins = manifest.get("plugins") +if not isinstance(plugins, list) or not plugins: + print("dist/MANIFEST.json 缺少 plugins 列表") + raise SystemExit(1) + +print("## MoviePilot 插件 ZIP") +print() +print("| Plugin | Name | Version | ZIP | Size | SHA256 |") +print("| --- | --- | --- | --- | ---: | --- |") +for item in plugins: + size_kib = int(round(int(item.get("size") or 0) / 1024)) + print( + "| {id} | {name} | {version} | {zip} | {size_kib} KiB | `{sha256}` |".format( + id=item.get("id", ""), + name=item.get("name", ""), + version=item.get("version", ""), + zip=item.get("zip", ""), + size_kib=size_kib, + sha256=item.get("sha256", ""), + ) + ) +PY diff --git a/scripts/print-skill-release-summary.sh b/scripts/print-skill-release-summary.sh new file mode 100755 index 0000000..d01ad3d --- /dev/null +++ b/scripts/print-skill-release-summary.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/print-skill-release-summary.sh + +Prints a Markdown table for public Skill ZIP release assets from +dist/skills/MANIFEST.json. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +python3 - <<'PY' +import json +from pathlib import Path + +manifest_file = Path("dist/skills/MANIFEST.json") +if not manifest_file.exists(): + print("dist/skills/MANIFEST.json 不存在") + raise SystemExit(1) +manifest = json.loads(manifest_file.read_text(encoding="utf-8")) +skills = manifest.get("skills") or [] +print("| Skill | Version | ZIP | SHA256 |") +print("| --- | --- | --- | --- |") +for item in skills: + print( + "| {id} | {version} | `{zip}` | `{sha}` |".format( + id=item.get("id", ""), + version=item.get("version", ""), + zip=item.get("zip", ""), + sha=str(item.get("sha256", ""))[:12], + ) + ) +PY diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh new file mode 100644 index 0000000..9316032 --- /dev/null +++ b/scripts/release-preflight.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/release-preflight.sh + +Runs the full release preflight in two stages: +1. repo-hygiene.sh +2. pre-release-check.sh +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +echo "[1/2] repo hygiene" +bash scripts/repo-hygiene.sh + +echo "[2/2] pre-release check" +bash scripts/pre-release-check.sh diff --git a/scripts/repo-hygiene.sh b/scripts/repo-hygiene.sh new file mode 100644 index 0000000..6548a71 --- /dev/null +++ b/scripts/repo-hygiene.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/repo-hygiene.sh + +Runs the lightweight repository maintenance checks: +1. git fetch --prune origin +2. remote/local branch audit +3. local branch archive dry-run +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +echo "[1/3] fetch --prune origin" +git fetch --prune origin >/dev/null + +echo "[2/3] remote/local branch audit" +python3 scripts/audit-remote-branches.py + +echo "[3/3] local archive dry-run" +python3 scripts/archive-local-branches.py diff --git a/scripts/smoke-agent-resource-officer.py b/scripts/smoke-agent-resource-officer.py new file mode 100644 index 0000000..d389e43 --- /dev/null +++ b/scripts/smoke-agent-resource-officer.py @@ -0,0 +1,1776 @@ +#!/usr/bin/env python3 +"""Live smoke checks for AgentResourceOfficer. + +This script intentionally does not print API keys or cookies. It reads the +same local config used by the public agent-resource-officer Skill: +~/.config/agent-resource-officer/config. +""" + +import argparse +import json +import os +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +CONFIG_PATH = Path("~/.config/agent-resource-officer/config").expanduser() + + +def read_config() -> dict: + if not CONFIG_PATH.exists(): + return {} + config = {} + for line in CONFIG_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + return config + + +def pick_config(config: dict, *names: str) -> str: + for name in names: + value = os.environ.get(name) or config.get(name) + if value: + return value.strip() + return "" + + +def request(base_url: str, api_key: str, method: str, path: str, body: dict | None = None, query: dict | None = None) -> dict: + query_items = list((query or {}).items()) + query_items.append(("apikey", api_key)) + url = base_url.rstrip("/") + "/" + path.lstrip("/") + url = url + "?" + urllib.parse.urlencode(query_items) + payload = None + headers = {} + if body is not None: + payload = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=payload, method=method.upper(), headers=headers) + last_error = None + for attempt in range(6): + try: + with urllib.request.urlopen(req, timeout=120) as resp: + raw = resp.read().decode("utf-8") + break + except urllib.error.HTTPError as exc: + last_error = exc + if exc.code not in {502, 503, 504} or attempt >= 5: + raise + time.sleep(2) + except urllib.error.URLError as exc: + last_error = exc + if attempt >= 5: + raise + time.sleep(2) + else: + raise last_error or RuntimeError("request failed without response") + try: + return json.loads(raw) + except json.JSONDecodeError: + return {"success": False, "raw": raw} + + +def data(result: dict) -> dict: + payload = result.get("data") + return payload if isinstance(payload, dict) else result + + +def assert_ok(name: str, condition: bool, detail: str = "") -> None: + if not condition: + suffix = f": {detail}" if detail else "" + raise RuntimeError(f"{name}_failed{suffix}") + print(f"{name}_ok") + + +def message_text(result: dict) -> str: + return str(result.get("message") or "") + + +def assert_route_action(name: str, result: dict, expected_action: str, *, require_success: bool = True) -> dict: + result_data = data(result) + condition = result_data.get("action") == expected_action + if require_success: + condition = condition and bool(result.get("success") and result_data.get("ok")) + assert_ok( + name, + condition, + json.dumps({ + "success": result.get("success"), + "ok": result_data.get("ok"), + "action": result_data.get("action"), + "message": message_text(result)[:160], + }, ensure_ascii=False), + ) + return result_data + + +def template_names(result_data: dict) -> list[str]: + items = result_data.get("action_templates") or [] + return [str(item.get("name") or "").strip() for item in items if isinstance(item, dict) and str(item.get("name") or "").strip()] + + +def route(base_url: str, api_key: str, session: str, text: str) -> dict: + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/route", + body={"session": session, "text": text, "compact": True}, + ) + + +def workflow(base_url: str, api_key: str, session: str, workflow_name: str, **kwargs) -> dict: + body = {"session": session, "workflow": workflow_name, "compact": True} + body.update(kwargs) + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", + body=body, + ) + + +def action(base_url: str, api_key: str, session: str, name: str, **kwargs) -> dict: + body = {"session": session, "name": name, "compact": True} + body.update(kwargs) + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/action", + body=body, + ) + + +def recover(base_url: str, api_key: str, session: str) -> dict: + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/recover", + body={"session": session, "compact": True}, + ) + + +def plan_execute(base_url: str, api_key: str, session: str, plan_id: str) -> dict: + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", + body={"session": session, "plan_id": plan_id, "compact": True}, + ) + + +def session_state(base_url: str, api_key: str, session: str) -> dict: + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/session", + body={"session": session, "compact": True}, + ) + + +def request_templates(base_url: str, api_key: str, recipe: str) -> dict: + return request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", + body={"recipe": recipe, "include_templates": False}, + ) + + +def clear_session(base_url: str, api_key: str, session: str) -> None: + try: + request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/session/clear", + body={"session": session, "compact": True}, + ) + except Exception: + pass + + +def clear_plans(base_url: str, api_key: str, session: str) -> None: + try: + request( + base_url, + api_key, + "POST", + "/api/v1/plugin/AgentResourceOfficer/assistant/plans/clear", + body={"session": session, "limit": 100}, + ) + except Exception: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description="Smoke test AgentResourceOfficer live assistant endpoints") + parser.add_argument("--base-url") + parser.add_argument("--api-key") + parser.add_argument("--include-search", action="store_true", help="Also test MP native search, PanSou, and HDHive alias routes") + parser.add_argument("--keyword", default="蜘蛛侠") + parser.add_argument("--pansou-keyword", default="大君夫人") + args = parser.parse_args() + + config = read_config() + base_url = args.base_url or pick_config(config, "ARO_BASE_URL", "MP_BASE_URL", "MOVIEPILOT_URL") + api_key = args.api_key or pick_config(config, "ARO_API_KEY", "MP_API_TOKEN") + if not base_url or not api_key: + raise SystemExit("missing ARO_BASE_URL/ARO_API_KEY; configure ~/.config/agent-resource-officer/config or env") + + stamp = int(time.time()) + sessions = [ + f"smoke-aro-status-{stamp}", + ] + if args.include_search: + sessions.extend([ + f"smoke-aro-mp-search-{stamp}", + f"smoke-aro-pansou-{stamp}", + f"smoke-aro-hdhive-{stamp}", + f"smoke-aro-mp-readonly-{stamp}", + f"smoke-aro-recommend-movie-{stamp}", + f"smoke-aro-recommend-pansou-{stamp}", + f"smoke-aro-recommend-tv-{stamp}", + f"smoke-aro-smart-discovery-{stamp}", + f"smoke-aro-smart-discovery-plan-{stamp}", + f"smoke-aro-smart-discovery-execute-{stamp}", + f"smoke-aro-smart-discovery-short-decision-{stamp}", + f"smoke-aro-smart-discovery-short-plan-{stamp}", + f"smoke-aro-smart-discovery-short-execute-{stamp}", + f"smoke-aro-smart-discovery-followups-{stamp}", + f"smoke-aro-smart-discovery-detail-flow-{stamp}", + f"smoke-aro-smart-discovery-autoplan-{stamp}", + f"smoke-aro-smart-discovery-direct-detail-{stamp}", + f"smoke-aro-smart-discovery-direct-plan-{stamp}", + f"smoke-aro-smart-discovery-direct-execute-{stamp}", + f"smoke-aro-smart-discovery-direct-pansou-{stamp}", + f"smoke-aro-smart-discovery-direct-hdhive-{stamp}", + f"smoke-aro-smart-discovery-direct-mp-{stamp}", + f"smoke-aro-smart-discovery-return-pansou-{stamp}", + f"smoke-aro-smart-discovery-return-mp-{stamp}", + f"smoke-aro-smart-discovery-switch-pansou-{stamp}", + f"smoke-aro-smart-discovery-switch-mp-{stamp}", + f"smoke-aro-smart-discovery-handoff-pansou-flow-{stamp}", + f"smoke-aro-smart-discovery-handoff-mp-flow-{stamp}", + f"smoke-aro-smart-discovery-source-compound-recommend-{stamp}", + f"smoke-aro-smart-discovery-source-compound-handoff-{stamp}", + ]) + + try: + selfcheck = request(base_url, api_key, "GET", "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck") + selfcheck_data = data(selfcheck) + assert_ok("selfcheck", bool(selfcheck.get("success") and selfcheck_data.get("ok")), str(selfcheck.get("message") or "")) + assert_ok( + "selfcheck_executed_plan_recovery", + bool(((selfcheck_data.get("checks") or {}).get("executed_plan_recovery"))), + json.dumps((selfcheck_data.get("checks") or {}), ensure_ascii=False)[:240], + ) + print(f"plugin_version={selfcheck_data.get('version') or ''}") + execute_plan_followups = ((selfcheck_data.get("template_samples") or {}).get("execute_plan_followups") or {}) + assert_ok( + "selfcheck_execute_plan_followups", + ( + (execute_plan_followups.get("mp_best_download") or {}).get("template_names") == [ + "query_execution_followup", + "query_mp_ingest_status", + "query_mp_download_history", + "query_mp_lifecycle_status", + "query_mp_local_diagnose", + ] + and (execute_plan_followups.get("mp_best_download") or {}).get("recommended_action") == "query_execution_followup" + and bool((execute_plan_followups.get("mp_best_download") or {}).get("follow_up_hint")) + and (execute_plan_followups.get("mp_subscribe") or {}).get("template_names") == [ + "query_execution_followup", + "query_mp_subscribes", + "query_mp_ingest_status", + "start_mp_media_search", + ] + and (execute_plan_followups.get("mp_subscribe") or {}).get("recommended_action") == "query_execution_followup" + and bool((execute_plan_followups.get("mp_subscribe") or {}).get("follow_up_hint")) + and (execute_plan_followups.get("hdhive_unlock_selected") or {}).get("template_names") == [ + "query_execution_followup", + "query_mp_transfer_history", + "query_mp_local_diagnose", + ] + and (execute_plan_followups.get("hdhive_unlock_selected") or {}).get("recommended_action") == "query_execution_followup" + and bool((execute_plan_followups.get("hdhive_unlock_selected") or {}).get("follow_up_hint")) + ), + json.dumps(execute_plan_followups, ensure_ascii=False), + ) + + feishu = request(base_url, api_key, "GET", "/api/v1/plugin/AgentResourceOfficer/feishu/health") + feishu_data = data(feishu) + assert_ok("feishu_health", bool(feishu.get("success") and "sdk_available" in feishu_data), str(feishu.get("message") or "")) + + external_agent_templates = request_templates(base_url, api_key, "external_agent") + external_agent_templates_data = data(external_agent_templates) + selected_names = external_agent_templates_data.get("selected_names") or [] + assert_ok( + "external_agent_request_templates", + bool( + external_agent_templates.get("success") + and external_agent_templates_data.get("ok") + and external_agent_templates_data.get("selected_recipe") == "external_agent_quickstart" + and selected_names == ["startup_probe", "route_text", "pick_continue"] + ), + str(external_agent_templates.get("message") or ""), + ) + preferences_templates = request_templates(base_url, api_key, "preferences") + preferences_templates_data = data(preferences_templates) + preferences_names = preferences_templates_data.get("selected_names") or [] + assert_ok( + "preferences_request_templates", + bool( + preferences_templates.get("success") + and preferences_templates_data.get("ok") + and preferences_templates_data.get("selected_recipe") == "preferences_onboarding" + and preferences_names == ["preferences_get", "scoring_policy", "preferences_save"] + ), + str(preferences_templates.get("message") or ""), + ) + + mp_pt_templates = request_templates(base_url, api_key, "mp_pt") + mp_pt_templates_data = data(mp_pt_templates) + mp_pt_names = mp_pt_templates_data.get("selected_names") or [] + assert_ok( + "mp_pt_request_templates", + bool( + mp_pt_templates.get("success") + and mp_pt_templates_data.get("ok") + and mp_pt_templates_data.get("selected_recipe") == "mp_pt_mainline" + and "mp_search" in mp_pt_names + and "mp_search_download_plan" in mp_pt_names + and "saved_plan_execute" in mp_pt_names + ), + str(mp_pt_templates.get("message") or ""), + ) + + mp_recommend_templates = request_templates(base_url, api_key, "recommend") + mp_recommend_templates_data = data(mp_recommend_templates) + mp_recommend_names = mp_recommend_templates_data.get("selected_names") or [] + assert_ok( + "mp_recommend_request_templates", + bool( + mp_recommend_templates.get("success") + and mp_recommend_templates_data.get("ok") + and mp_recommend_templates_data.get("selected_recipe") == "mp_recommendation" + and "mp_recommend" in mp_recommend_names + and "mp_recommend_search" in mp_recommend_names + and "mp_search_download_plan" in mp_recommend_names + ), + str(mp_recommend_templates.get("message") or ""), + ) + followup_templates = request_templates(base_url, api_key, "followup") + followup_templates_data = data(followup_templates) + followup_names = followup_templates_data.get("selected_names") or [] + assert_ok( + "followup_request_templates", + bool( + followup_templates.get("success") + and followup_templates_data.get("ok") + and followup_templates_data.get("selected_recipe") == "post_execute_followup" + and "execution_followup" in followup_names + and "mp_download_history" in followup_names + and "mp_lifecycle_status" in followup_names + and "mp_transfer_history" in followup_names + ), + str(followup_templates.get("message") or ""), + ) + local_ingest_templates = request_templates(base_url, api_key, "local_ingest") + local_ingest_templates_data = data(local_ingest_templates) + local_ingest_names = local_ingest_templates_data.get("selected_names") or [] + assert_ok( + "local_ingest_request_templates", + bool( + local_ingest_templates.get("success") + and local_ingest_templates_data.get("ok") + and local_ingest_templates_data.get("selected_recipe") == "local_ingest" + and "mp_ingest_status" in local_ingest_names + and "mp_local_diagnose" in local_ingest_names + and "mp_recent_activity" in local_ingest_names + ), + str(local_ingest_templates.get("message") or ""), + ) + smart_search_templates = request_templates(base_url, api_key, "smart_search") + smart_search_templates_data = data(smart_search_templates) + smart_search_names = smart_search_templates_data.get("selected_names") or [] + assert_ok( + "smart_search_request_templates", + bool( + smart_search_templates.get("success") + and smart_search_templates_data.get("ok") + and smart_search_templates_data.get("selected_recipe") == "smart_search" + and smart_search_names == ["smart_search", "preferences_get", "scoring_policy"] + ), + str(smart_search_templates.get("message") or ""), + ) + smart_decision_templates = request_templates(base_url, api_key, "smart_decision") + smart_decision_templates_data = data(smart_decision_templates) + smart_decision_names = smart_decision_templates_data.get("selected_names") or [] + assert_ok( + "smart_decision_request_templates", + bool( + smart_decision_templates.get("success") + and smart_decision_templates_data.get("ok") + and smart_decision_templates_data.get("selected_recipe") == "smart_decision" + and smart_decision_names == ["smart_decision", "preferences_get", "scoring_policy"] + ), + str(smart_decision_templates.get("message") or ""), + ) + smart_search_plan_templates = request_templates(base_url, api_key, "smart_search_plan") + smart_search_plan_templates_data = data(smart_search_plan_templates) + smart_search_plan_names = smart_search_plan_templates_data.get("selected_names") or [] + assert_ok( + "smart_search_plan_request_templates", + bool( + smart_search_plan_templates.get("success") + and smart_search_plan_templates_data.get("ok") + and smart_search_plan_templates_data.get("selected_recipe") == "smart_search_plan" + and smart_search_plan_names == ["smart_search_plan", "preferences_get", "scoring_policy", "saved_plan_execute"] + ), + str(smart_search_plan_templates.get("message") or ""), + ) + smart_search_execute_templates = request_templates(base_url, api_key, "smart_search_execute") + smart_search_execute_templates_data = data(smart_search_execute_templates) + smart_search_execute_names = smart_search_execute_templates_data.get("selected_names") or [] + assert_ok( + "smart_search_execute_request_templates", + bool( + smart_search_execute_templates.get("success") + and smart_search_execute_templates_data.get("ok") + and smart_search_execute_templates_data.get("selected_recipe") == "smart_search_execute" + and smart_search_execute_names == ["smart_search_execute", "preferences_get", "scoring_policy", "post_execute_followup"] + ), + str(smart_search_execute_templates.get("message") or ""), + ) + preferences_view = route(base_url, api_key, sessions[0], "偏好") + preferences_view_data = assert_route_action("route_preferences_get", preferences_view, "preferences") + assert_ok( + "route_preferences_get_payload", + isinstance(preferences_view_data.get("preference_status"), dict) + and "needs_onboarding" in (preferences_view_data.get("preference_status") or {}), + json.dumps(preferences_view_data.get("preference_status") or {}, ensure_ascii=False)[:240], + ) + + scoring_policy = route(base_url, api_key, sessions[0], "评分策略") + scoring_policy_data = assert_route_action("route_scoring_policy", scoring_policy, "scoring_policy") + assert_ok( + "route_scoring_policy_payload", + isinstance(scoring_policy_data.get("scoring_policy"), dict) + and (scoring_policy_data.get("scoring_policy") or {}).get("schema_version") == "scoring_policy.v1" + and isinstance(((scoring_policy_data.get("scoring_policy") or {}).get("global_decision") or {}).get("default_confirm_score_threshold"), int) + and isinstance(((scoring_policy_data.get("scoring_policy") or {}).get("global_decision") or {}).get("default_auto_ingest_score_threshold"), int), + json.dumps(scoring_policy_data.get("scoring_policy") or {}, ensure_ascii=False)[:240], + ) + + preferences_save = route( + base_url, + api_key, + sessions[0], + "保存偏好 4K 杜比 HDR 中字 全集 做种>=5 影巢积分15 不自动入库", + ) + preferences_save_data = assert_route_action("route_preferences_save", preferences_save, "preferences_save") + saved_preferences = ((preferences_save_data.get("preference_status") or {}).get("summary") or {}) + assert_ok( + "route_preferences_save_values", + ( + saved_preferences.get("prefer_resolution") == "4K" + and saved_preferences.get("pt_min_seeders") == 5 + and saved_preferences.get("hdhive_max_unlock_points") == 15 + and saved_preferences.get("auto_ingest_enabled") is False + ), + json.dumps(saved_preferences, ensure_ascii=False)[:240], + ) + + preferences_after_save = route(base_url, api_key, sessions[0], "偏好") + preferences_after_save_data = assert_route_action("route_preferences_after_save", preferences_after_save, "preferences") + assert_ok( + "route_preferences_after_save_initialized", + ((preferences_after_save_data.get("preference_status") or {}).get("initialized") is True), + json.dumps(preferences_after_save_data.get("preference_status") or {}, ensure_ascii=False)[:240], + ) + + preferences_reset = route(base_url, api_key, sessions[0], "重置偏好") + preferences_reset_data = assert_route_action("route_preferences_reset", preferences_reset, "preferences_reset") + assert_ok( + "route_preferences_reset_needs_onboarding", + ((preferences_reset_data.get("preference_status") or {}).get("needs_onboarding") is True), + json.dumps(preferences_reset_data.get("preference_status") or {}, ensure_ascii=False)[:240], + ) + + status = route(base_url, api_key, sessions[0], "115状态") + assert_route_action("route_115_status", status, "p115_status") + if args.include_search: + download_tasks = route(base_url, api_key, sessions[0], "下载任务") + download_tasks_data = assert_route_action("route_download_tasks", download_tasks, "mp_download_tasks") + execution_followup = action(base_url, api_key, sessions[0], "query_execution_followup") + execution_followup_data = data(execution_followup) + assert_ok( + "action_execution_followup_without_plan", + ( + execution_followup.get("success") is False + and execution_followup_data.get("action") == "execution_followup" + and execution_followup_data.get("error_code") in {"executed_plan_not_found", "latest_plan_not_executed"} + ), + json.dumps(execution_followup, ensure_ascii=False)[:240], + ) + execution_followup_error_summary = execution_followup_data.get("error_summary") or {} + execution_followup_error_code = execution_followup_data.get("error_code") + execution_followup_compact_commands = execution_followup_error_summary.get("compact_commands") or [] + assert_ok( + "action_execution_followup_without_plan_error_summary", + ( + isinstance(execution_followup_error_summary, dict) + and bool(execution_followup_error_summary.get("decision_hint")) + and ( + ( + execution_followup_error_code == "latest_plan_not_executed" + and "执行计划" in execution_followup_compact_commands + ) or ( + execution_followup_error_code == "executed_plan_not_found" + and "最近" in execution_followup_compact_commands + ) + ) + ), + json.dumps(execution_followup_error_summary, ensure_ascii=False)[:240], + ) + assert_ok( + "action_execution_followup_without_plan_preferred_command", + ( + execution_followup_data.get("command_source") == "error_summary" + and execution_followup_data.get("preferred_command") == execution_followup_error_summary.get("preferred_command") + and isinstance(execution_followup_data.get("compact_commands"), list) + and execution_followup_data.get("command_policy") == "safe_read_recovery" + and execution_followup_data.get("preferred_requires_confirmation") is False + ), + json.dumps(execution_followup_data, ensure_ascii=False)[:240], + ) + download_task_actions = list(download_tasks_data.get("next_actions") or []) + has_download_controls = any( + action_name in download_task_actions + for action_name in [ + "mp_download_control.pause", + "mp_download_control.resume", + "mp_download_control.delete", + ] + ) + assert_ok( + "route_download_tasks_next_actions", + ( + has_download_controls + or ( + "mp_download_control.pause" not in download_task_actions + and "mp_download_control.resume" not in download_task_actions + and "mp_download_control.delete" not in download_task_actions + ) + ), + json.dumps(download_task_actions, ensure_ascii=False), + ) + download_task_templates = template_names(download_tasks_data) + assert_ok( + "route_download_tasks_templates", + ( + ( + "pause_mp_download" in download_task_templates + and "resume_mp_download" in download_task_templates + and "delete_mp_download" in download_task_templates + ) if has_download_controls else ( + "pause_mp_download" not in download_task_templates + and "resume_mp_download" not in download_task_templates + and "delete_mp_download" not in download_task_templates + and "query_mp_download_history" in download_task_templates + ) + ), + json.dumps(download_task_templates, ensure_ascii=False), + ) + + sites = route(base_url, api_key, sessions[0], "站点状态") + sites_data = assert_route_action("route_sites", sites, "mp_sites") + assert_ok( + "route_sites_next_actions", + "mp_downloaders" in list(sites_data.get("next_actions") or []), + json.dumps(sites_data.get("next_actions") or [], ensure_ascii=False), + ) + site_templates = template_names(sites_data) + assert_ok( + "route_sites_templates", + "query_mp_downloaders" in site_templates and "start_mp_media_search" in site_templates, + json.dumps(site_templates, ensure_ascii=False), + ) + site_session = session_state(base_url, api_key, sessions[0]) + site_session_data = data(site_session) + site_session_templates = template_names(site_session_data) + assert_ok( + "route_sites_session_templates", + "query_mp_downloaders" in site_session_templates and "start_mp_media_search" in site_session_templates, + json.dumps(site_session_templates, ensure_ascii=False), + ) + site_recover = recover(base_url, api_key, sessions[0]) + site_recover_data = data(site_recover) + site_recover_templates = template_names(site_recover_data) + assert_ok( + "route_sites_recover_templates", + "preferences_save" in site_recover_templates and "query_mp_downloaders" in site_recover_templates, + json.dumps(site_recover_templates, ensure_ascii=False), + ) + assert_ok( + "route_sites_recover_priority", + (site_recover_data.get("recovery") or {}).get("recommended_action") == "query_mp_downloaders" + and (site_recover_data.get("recovery") or {}).get("mode") == "continue_mp_sites", + json.dumps(site_recover_data.get("recovery") or {}, ensure_ascii=False), + ) + + downloaders = route(base_url, api_key, sessions[0], "下载器状态") + downloaders_data = assert_route_action("route_downloaders", downloaders, "mp_downloaders") + assert_ok( + "route_downloaders_next_actions", + "mp_sites" in list(downloaders_data.get("next_actions") or []), + json.dumps(downloaders_data.get("next_actions") or [], ensure_ascii=False), + ) + downloader_templates = template_names(downloaders_data) + assert_ok( + "route_downloaders_templates", + "query_mp_sites" in downloader_templates and "start_mp_media_search" in downloader_templates, + json.dumps(downloader_templates, ensure_ascii=False), + ) + + smart_search = route(base_url, api_key, sessions[1], f"智能搜索 {args.keyword}") + smart_search_data = assert_route_action("route_smart_search", smart_search, "smart_resource_search") + checked_sources = [ + str(item.get("source_type") or "").strip() + for item in (smart_search_data.get("sources_checked") or []) + if isinstance(item, dict) + ] + assert_ok( + "route_smart_search_checked_sources", + bool(checked_sources) and checked_sources[0] == "pansou", + json.dumps(smart_search_data.get("sources_checked") or [], ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_search_best_candidate", + isinstance(smart_search_data.get("best_candidate"), dict) + and bool((smart_search_data.get("best_candidate") or {}).get("source_type")) + and bool((smart_search_data.get("decision_summary") or {}).get("preferred_command")), + json.dumps({ + "best_candidate": smart_search_data.get("best_candidate"), + "decision_summary": smart_search_data.get("decision_summary"), + }, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_search_preference_status", + ( + isinstance((smart_search_data.get("preference_status") or {}).get("summary"), dict) + and "enable_pansou" in ((smart_search_data.get("preference_status") or {}).get("summary") or {}) + and "has_quark" in ((smart_search_data.get("preference_status") or {}).get("summary") or {}) + ), + json.dumps(smart_search_data.get("preference_status") or {}, ensure_ascii=False)[:240], + ) + smart_decision = route(base_url, api_key, sessions[1], f"资源决策 {args.keyword}") + smart_decision_data = assert_route_action("route_smart_decision", smart_decision, "smart_resource_decision") + assert_ok( + "route_smart_decision_payload", + bool(smart_decision_data.get("decision_mode")) + and isinstance(smart_decision_data.get("available_sources"), list) + and isinstance(smart_decision_data.get("blocked_sources"), list) + and bool((smart_decision_data.get("decision_summary") or {}).get("preferred_command")), + json.dumps({ + "decision_mode": smart_decision_data.get("decision_mode"), + "decision_summary": smart_decision_data.get("decision_summary"), + "available_sources": smart_decision_data.get("available_sources"), + "blocked_sources": smart_decision_data.get("blocked_sources"), + }, ensure_ascii=False)[:320], + ) + smart_decision_preferred = str((smart_decision_data.get("decision_summary") or {}).get("preferred_command") or "") + assert_ok( + "route_smart_decision_command_policy", + ( + smart_decision_data.get("command_policy") in {"wait_user_confirmation", "read_then_confirm_write"} + and smart_decision_data.get("preferred_requires_confirmation") is True + and smart_decision_data.get("can_auto_run_preferred") is False + ) + if smart_decision_preferred in {"计划最佳", "执行最佳"} + else ( + smart_decision_data.get("command_policy") == "safe_read_only" + and smart_decision_data.get("preferred_requires_confirmation") is False + ), + json.dumps({ + "preferred_command": smart_decision_preferred, + "command_policy": smart_decision_data.get("command_policy"), + "preferred_requires_confirmation": smart_decision_data.get("preferred_requires_confirmation"), + "can_auto_run_preferred": smart_decision_data.get("can_auto_run_preferred"), + "recommended_agent_behavior": smart_decision_data.get("recommended_agent_behavior"), + "auto_run_command": smart_decision_data.get("auto_run_command"), + "confirm_command": smart_decision_data.get("confirm_command"), + }, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_decision_explicit_execution_policy", + ( + smart_decision_data.get("recommended_agent_behavior") == "auto_continue_then_wait_confirmation" + and smart_decision_data.get("auto_run_command") == "先看详情" + and smart_decision_data.get("confirm_command") in {"计划最佳", "执行最佳"} + ) + if smart_decision_preferred in {"计划最佳", "执行最佳"} + else ( + smart_decision_data.get("recommended_agent_behavior") in {"auto_continue", "show_only"} + ), + json.dumps({ + "preferred_command": smart_decision_preferred, + "recommended_agent_behavior": smart_decision_data.get("recommended_agent_behavior"), + "auto_run_command": smart_decision_data.get("auto_run_command"), + "confirm_command": smart_decision_data.get("confirm_command"), + }, ensure_ascii=False)[:240], + ) + smart_decision_switch = route(base_url, api_key, sessions[1], "换影巢") + smart_decision_switch_data = assert_route_action("route_smart_decision_switch_hdhive", smart_decision_switch, "smart_resource_decision") + assert_ok( + "route_smart_decision_switch_hdhive", + isinstance(smart_decision_switch_data.get("sources_checked"), list) + and bool(smart_decision_switch_data.get("decision_mode")), + json.dumps({ + "sources_checked": smart_decision_switch_data.get("sources_checked"), + "decision_mode": smart_decision_switch_data.get("decision_mode"), + }, ensure_ascii=False)[:240], + ) + smart_pref_session = f"{sessions[1]}-prefs" + assert_route_action( + "route_smart_decision_pref_session_start", + route(base_url, api_key, smart_pref_session, f"资源决策 {args.keyword}"), + "smart_resource_decision", + ) + smart_decision_only_quark = route(base_url, api_key, smart_pref_session, "只用夸克") + smart_decision_only_quark_data = assert_route_action("route_smart_decision_only_quark", smart_decision_only_quark, "smart_resource_decision") + assert_ok( + "route_smart_decision_only_quark_effective", + ( + isinstance(smart_decision_only_quark_data.get("session_preference_overrides"), dict) + and (smart_decision_only_quark_data.get("session_preference_overrides") or {}).get("has_quark") is True + and (smart_decision_only_quark_data.get("session_preference_overrides") or {}).get("has_115") is False + and any( + (item or {}).get("source_type") == "115" + for item in (smart_decision_only_quark_data.get("blocked_sources") or []) + ) + ), + json.dumps(smart_decision_only_quark_data, ensure_ascii=False)[:320], + ) + smart_decision_only_pt = route(base_url, api_key, smart_pref_session, "只走PT") + smart_decision_only_pt_data = assert_route_action("route_smart_decision_only_pt", smart_decision_only_pt, "smart_resource_decision") + assert_ok( + "route_smart_decision_only_pt_effective", + ( + [(item or {}).get("source_type") for item in (smart_decision_only_pt_data.get("available_sources") or [])] == ["mp_pt"] + and any((item or {}).get("source_type") == "pansou" for item in (smart_decision_only_pt_data.get("blocked_sources") or [])) + and any((item or {}).get("source_type") == "hdhive" for item in (smart_decision_only_pt_data.get("blocked_sources") or [])) + ), + json.dumps({ + "available_sources": smart_decision_only_pt_data.get("available_sources"), + "blocked_sources": smart_decision_only_pt_data.get("blocked_sources"), + "session_preference_overrides": smart_decision_only_pt_data.get("session_preference_overrides"), + }, ensure_ascii=False)[:320], + ) + smart_decision_reset = route(base_url, api_key, smart_pref_session, "按保存偏好") + smart_decision_reset_data = assert_route_action("route_smart_decision_reset_preferences", smart_decision_reset, "smart_resource_decision") + assert_ok( + "route_smart_decision_reset_preferences_effective", + isinstance(smart_decision_reset_data.get("session_preference_overrides"), dict) + and not bool(smart_decision_reset_data.get("session_preference_overrides")), + json.dumps(smart_decision_reset_data.get("session_preference_overrides") or {}, ensure_ascii=False)[:240], + ) + smart_decision_plan = route(base_url, api_key, sessions[1], f"资源决策 {args.keyword} 计划") + smart_decision_plan_data = assert_route_action("route_smart_decision_plan_intent", smart_decision_plan, "workflow_plan") + assert_ok( + "route_smart_decision_plan_intent", + bool(smart_decision_plan_data.get("plan_id")) + and smart_decision_plan_data.get("workflow") == "smart_resource_plan", + json.dumps(smart_decision_plan_data, ensure_ascii=False)[:240], + ) + smart_decision_confirm_plan = route(base_url, api_key, sessions[1], "先计划") + smart_decision_confirm_plan_data = assert_route_action("route_smart_decision_confirm_plan", smart_decision_confirm_plan, "workflow_plan") + assert_ok( + "route_smart_decision_confirm_plan_has_plan", + bool(smart_decision_confirm_plan_data.get("plan_id")) + and smart_decision_confirm_plan_data.get("workflow") == "smart_resource_plan", + json.dumps(smart_decision_confirm_plan_data, ensure_ascii=False)[:240], + ) + smart_decision_best_detail = route(base_url, api_key, sessions[1], "先看详情") + smart_decision_best_detail_data = assert_route_action("route_smart_decision_best_detail", smart_decision_best_detail, "pansou_best_detail") + assert_ok( + "route_smart_decision_best_detail_score_summary", + isinstance(smart_decision_best_detail_data.get("score_summary"), dict), + json.dumps(smart_decision_best_detail_data, ensure_ascii=False)[:240], + ) + smart_decision_plan_after_detail = route(base_url, api_key, sessions[1], "计划") + smart_decision_plan_after_detail_data = assert_route_action("route_smart_decision_plan_after_detail", smart_decision_plan_after_detail, "workflow_plan") + assert_ok( + "route_smart_decision_plan_after_detail_has_plan", + bool(smart_decision_plan_after_detail_data.get("plan_id")) + and smart_decision_plan_after_detail_data.get("workflow") == "smart_resource_plan", + json.dumps(smart_decision_plan_after_detail_data, ensure_ascii=False)[:240], + ) + smart_decision_detail_intent_session = f"{sessions[1]}-detail-intent" + smart_decision_detail_intent = route(base_url, api_key, smart_decision_detail_intent_session, f"资源决策 {args.keyword} 详情") + smart_decision_detail_intent_data = assert_route_action("route_smart_decision_detail_intent", smart_decision_detail_intent, "pansou_best_detail") + assert_ok( + "route_smart_decision_detail_intent_score_summary", + isinstance(smart_decision_detail_intent_data.get("score_summary"), dict), + json.dumps(smart_decision_detail_intent_data, ensure_ascii=False)[:240], + ) + smart_search_detail_intent_session = f"{sessions[1]}-smart-detail-intent" + smart_search_detail_intent = route(base_url, api_key, smart_search_detail_intent_session, f"智能搜索 {args.keyword} 详情") + smart_search_detail_intent_data = assert_route_action("route_smart_search_detail_intent", smart_search_detail_intent, "pansou_best_detail") + assert_ok( + "route_smart_search_detail_intent_score_summary", + isinstance(smart_search_detail_intent_data.get("score_summary"), dict), + json.dumps(smart_search_detail_intent_data, ensure_ascii=False)[:240], + ) + smart_decision_execute_intent_session = f"{sessions[1]}-execute-intent" + smart_decision_execute_intent = route(base_url, api_key, smart_decision_execute_intent_session, f"资源决策 {args.keyword} 确认") + smart_decision_execute_intent_data = assert_route_action("route_smart_decision_execute_intent", smart_decision_execute_intent, "execute_plan") + assert_ok( + "route_smart_decision_execute_intent_write_effect", + smart_decision_execute_intent_data.get("write_effect") == "write" + and bool(smart_decision_execute_intent_data.get("smart_execute_auto_selected")), + json.dumps(smart_decision_execute_intent_data, ensure_ascii=False)[:240], + ) + smart_shortcut_session = f"{sessions[1]}-shortcuts" + assert_route_action( + "route_smart_decision_shortcuts_start", + route(base_url, api_key, smart_shortcut_session, f"资源决策 {args.keyword}"), + "smart_resource_decision", + ) + smart_decision_short_detail = route(base_url, api_key, smart_shortcut_session, "详情") + smart_decision_short_detail_data = assert_route_action("route_smart_decision_short_detail", smart_decision_short_detail, "pansou_best_detail") + assert_ok( + "route_smart_decision_short_detail_score_summary", + isinstance(smart_decision_short_detail_data.get("score_summary"), dict), + json.dumps(smart_decision_short_detail_data, ensure_ascii=False)[:240], + ) + assert_route_action( + "route_smart_decision_short_plan_start", + route(base_url, api_key, smart_shortcut_session, f"资源决策 {args.keyword}"), + "smart_resource_decision", + ) + smart_decision_short_plan = route(base_url, api_key, smart_shortcut_session, "计划") + smart_decision_short_plan_data = assert_route_action("route_smart_decision_short_plan", smart_decision_short_plan, "workflow_plan") + assert_ok( + "route_smart_decision_short_plan_has_plan", + bool(smart_decision_short_plan_data.get("plan_id")) + and smart_decision_short_plan_data.get("workflow") == "smart_resource_plan", + json.dumps(smart_decision_short_plan_data, ensure_ascii=False)[:240], + ) + smart_search_best_plan = route(base_url, api_key, sessions[1], "计划最佳") + smart_search_best_plan_data = assert_route_action("route_smart_search_best_plan", smart_search_best_plan, "workflow_plan") + assert_ok( + "route_smart_search_best_plan_has_plan", + bool(smart_search_best_plan_data.get("plan_id")) + and smart_search_best_plan_data.get("workflow") in {"smart_resource_plan", "pansou_best_plan", "hdhive_best_plan"}, + json.dumps(smart_search_best_plan_data, ensure_ascii=False)[:240], + ) + smart_search_plan_recover = recover(base_url, api_key, sessions[1]) + smart_search_plan_recover_data = data(smart_search_plan_recover) + assert_ok( + "route_smart_search_plan_recover_priority", + (smart_search_plan_recover_data.get("recovery") or {}).get("mode") == "resume_saved_plan" + and (smart_search_plan_recover_data.get("recovery") or {}).get("recommended_action") == "execute_latest_plan", + json.dumps(smart_search_plan_recover_data.get("recovery") or {}, ensure_ascii=False), + ) + smart_plan = route(base_url, api_key, sessions[2], f"智能计划 {args.keyword}") + smart_plan_data = assert_route_action("route_smart_plan", smart_plan, "workflow_plan") + assert_ok( + "route_smart_plan_has_plan", + bool(smart_plan_data.get("plan_id")) and smart_plan_data.get("workflow") == "smart_resource_plan", + json.dumps(smart_plan_data, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_plan_best_candidate", + isinstance(smart_plan_data.get("best_candidate"), dict) + and bool((smart_plan_data.get("best_candidate") or {}).get("source_type")) + and smart_plan_data.get("smart_plan_auto_selected") is True, + json.dumps(smart_plan_data, ensure_ascii=False)[:240], + ) + + mp_search = route(base_url, api_key, sessions[1], f"MP搜索 {args.keyword}") + mp_search_data = assert_route_action("route_mp_search", mp_search, "mp_media_search") + mp_search_message = message_text(mp_search) + mp_search_has_best = bool((mp_search_data.get("score_summary") or {}).get("best")) + assert_ok( + "route_mp_search_plan_hint", + ( + ("会先生成下载计划" in mp_search_message and "即可下载选中项" not in mp_search_message) + if mp_search_has_best + else ("暂未搜索到资源" in mp_search_message or "未搜索到资源" in mp_search_message) + ), + mp_search_message[:240], + ) + assert_ok( + "route_mp_search_score_summary", + isinstance(((mp_search_data.get("score_summary") or {}).get("decision") or {}).get("recommended_commands"), list), + json.dumps(mp_search_data.get("score_summary") or {}, ensure_ascii=False)[:240], + ) + assert_ok( + "route_mp_search_score_summary_compact_commands", + bool((((mp_search_data.get("score_summary") or {}).get("decision") or {}).get("preferred_command"))) + and isinstance((((mp_search_data.get("score_summary") or {}).get("decision") or {}).get("compact_commands")), list), + json.dumps((mp_search_data.get("score_summary") or {}).get("decision") or {}, ensure_ascii=False)[:240], + ) + assert_ok( + "route_mp_search_top_level_compact_commands", + ( + mp_search_data.get("command_source") == "score_summary" + and mp_search_data.get("preferred_command") == (((mp_search_data.get("score_summary") or {}).get("decision") or {}).get("preferred_command")) + and isinstance(mp_search_data.get("compact_commands"), list) + and mp_search_data.get("command_policy") == "read_then_confirm_write" + and mp_search_data.get("preferred_requires_confirmation") is False + and mp_search_data.get("fallback_requires_confirmation") is True + ), + json.dumps(mp_search_data, ensure_ascii=False)[:240], + ) + + mp_best = route(base_url, api_key, sessions[1], "最佳片源") + mp_best_data = data(mp_best) + if mp_search_has_best: + mp_best_data = assert_route_action("route_mp_search_best", mp_best, "mp_search_best_detail") + assert_ok( + "route_mp_search_best_score_summary", + bool((mp_best_data.get("score_summary") or {}).get("best")) + and bool(((mp_best_data.get("score_summary") or {}).get("decision") or {}).get("decision_hint")), + json.dumps(mp_best_data.get("score_summary") or {}, ensure_ascii=False)[:240], + ) + + mp_best_download = route(base_url, api_key, sessions[1], "下载最佳") + mp_best_download_data = assert_route_action("route_mp_download_best_plan", mp_best_download, "workflow_plan") + assert_ok( + "route_mp_download_best_has_plan", + bool(mp_best_download_data.get("plan_id")) and mp_best_download_data.get("workflow") == "mp_best_download", + json.dumps(mp_best_download_data, ensure_ascii=False)[:240], + ) + mp_recover_after_plan = recover(base_url, api_key, sessions[1]) + mp_recover_after_plan_data = data(mp_recover_after_plan) + assert_ok( + "route_mp_download_recover_priority", + (mp_recover_after_plan_data.get("recovery") or {}).get("mode") == "resume_saved_plan" + and (mp_recover_after_plan_data.get("recovery") or {}).get("recommended_action") == "execute_latest_plan", + json.dumps(mp_recover_after_plan_data.get("recovery") or {}, ensure_ascii=False), + ) + else: + assert_ok( + "route_mp_search_best_empty_ok", + mp_best.get("success") is False + and mp_best_data.get("action") == "mp_search_best_detail", + json.dumps(mp_best, ensure_ascii=False)[:240], + ) + missing_plan_execute = plan_execute(base_url, api_key, sessions[1], "plan-does-not-exist") + missing_plan_execute_data = data(missing_plan_execute) + assert_ok( + "route_plan_execute_missing_compact", + missing_plan_execute.get("success") is False + and missing_plan_execute_data.get("action") == "execute_plan" + and missing_plan_execute_data.get("write_effect") == "write" + and missing_plan_execute_data.get("error_code") == "plan_not_found" + and isinstance(missing_plan_execute_data.get("result_summary"), dict), + json.dumps({ + "success": missing_plan_execute.get("success"), + "action": missing_plan_execute_data.get("action"), + "write_effect": missing_plan_execute_data.get("write_effect"), + "error_code": missing_plan_execute_data.get("error_code"), + "result_summary": missing_plan_execute_data.get("result_summary"), + }, ensure_ascii=False), + ) + workflow_download_control_missing = workflow( + base_url, + api_key, + sessions[1], + "mp_download_control", + control="pause", + target="1", + ) + workflow_download_control_missing_data = data(workflow_download_control_missing) + assert_ok( + "workflow_download_control_requires_task_item", + workflow_download_control_missing.get("success") is False + and workflow_download_control_missing_data.get("action") == "mp_download_control" + and workflow_download_control_missing_data.get("error_code") == "download_target_not_found" + and not workflow_download_control_missing_data.get("plan_id"), + json.dumps({ + "success": workflow_download_control_missing.get("success"), + "action": workflow_download_control_missing_data.get("action"), + "error_code": workflow_download_control_missing_data.get("error_code"), + "plan_id": workflow_download_control_missing_data.get("plan_id"), + "message": message_text(workflow_download_control_missing)[:160], + }, ensure_ascii=False), + ) + + pansou = route(base_url, api_key, sessions[2], f"ps{args.pansou_keyword}") + assert_route_action("route_pansou_alias", pansou, "pansou_search") + + generic_search = route(base_url, api_key, f"{sessions[2]}-generic-search", f"搜索 {args.pansou_keyword}") + assert_route_action("route_generic_search_defaults_pansou", generic_search, "pansou_search") + + cloud_search = route(base_url, api_key, f"{sessions[2]}-cloud-search", f"云盘搜索 {args.keyword}") + cloud_search_data = data(cloud_search) + assert_ok( + "route_cloud_search_alias", + bool(cloud_search.get("success") and cloud_search_data.get("ok")) + and cloud_search_data.get("action") in {"cloud_search", "pansou_search", "hdhive_candidates", "smart_resource_search"}, + json.dumps({ + "success": cloud_search.get("success"), + "ok": cloud_search_data.get("ok"), + "action": cloud_search_data.get("action"), + "message": message_text(cloud_search)[:160], + }, ensure_ascii=False), + ) + checked_sources = [str((item or {}).get("source_type") or "") for item in (cloud_search_data.get("sources_checked") or [])] + if checked_sources: + assert_ok( + "route_cloud_search_sources_only_cloud", + set(checked_sources).issubset({"pansou", "hdhive"}) and "mp_pt" not in checked_sources, + json.dumps(cloud_search_data.get("sources_checked") or [], ensure_ascii=False)[:240], + ) + + update_check = route(base_url, api_key, f"{sessions[2]}-update-check", f"更新检查 {args.keyword}") + update_check_data = assert_route_action("route_update_check", update_check, "update_check") + update_message = message_text(update_check) + assert_ok( + "route_update_check_lists_channels", + "盘搜:" in update_message and "影巢:" in update_message, + update_message[:240], + ) + assert_ok( + "route_update_check_lists_latest_candidates", + ("盘搜最新集资源:" in update_message or "盘搜最近资源日期:" in update_message or "盘搜:暂无可识别更新结果" in update_message) + and ("影巢最新集资源:" in update_message or "影巢最近资源时间:" in update_message or "影巢:暂无可识别更新结果" in update_message or "影巢:未识别到集数" in update_message), + update_message[:320], + ) + assert_ok( + "route_update_check_decision_summary", + isinstance(update_check_data.get("decision_summary"), dict) + and bool((update_check_data.get("decision_summary") or {}).get("preferred_command")), + json.dumps(update_check_data.get("decision_summary") or {}, ensure_ascii=False)[:240], + ) + + pt_search = route(base_url, api_key, f"{sessions[1]}-pt-search", f"PT搜索 {args.keyword}") + assert_route_action("route_pt_search_alias", pt_search, "mp_media_search") + + hdhive = route(base_url, api_key, sessions[3], f"yc{args.keyword}") + assert_route_action("route_hdhive_alias", hdhive, "hdhive_candidates") + + subscribe_list = route(base_url, api_key, sessions[4], f"订阅列表{args.keyword}") + subscribe_data = assert_route_action("route_subscribe_list_compact", subscribe_list, "mp_subscribes") + assert_ok("route_subscribe_list_no_plan", not subscribe_data.get("plan_id"), json.dumps(subscribe_data, ensure_ascii=False)[:240]) + subscribe_actions = list(subscribe_data.get("next_actions") or []) + assert_ok( + "route_subscribe_list_empty_next_actions", + "mp_subscribe_control.search" not in subscribe_actions + and "mp_subscribe_control.pause" not in subscribe_actions + and "mp_subscribe_control.resume" not in subscribe_actions + and "mp_subscribe_control.delete" not in subscribe_actions, + json.dumps(subscribe_actions, ensure_ascii=False), + ) + subscribe_templates = template_names(subscribe_data) + assert_ok( + "route_subscribe_list_empty_templates", + "search_mp_subscribe" not in subscribe_templates + and "pause_mp_subscribe" not in subscribe_templates + and "resume_mp_subscribe" not in subscribe_templates + and "delete_mp_subscribe" not in subscribe_templates + and "start_mp_subscribe" in subscribe_templates, + json.dumps(subscribe_templates, ensure_ascii=False), + ) + subscribe_recover = recover(base_url, api_key, sessions[4]) + subscribe_recover_data = data(subscribe_recover) + assert_ok( + "route_subscribe_recover_priority", + (subscribe_recover_data.get("recovery") or {}).get("mode") == "continue_mp_subscribes" + and (subscribe_recover_data.get("recovery") or {}).get("recommended_action") == "start_mp_subscribe", + json.dumps(subscribe_recover_data.get("recovery") or {}, ensure_ascii=False), + ) + subscribe_control_missing = route(base_url, api_key, sessions[4], "搜索订阅 1") + subscribe_control_missing_data = data(subscribe_control_missing) + assert_ok( + "route_subscribe_control_requires_list_item", + subscribe_control_missing.get("success") is False + and subscribe_control_missing_data.get("action") == "mp_subscribe_control" + and subscribe_control_missing_data.get("error_code") == "subscribe_target_not_found" + and not subscribe_control_missing_data.get("plan_id"), + json.dumps({ + "success": subscribe_control_missing.get("success"), + "action": subscribe_control_missing_data.get("action"), + "error_code": subscribe_control_missing_data.get("error_code"), + "plan_id": subscribe_control_missing_data.get("plan_id"), + "message": message_text(subscribe_control_missing)[:160], + }, ensure_ascii=False), + ) + workflow_subscribe_control_missing = workflow( + base_url, + api_key, + sessions[4], + "mp_subscribe_control", + control="search", + target="1", + ) + workflow_subscribe_control_missing_data = data(workflow_subscribe_control_missing) + assert_ok( + "workflow_subscribe_control_requires_list_item", + workflow_subscribe_control_missing.get("success") is False + and workflow_subscribe_control_missing_data.get("action") == "mp_subscribe_control" + and workflow_subscribe_control_missing_data.get("error_code") == "subscribe_target_not_found" + and not workflow_subscribe_control_missing_data.get("plan_id"), + json.dumps({ + "success": workflow_subscribe_control_missing.get("success"), + "action": workflow_subscribe_control_missing_data.get("action"), + "error_code": workflow_subscribe_control_missing_data.get("error_code"), + "plan_id": workflow_subscribe_control_missing_data.get("plan_id"), + "message": message_text(workflow_subscribe_control_missing)[:160], + }, ensure_ascii=False), + ) + + download_history = route(base_url, api_key, sessions[4], f"记录{args.keyword}") + download_history_data = assert_route_action("route_download_history_compact", download_history, "mp_download_history") + download_history_recover = recover(base_url, api_key, sessions[4]) + download_history_recover_data = data(download_history_recover) + assert_ok( + "route_download_history_recover_priority", + (download_history_recover_data.get("recovery") or {}).get("mode") == "continue_mp_download_history" + and (download_history_recover_data.get("recovery") or {}).get("recommended_action") == "query_mp_lifecycle_status", + json.dumps(download_history_recover_data.get("recovery") or {}, ensure_ascii=False), + ) + + lifecycle = route(base_url, api_key, sessions[4], f"状态{args.keyword}") + lifecycle_data = assert_route_action("route_lifecycle_compact", lifecycle, "mp_lifecycle_status") + lifecycle_recover = recover(base_url, api_key, sessions[4]) + lifecycle_recover_data = data(lifecycle_recover) + assert_ok( + "route_lifecycle_recover_priority", + (lifecycle_recover_data.get("recovery") or {}).get("mode") == "continue_mp_lifecycle_status" + and (lifecycle_recover_data.get("recovery") or {}).get("recommended_action") == "query_mp_download_history", + json.dumps(lifecycle_recover_data.get("recovery") or {}, ensure_ascii=False), + ) + + smart_followup_keyword = route(base_url, api_key, sessions[4], f"跟进{args.keyword}") + smart_followup_keyword_data = assert_route_action("route_smart_followup_keyword", smart_followup_keyword, "smart_followup") + assert_ok( + "route_smart_followup_keyword_resolved_lifecycle", + smart_followup_keyword_data.get("resolved_followup_action") == "mp_lifecycle_status", + json.dumps(smart_followup_keyword_data, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_followup_keyword_compact_commands", + bool(((smart_followup_keyword_data.get("followup_summary") or {}).get("preferred_command"))) + and isinstance(((smart_followup_keyword_data.get("followup_summary") or {}).get("compact_commands")), list), + json.dumps(smart_followup_keyword_data.get("followup_summary") or {}, ensure_ascii=False)[:240], + ) + + ingest_status = route(base_url, api_key, sessions[4], f"入库{args.keyword}") + ingest_status_data = assert_route_action("route_ingest_status_compact", ingest_status, "mp_ingest_status") + assert_ok( + "route_ingest_status_has_diagnosis", + isinstance(ingest_status_data.get("diagnosis_summary"), dict) + and bool((ingest_status_data.get("diagnosis_summary") or {}).get("stage")) + and bool((((ingest_status_data.get("diagnosis_summary") or {}).get("followup_summary") or {}).get("label"))), + json.dumps(ingest_status_data.get("diagnosis_summary") or {}, ensure_ascii=False)[:240], + ) + + transfer_failed = route(base_url, api_key, sessions[4], f"入库失败{args.keyword}") + transfer_failed_data = assert_route_action("route_transfer_failed_compact", transfer_failed, "mp_ingest_failures") + assert_ok( + "route_transfer_failed_has_diagnosis", + isinstance(transfer_failed_data.get("diagnosis_summary"), dict), + json.dumps(transfer_failed_data.get("diagnosis_summary") or {}, ensure_ascii=False)[:240], + ) + + recent_ingest = route(base_url, api_key, sessions[4], "最近") + recent_ingest_data = assert_route_action("route_recent_ingest_compact", recent_ingest, "mp_recent_activity") + assert_ok( + "route_recent_activity_has_transfer_history", + isinstance((recent_ingest_data.get("transfer_history") or {}).get("items"), list), + json.dumps(recent_ingest_data.get("transfer_history") or {}, ensure_ascii=False)[:240], + ) + + recent_download = route(base_url, api_key, sessions[4], "最近下载") + recent_download_data = assert_route_action("route_recent_download_compact", recent_download, "mp_recent_activity") + assert_ok( + "route_recent_download_has_download_history", + isinstance((recent_download_data.get("download_history") or {}).get("items"), list), + json.dumps(recent_download_data.get("download_history") or {}, ensure_ascii=False)[:240], + ) + + local_diagnose = route(base_url, api_key, sessions[4], f"诊断{args.keyword}") + local_diagnose_data = assert_route_action("route_local_diagnose_compact", local_diagnose, "mp_local_diagnose") + assert_ok( + "route_local_diagnose_has_diagnosis", + isinstance(local_diagnose_data.get("diagnosis_summary"), dict), + json.dumps(local_diagnose_data.get("diagnosis_summary") or {}, ensure_ascii=False)[:240], + ) + assert_ok( + "route_local_diagnose_ai_sample_worklist", + isinstance((local_diagnose_data.get("ai_sample_worklist") or {}).get("items"), list), + json.dumps(local_diagnose_data.get("ai_sample_worklist") or {}, ensure_ascii=False)[:240], + ) + + ai_failed_samples = route(base_url, api_key, sessions[4], f"失败样本 {args.keyword}") + ai_failed_samples_data = assert_route_action("route_ai_failed_samples", ai_failed_samples, "ai_failed_samples", require_success=False) + assert_ok( + "route_ai_failed_samples_payload", + isinstance(ai_failed_samples_data.get("items"), list), + json.dumps(ai_failed_samples_data, ensure_ascii=False)[:240], + ) + + ai_worklist = route(base_url, api_key, sessions[4], f"工作清单 {args.keyword}") + ai_worklist_data = assert_route_action("route_ai_sample_worklist", ai_worklist, "ai_sample_worklist", require_success=False) + assert_ok( + "route_ai_sample_worklist_payload", + isinstance(ai_worklist_data.get("items"), list), + json.dumps(ai_worklist_data, ensure_ascii=False)[:240], + ) + ai_diagnose_short = route(base_url, api_key, sessions[4], "诊断") + ai_diagnose_short_data = assert_route_action("route_ai_session_diagnose_short", ai_diagnose_short, "mp_local_diagnose", require_success=False) + assert_ok( + "route_ai_session_diagnose_short_ok", + ai_diagnose_short_data.get("action") == "mp_local_diagnose", + json.dumps(ai_diagnose_short_data, ensure_ascii=False)[:240], + ) + ai_ingest_short = route(base_url, api_key, sessions[4], "入库状态") + ai_ingest_short_data = assert_route_action("route_ai_session_ingest_short", ai_ingest_short, "mp_ingest_status", require_success=False) + assert_ok( + "route_ai_session_ingest_short_ok", + ai_ingest_short_data.get("action") == "mp_ingest_status", + json.dumps(ai_ingest_short_data, ensure_ascii=False)[:240], + ) + + ai_insights = route(base_url, api_key, sessions[4], f"样本洞察 {args.keyword}") + ai_insights_data = assert_route_action("route_ai_sample_insights", ai_insights, "ai_sample_insights", require_success=False) + assert_ok( + "route_ai_sample_insights_payload", + isinstance(ai_insights_data.get("insights"), dict), + json.dumps(ai_insights_data, ensure_ascii=False)[:240], + ) + + ai_replay = route(base_url, api_key, sessions[4], "重放样本 1") + ai_replay_data = assert_route_action("route_ai_replay_failed_sample", ai_replay, "ai_replay_failed_sample", require_success=False) + if ai_replay.get("success"): + assert_ok( + "route_ai_replay_failed_sample_plan", + bool(ai_replay_data.get("plan_id")) and ai_replay_data.get("workflow") == "ai_replay_failed_sample", + json.dumps(ai_replay_data, ensure_ascii=False)[:240], + ) + else: + assert_ok( + "route_ai_replay_failed_sample_empty_ok", + ai_replay_data.get("error_code") in {"sample_not_found", "missing_sample_index"}, + json.dumps(ai_replay_data, ensure_ascii=False)[:240], + ) + ai_replay_short = route(base_url, api_key, sessions[4], "重放 1") + ai_replay_short_data = assert_route_action("route_ai_replay_short_command", ai_replay_short, "ai_replay_failed_sample", require_success=False) + if ai_replay_short.get("success"): + assert_ok( + "route_ai_replay_short_plan", + bool(ai_replay_short_data.get("plan_id")) and ai_replay_short_data.get("workflow") == "ai_replay_failed_sample", + json.dumps(ai_replay_short_data, ensure_ascii=False)[:240], + ) + ai_replay_confirm = route(base_url, api_key, sessions[4], "确认") + ai_replay_confirm_data = assert_route_action("route_ai_replay_confirm_short", ai_replay_confirm, "execute_plan", require_success=False) + assert_ok( + "route_ai_replay_confirm_short_ok", + ai_replay_confirm_data.get("write_effect") == "write", + json.dumps(ai_replay_confirm_data, ensure_ascii=False)[:240], + ) + else: + assert_ok( + "route_ai_replay_short_empty_ok", + ai_replay_short_data.get("error_code") in {"sample_not_found", "missing_sample_index"}, + json.dumps(ai_replay_short_data, ensure_ascii=False)[:240], + ) + + smart_followup_idle = route(base_url, api_key, sessions[4], "跟进") + smart_followup_idle_data = assert_route_action("route_smart_followup_idle", smart_followup_idle, "smart_followup") + assert_ok( + "route_smart_followup_idle_recent_activity", + smart_followup_idle_data.get("resolved_followup_action") == "mp_recent_activity", + json.dumps(smart_followup_idle_data, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_followup_idle_compact_commands", + bool(((smart_followup_idle_data.get("followup_summary") or {}).get("preferred_command"))) + and isinstance(((smart_followup_idle_data.get("followup_summary") or {}).get("compact_commands")), list), + json.dumps(smart_followup_idle_data.get("followup_summary") or {}, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_followup_idle_top_level_compact_commands", + ( + smart_followup_idle_data.get("command_source") == "followup_summary" + and smart_followup_idle_data.get("preferred_command") == ((smart_followup_idle_data.get("followup_summary") or {}).get("preferred_command")) + and isinstance(smart_followup_idle_data.get("compact_commands"), list) + and smart_followup_idle_data.get("command_policy") == "safe_read_only" + and smart_followup_idle_data.get("preferred_requires_confirmation") is False + ), + json.dumps(smart_followup_idle_data, ensure_ascii=False)[:240], + ) + + movie_recommend = route(base_url, api_key, sessions[5], "热门电影") + assert_route_action("route_recommend_movie", movie_recommend, "mp_recommendations") + movie_message = message_text(movie_recommend) + assert_ok("route_recommend_movie_type_filter", "| 电视剧 |" not in movie_message, movie_message[:240]) + movie_to_mp = route(base_url, api_key, sessions[5], "选择 1") + movie_to_mp_data = data(movie_to_mp) + if movie_to_mp.get("success"): + movie_to_mp_data = assert_route_action("route_recommend_to_mp", movie_to_mp, "mp_media_search") + assert_ok( + "route_recommend_to_mp_scored", + isinstance(((movie_to_mp_data.get("score_summary") or {}).get("decision") or {}).get("recommended_commands"), list), + json.dumps(movie_to_mp_data.get("score_summary") or {}, ensure_ascii=False)[:240], + ) + else: + assert_ok( + "route_recommend_to_mp_empty_ok", + movie_to_mp_data.get("action") == "mp_media_search" + and ("未识别到媒体信息" in message_text(movie_to_mp) or "搜索资源失败" in message_text(movie_to_mp)), + json.dumps(movie_to_mp, ensure_ascii=False)[:240], + ) + movie_recommend_pansou = route(base_url, api_key, sessions[6], "热门电影") + assert_route_action("route_recommend_movie_pansou_session", movie_recommend_pansou, "mp_recommendations") + movie_to_pansou = route(base_url, api_key, sessions[6], "选择 1 盘搜") + movie_to_pansou_data = assert_route_action("route_recommend_to_pansou", movie_to_pansou, "pansou_search") + assert_ok( + "route_recommend_to_pansou_entry_mode", + bool(movie_to_pansou_data.get("preferred_command")) + and isinstance(movie_to_pansou_data.get("compact_commands") or [], list), + json.dumps({ + "preferred_command": movie_to_pansou_data.get("preferred_command"), + "compact_commands": movie_to_pansou_data.get("compact_commands"), + "score_summary": movie_to_pansou_data.get("score_summary"), + }, ensure_ascii=False)[:240], + ) + smart_discovery = route(base_url, api_key, sessions[8], "智能发现 热门电影") + assert_route_action("route_smart_discovery", smart_discovery, "mp_recommendations") + recommend_to_decision = route(base_url, api_key, sessions[8], "选择 1 决策") + recommend_to_decision_data = assert_route_action("route_recommend_to_decision", recommend_to_decision, "smart_resource_decision", require_success=False) + assert_ok( + "route_recommend_to_decision_payload", + bool(recommend_to_decision_data.get("decision_mode")) + and isinstance(recommend_to_decision_data.get("available_sources"), list) + and isinstance(recommend_to_decision_data.get("blocked_sources"), list), + json.dumps(recommend_to_decision_data, ensure_ascii=False)[:240], + ) + smart_discovery_plan = route(base_url, api_key, sessions[9], "智能发现 热门电影") + assert_route_action("route_smart_discovery_plan", smart_discovery_plan, "mp_recommendations") + recommend_to_plan = route(base_url, api_key, sessions[9], "选择 1 计划") + recommend_to_plan_data = data(recommend_to_plan) + assert_ok( + "route_recommend_to_plan", + recommend_to_plan.get("success") + and recommend_to_plan_data.get("action") in {"workflow_plan", "smart_resource_plan"}, + json.dumps(recommend_to_plan, ensure_ascii=False)[:240], + ) + assert_ok( + "route_recommend_to_plan_payload", + bool(recommend_to_plan_data.get("plan_id")) and recommend_to_plan_data.get("workflow") == "smart_resource_plan", + json.dumps(recommend_to_plan_data, ensure_ascii=False)[:240], + ) + smart_discovery_execute = route(base_url, api_key, sessions[10], "智能发现 热门电影") + assert_route_action("route_smart_discovery_execute", smart_discovery_execute, "mp_recommendations") + recommend_to_execute = route(base_url, api_key, sessions[10], "选择 1 确认") + recommend_to_execute_data = data(recommend_to_execute) + assert_ok( + "route_recommend_to_execute", + recommend_to_execute_data.get("action") in {"smart_resource_execute", "execute_plan"} + and recommend_to_execute_data.get("write_effect") == "write", + json.dumps(recommend_to_execute, ensure_ascii=False)[:240], + ) + assert_ok( + "route_recommend_to_execute_payload", + recommend_to_execute_data.get("write_effect") == "write", + json.dumps(recommend_to_execute_data, ensure_ascii=False)[:240], + ) + smart_discovery_short_decision = route(base_url, api_key, sessions[11], "智能发现 热门电影") + assert_route_action("route_smart_discovery_short_decision", smart_discovery_short_decision, "mp_recommendations") + recommend_short_decision = route(base_url, api_key, sessions[11], "决策 1") + recommend_short_decision_data = assert_route_action("route_recommend_short_decision", recommend_short_decision, "smart_resource_decision", require_success=False) + assert_ok( + "route_recommend_short_decision_payload", + bool(recommend_short_decision_data.get("decision_mode")), + json.dumps(recommend_short_decision_data, ensure_ascii=False)[:240], + ) + smart_discovery_short_plan = route(base_url, api_key, sessions[12], "智能发现 热门电影") + assert_route_action("route_smart_discovery_short_plan", smart_discovery_short_plan, "mp_recommendations") + recommend_short_plan = route(base_url, api_key, sessions[12], "计划 1") + recommend_short_plan_data = data(recommend_short_plan) + assert_ok( + "route_recommend_short_plan", + recommend_short_plan.get("success") + and recommend_short_plan_data.get("action") in {"workflow_plan", "smart_resource_plan"}, + json.dumps(recommend_short_plan, ensure_ascii=False)[:240], + ) + assert_ok( + "route_recommend_short_plan_payload", + bool(recommend_short_plan_data.get("plan_id")) and recommend_short_plan_data.get("workflow") == "smart_resource_plan", + json.dumps(recommend_short_plan_data, ensure_ascii=False)[:240], + ) + smart_discovery_short_execute = route(base_url, api_key, sessions[13], "智能发现 热门电影") + assert_route_action("route_smart_discovery_short_execute", smart_discovery_short_execute, "mp_recommendations") + recommend_short_execute = route(base_url, api_key, sessions[13], "确认 1") + recommend_short_execute_data = data(recommend_short_execute) + assert_ok( + "route_recommend_short_execute", + recommend_short_execute_data.get("action") in {"smart_resource_execute", "execute_plan"} + and recommend_short_execute_data.get("write_effect") == "write", + json.dumps(recommend_short_execute, ensure_ascii=False)[:240], + ) + smart_discovery_followups = route(base_url, api_key, sessions[14], "智能发现 热门电影") + assert_route_action("route_smart_discovery_followups", smart_discovery_followups, "mp_recommendations") + recommend_followup_movies = route(base_url, api_key, sessions[14], "电影") + recommend_followup_movies_data = assert_route_action("route_recommend_followup_movies", recommend_followup_movies, "mp_recommendations") + assert_ok( + "route_recommend_followup_movies_payload", + ( + recommend_followup_movies_data.get("kind") == "assistant_mp_recommend" + and "tmdb_movies" in str(recommend_followup_movies_data.get("message_head") or "") + ), + json.dumps(recommend_followup_movies_data, ensure_ascii=False)[:240], + ) + recommend_followup_bangumi = route(base_url, api_key, sessions[14], "番剧") + recommend_followup_bangumi_data = assert_route_action("route_recommend_followup_bangumi", recommend_followup_bangumi, "mp_recommendations") + assert_ok( + "route_recommend_followup_bangumi_payload", + ( + recommend_followup_bangumi_data.get("kind") == "assistant_mp_recommend" + and "bangumi_calendar" in str(recommend_followup_bangumi_data.get("message_head") or "") + ), + json.dumps(recommend_followup_bangumi_data, ensure_ascii=False)[:240], + ) + smart_discovery_detail_flow = route(base_url, api_key, sessions[15], "智能发现 热门电影") + assert_route_action("route_smart_discovery_detail_flow", smart_discovery_detail_flow, "mp_recommendations") + recommend_detail = route(base_url, api_key, sessions[15], "详情 1") + recommend_detail_data = assert_route_action("route_recommend_detail", recommend_detail, "mp_recommendation_detail") + assert_ok( + "route_recommend_detail_message_ok", + "MP 推荐条目详情" in str(recommend_detail_data.get("message_head") or ""), + json.dumps(recommend_detail_data, ensure_ascii=False)[:240], + ) + recommend_detail_decision = route(base_url, api_key, sessions[15], "决策") + recommend_detail_decision_data = assert_route_action("route_recommend_detail_followup_decision", recommend_detail_decision, "smart_resource_decision", require_success=False) + assert_ok( + "route_recommend_detail_followup_decision_ok", + bool(recommend_detail_decision_data.get("decision_mode")), + json.dumps(recommend_detail_decision_data, ensure_ascii=False)[:240], + ) + recommend_detail_plan = route(base_url, api_key, sessions[15], "计划") + recommend_detail_plan_data = assert_route_action("route_recommend_detail_followup_plan", recommend_detail_plan, "workflow_plan") + assert_ok( + "route_recommend_detail_followup_plan_ok", + bool(recommend_detail_plan_data.get("plan_id")), + json.dumps(recommend_detail_plan_data, ensure_ascii=False)[:240], + ) + recommend_detail_confirm = route(base_url, api_key, sessions[15], "确认") + recommend_detail_confirm_data = assert_route_action("route_recommend_detail_followup_confirm", recommend_detail_confirm, "execute_plan", require_success=False) + confirm_message = message_text(recommend_detail_confirm) + assert_ok( + "route_recommend_detail_followup_confirm_pending_plan_ok", + "已根据智能搜索结果自动生成并执行当前首选计划" not in confirm_message, + confirm_message[:240], + ) + assert_ok( + "route_recommend_detail_followup_confirm_has_followup_commands", + bool(recommend_detail_confirm_data.get("preferred_command")) + and isinstance(recommend_detail_confirm_data.get("compact_commands"), list) + and recommend_detail_confirm_data.get("command_source") in {"followup_summary", "error_summary"}, + json.dumps(recommend_detail_confirm_data, ensure_ascii=False)[:240], + ) + smart_discovery_autoplan = route(base_url, api_key, sessions[16], "智能发现 热门电影") + assert_route_action("route_smart_discovery_autoplan", smart_discovery_autoplan, "mp_recommendations") + smart_discovery_autoplan_data = data(smart_discovery_autoplan) + assert_ok( + "route_smart_discovery_autoplan_payload", + smart_discovery_autoplan_data.get("preferred_command") == "详情" + and smart_discovery_autoplan_data.get("fallback_command") == "计划", + json.dumps(smart_discovery_autoplan_data, ensure_ascii=False)[:240], + ) + assert_ok( + "route_smart_discovery_provider_shortcuts_payload", + smart_discovery_autoplan_data.get("decision_short_command") == "决策" + and smart_discovery_autoplan_data.get("pansou_short_command") == "盘搜" + and smart_discovery_autoplan_data.get("hdhive_short_command") == "影巢" + and smart_discovery_autoplan_data.get("mp_short_command") == "原生", + json.dumps(smart_discovery_autoplan_data, ensure_ascii=False)[:280], + ) + recommend_autoplan = route(base_url, api_key, sessions[16], "计划") + recommend_autoplan_data = data(recommend_autoplan) + assert_ok( + "route_recommend_autoplan", + recommend_autoplan.get("success") + and recommend_autoplan_data.get("action") in {"workflow_plan", "smart_resource_plan"}, + json.dumps(recommend_autoplan, ensure_ascii=False)[:240], + ) + assert_ok( + "route_recommend_autoplan_has_plan", + bool(recommend_autoplan_data.get("plan_id")) and recommend_autoplan_data.get("workflow") == "smart_resource_plan", + json.dumps(recommend_autoplan_data, ensure_ascii=False)[:240], + ) + assert_ok( + "route_recommend_autoplan_short_policy", + recommend_autoplan_data.get("preferred_command") == "确认" + and recommend_autoplan_data.get("fallback_command") == "详情" + and recommend_autoplan_data.get("detail_short_command") == "详情" + and recommend_autoplan_data.get("confirm_short_command") == "确认", + json.dumps(recommend_autoplan_data, ensure_ascii=False)[:280], + ) + recommend_autoconfirm = route(base_url, api_key, sessions[16], "确认") + recommend_autoconfirm_data = data(recommend_autoconfirm) + assert_ok( + "route_recommend_autoconfirm", + recommend_autoconfirm_data.get("action") == "execute_plan" + and recommend_autoconfirm_data.get("write_effect") == "write", + json.dumps(recommend_autoconfirm, ensure_ascii=False)[:240], + ) + direct_discovery_detail = route(base_url, api_key, sessions[17], "智能发现 热门电影 详情") + direct_discovery_detail_data = assert_route_action("route_smart_discovery_direct_detail", direct_discovery_detail, "mp_recommendation_detail") + assert_ok( + "route_smart_discovery_direct_detail_message_ok", + "MP 推荐条目详情" in str(direct_discovery_detail_data.get("message_head") or ""), + json.dumps(direct_discovery_detail_data, ensure_ascii=False)[:240], + ) + direct_discovery_plan = route(base_url, api_key, sessions[18], "智能发现 热门电影 计划") + direct_discovery_plan_data = data(direct_discovery_plan) + assert_ok( + "route_smart_discovery_direct_plan", + direct_discovery_plan.get("success") + and direct_discovery_plan_data.get("action") in {"workflow_plan", "smart_resource_plan"} + and bool(direct_discovery_plan_data.get("plan_id")), + json.dumps(direct_discovery_plan, ensure_ascii=False)[:260], + ) + direct_discovery_execute = route(base_url, api_key, sessions[19], "智能发现 热门电影 确认") + direct_discovery_execute_data = data(direct_discovery_execute) + assert_ok( + "route_smart_discovery_direct_execute", + direct_discovery_execute_data.get("action") in {"smart_resource_execute", "execute_plan"} + and direct_discovery_execute_data.get("write_effect") == "write", + json.dumps(direct_discovery_execute, ensure_ascii=False)[:260], + ) + direct_discovery_pansou = route(base_url, api_key, sessions[20], "智能发现 热门电影 盘搜") + direct_discovery_pansou_data = assert_route_action("route_smart_discovery_direct_pansou", direct_discovery_pansou, "pansou_search") + assert_ok( + "route_smart_discovery_direct_pansou_payload", + bool((direct_discovery_pansou_data.get("score_summary") or {}).get("best")), + json.dumps(direct_discovery_pansou_data, ensure_ascii=False)[:260], + ) + direct_discovery_hdhive = route(base_url, api_key, sessions[21], "智能发现 热门电影 影巢") + assert_route_action("route_smart_discovery_direct_hdhive", direct_discovery_hdhive, "hdhive_candidates", require_success=False) + direct_discovery_mp = route(base_url, api_key, sessions[22], "智能发现 热门电影 原生") + direct_discovery_mp_data = assert_route_action("route_smart_discovery_direct_mp", direct_discovery_mp, "mp_media_search") + assert_ok( + "route_smart_discovery_direct_mp_payload", + isinstance((direct_discovery_mp_data.get("items") or []), list), + json.dumps(direct_discovery_mp_data, ensure_ascii=False)[:260], + ) + smart_discovery_return_pansou = route(base_url, api_key, sessions[23], "智能发现 热门电影 盘搜") + smart_discovery_return_pansou_data = assert_route_action("route_smart_discovery_return_pansou_entry", smart_discovery_return_pansou, "pansou_search") + assert_ok( + "route_smart_discovery_return_pansou_entry_payload", + smart_discovery_return_pansou_data.get("return_short_command") == "回推荐", + json.dumps(smart_discovery_return_pansou_data, ensure_ascii=False)[:260], + ) + recommend_return_from_pansou = route(base_url, api_key, sessions[23], "回推荐") + recommend_return_from_pansou_data = assert_route_action("route_smart_discovery_return_pansou", recommend_return_from_pansou, "mp_recommendations") + assert_ok( + "route_smart_discovery_return_pansou_payload", + recommend_return_from_pansou_data.get("selected_index") == 1, + json.dumps(recommend_return_from_pansou_data, ensure_ascii=False)[:260], + ) + smart_discovery_return_mp = route(base_url, api_key, sessions[24], "智能发现 热门电影 原生") + smart_discovery_return_mp_data = assert_route_action("route_smart_discovery_return_mp_entry", smart_discovery_return_mp, "mp_media_search") + assert_ok( + "route_smart_discovery_return_mp_entry_payload", + smart_discovery_return_mp_data.get("return_short_command") == "回推荐", + json.dumps(smart_discovery_return_mp_data, ensure_ascii=False)[:260], + ) + recommend_return_from_mp = route(base_url, api_key, sessions[24], "回推荐") + recommend_return_from_mp_data = assert_route_action("route_smart_discovery_return_mp", recommend_return_from_mp, "mp_recommendations") + assert_ok( + "route_smart_discovery_return_mp_payload", + recommend_return_from_mp_data.get("selected_index") == 1, + json.dumps(recommend_return_from_mp_data, ensure_ascii=False)[:260], + ) + smart_discovery_switch_pansou = route(base_url, api_key, sessions[25], "智能发现 热门电影 盘搜") + smart_discovery_switch_pansou_data = assert_route_action("route_smart_discovery_switch_pansou_entry", smart_discovery_switch_pansou, "pansou_search") + assert_ok( + "route_smart_discovery_switch_pansou_entry_payload", + isinstance((smart_discovery_switch_pansou_data.get("recommend_handoff") or {}).get("source_short_commands"), dict), + json.dumps(smart_discovery_switch_pansou_data, ensure_ascii=False)[:260], + ) + switch_pansou_to_hdhive = route(base_url, api_key, sessions[25], "影巢") + switch_pansou_to_hdhive_data = assert_route_action("route_smart_discovery_switch_pansou_to_hdhive", switch_pansou_to_hdhive, "hdhive_candidates", require_success=False) + assert_ok( + "route_smart_discovery_switch_pansou_to_hdhive_payload", + switch_pansou_to_hdhive_data.get("return_short_command") == "回推荐", + json.dumps(switch_pansou_to_hdhive_data, ensure_ascii=False)[:260], + ) + return_after_switch_pansou = route(base_url, api_key, sessions[25], "回推荐") + return_after_switch_pansou_data = assert_route_action("route_smart_discovery_switch_pansou_return", return_after_switch_pansou, "mp_recommendations") + assert_ok( + "route_smart_discovery_switch_pansou_return_payload", + return_after_switch_pansou_data.get("selected_index") == 1, + json.dumps(return_after_switch_pansou_data, ensure_ascii=False)[:260], + ) + smart_discovery_switch_mp = route(base_url, api_key, sessions[26], "智能发现 热门电影 原生") + assert_route_action("route_smart_discovery_switch_mp_entry", smart_discovery_switch_mp, "mp_media_search") + switch_mp_to_pansou = route(base_url, api_key, sessions[26], "盘搜") + switch_mp_to_pansou_data = assert_route_action("route_smart_discovery_switch_mp_to_pansou", switch_mp_to_pansou, "pansou_search") + assert_ok( + "route_smart_discovery_switch_mp_to_pansou_payload", + switch_mp_to_pansou_data.get("return_short_command") == "回推荐", + json.dumps(switch_mp_to_pansou_data, ensure_ascii=False)[:260], + ) + handoff_pansou_recommend = route(base_url, api_key, sessions[27], "智能发现 热门电影") + assert_route_action("route_smart_discovery_handoff_pansou_recommend", handoff_pansou_recommend, "mp_recommendations") + handoff_pansou_start = route(base_url, api_key, sessions[27], "盘搜") + handoff_pansou_start_data = assert_route_action("route_smart_discovery_handoff_pansou_start", handoff_pansou_start, "pansou_search") + assert_ok( + "route_smart_discovery_handoff_pansou_start_payload", + handoff_pansou_start_data.get("return_short_command") == "回推荐", + json.dumps(handoff_pansou_start_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_pansou_start_short_policy", + handoff_pansou_start_data.get("preferred_command") == "详情" + and handoff_pansou_start_data.get("fallback_command") == "计划", + json.dumps(handoff_pansou_start_data, ensure_ascii=False)[:260], + ) + handoff_pansou_detail = route(base_url, api_key, sessions[27], "详情") + handoff_pansou_detail_data = assert_route_action("route_smart_discovery_handoff_pansou_detail", handoff_pansou_detail, "pansou_best_detail") + assert_ok( + "route_smart_discovery_handoff_pansou_detail_payload", + isinstance(handoff_pansou_detail_data.get("score_summary"), dict), + json.dumps(handoff_pansou_detail_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_pansou_detail_short_policy", + handoff_pansou_detail_data.get("preferred_command") == "计划" + and handoff_pansou_detail_data.get("fallback_command") == "确认", + json.dumps(handoff_pansou_detail_data, ensure_ascii=False)[:260], + ) + handoff_pansou_plan = route(base_url, api_key, sessions[27], "计划") + handoff_pansou_plan_data = assert_route_action("route_smart_discovery_handoff_pansou_plan", handoff_pansou_plan, "workflow_plan") + assert_ok( + "route_smart_discovery_handoff_pansou_plan_payload", + bool(handoff_pansou_plan_data.get("plan_id")), + json.dumps(handoff_pansou_plan_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_pansou_plan_short_policy", + handoff_pansou_plan_data.get("preferred_command") == "确认" + and handoff_pansou_plan_data.get("fallback_command") == "详情", + json.dumps(handoff_pansou_plan_data, ensure_ascii=False)[:260], + ) + handoff_pansou_confirm = route(base_url, api_key, sessions[27], "确认") + handoff_pansou_confirm_data = assert_route_action("route_smart_discovery_handoff_pansou_confirm", handoff_pansou_confirm, "execute_plan", require_success=False) + assert_ok( + "route_smart_discovery_handoff_pansou_confirm_payload", + handoff_pansou_confirm_data.get("write_effect") == "write", + json.dumps(handoff_pansou_confirm_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_pansou_confirm_followup", + handoff_pansou_confirm_data.get("preferred_command") in {"决策", "后续", ""} + or (handoff_pansou_confirm_data.get("followup_summary") or {}).get("preferred_command") == "决策", + json.dumps(handoff_pansou_confirm_data, ensure_ascii=False)[:260], + ) + handoff_mp_recommend = route(base_url, api_key, sessions[28], "智能发现 热门电影") + assert_route_action("route_smart_discovery_handoff_mp_recommend", handoff_mp_recommend, "mp_recommendations") + handoff_mp_start = route(base_url, api_key, sessions[28], "原生") + handoff_mp_start_data = assert_route_action("route_smart_discovery_handoff_mp_start", handoff_mp_start, "mp_media_search") + assert_ok( + "route_smart_discovery_handoff_mp_start_payload", + handoff_mp_start_data.get("return_short_command") == "回推荐", + json.dumps(handoff_mp_start_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_mp_start_short_policy", + handoff_mp_start_data.get("preferred_command") == "详情" + and handoff_mp_start_data.get("fallback_command") == "计划", + json.dumps(handoff_mp_start_data, ensure_ascii=False)[:260], + ) + handoff_mp_detail = route(base_url, api_key, sessions[28], "详情") + handoff_mp_detail_data = assert_route_action("route_smart_discovery_handoff_mp_detail", handoff_mp_detail, "mp_search_best_detail") + assert_ok( + "route_smart_discovery_handoff_mp_detail_payload", + isinstance(handoff_mp_detail_data.get("score_summary"), dict), + json.dumps(handoff_mp_detail_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_mp_detail_short_policy", + handoff_mp_detail_data.get("preferred_command") == "计划" + and handoff_mp_detail_data.get("fallback_command") == "确认", + json.dumps(handoff_mp_detail_data, ensure_ascii=False)[:260], + ) + handoff_mp_plan = route(base_url, api_key, sessions[28], "计划") + handoff_mp_plan_data = assert_route_action("route_smart_discovery_handoff_mp_plan", handoff_mp_plan, "workflow_plan") + assert_ok( + "route_smart_discovery_handoff_mp_plan_payload", + bool(handoff_mp_plan_data.get("plan_id")), + json.dumps(handoff_mp_plan_data, ensure_ascii=False)[:260], + ) + assert_ok( + "route_smart_discovery_handoff_mp_plan_short_policy", + handoff_mp_plan_data.get("preferred_command") == "确认" + and handoff_mp_plan_data.get("fallback_command") == "详情", + json.dumps(handoff_mp_plan_data, ensure_ascii=False)[:260], + ) + handoff_mp_decision = route(base_url, api_key, sessions[28], "决策") + handoff_mp_decision_data = assert_route_action("route_smart_discovery_handoff_mp_decision", handoff_mp_decision, "smart_resource_decision", require_success=False) + assert_ok( + "route_smart_discovery_handoff_mp_decision_payload", + bool(handoff_mp_decision_data.get("decision_mode")), + json.dumps(handoff_mp_decision_data, ensure_ascii=False)[:260], + ) + recommend_source_compound = route(base_url, api_key, sessions[29], "智能发现 热门电影") + assert_route_action("route_smart_discovery_source_compound_recommend", recommend_source_compound, "mp_recommendations") + recommend_source_compound_pansou_plan = route(base_url, api_key, sessions[29], "盘搜 计划") + recommend_source_compound_pansou_plan_data = assert_route_action( + "route_smart_discovery_source_compound_pansou_plan", + recommend_source_compound_pansou_plan, + "workflow_plan", + ) + assert_ok( + "route_smart_discovery_source_compound_pansou_plan_payload", + bool(recommend_source_compound_pansou_plan_data.get("plan_id")), + json.dumps(recommend_source_compound_pansou_plan_data, ensure_ascii=False)[:260], + ) + recommend_source_compound_mp = route(base_url, api_key, sessions[30], "智能发现 热门电影 盘搜") + assert_route_action("route_smart_discovery_source_compound_handoff_entry", recommend_source_compound_mp, "pansou_search") + recommend_source_compound_mp_detail = route(base_url, api_key, sessions[30], "原生 详情") + recommend_source_compound_mp_detail_data = assert_route_action( + "route_smart_discovery_source_compound_mp_detail", + recommend_source_compound_mp_detail, + "mp_search_best_detail", + ) + assert_ok( + "route_smart_discovery_source_compound_mp_detail_payload", + recommend_source_compound_mp_detail_data.get("preferred_command") == "计划" + and recommend_source_compound_mp_detail_data.get("fallback_command") == "确认", + json.dumps(recommend_source_compound_mp_detail_data, ensure_ascii=False)[:260], + ) + tv_recommend = route(base_url, api_key, sessions[7], "热门电视剧") + assert_route_action("route_recommend_tv", tv_recommend, "mp_recommendations") + tv_message = message_text(tv_recommend) + assert_ok("route_recommend_tv_type_filter", "| 电影 |" not in tv_message, tv_message[:240]) + finally: + for session in sessions: + clear_plans(base_url, api_key, session) + clear_session(base_url, api_key, session) + + print("agent_resource_officer_smoke_ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync-package-v2.sh b/scripts/sync-package-v2.sh new file mode 100755 index 0000000..fa75793 --- /dev/null +++ b/scripts/sync-package-v2.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/sync-package-v2.sh + +Syncs package.v2.json from package.json by dropping per-plugin "v2" flags +and keeping the current plugin metadata layout used by the repository. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +python3 - <<'PY' +import json +from pathlib import Path + +package_path = Path("package.json") +package_v2_path = Path("package.v2.json") + +package = json.loads(package_path.read_text(encoding="utf-8")) +package_v2 = { + plugin_id: {key: value for key, value in meta.items() if key != "v2"} + for plugin_id, meta in package.items() +} + +package_v2_path.write_text( + json.dumps(package_v2, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", +) +print(f"已同步 {package_v2_path}") +PY diff --git a/scripts/sync-repo-layout.sh b/scripts/sync-repo-layout.sh new file mode 100755 index 0000000..1505ca8 --- /dev/null +++ b/scripts/sync-repo-layout.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/sync-repo-layout.sh + +Syncs root plugin source directories into plugins/ and plugins.v2/ using the +current package.json plugin list and normalized lower-case target names. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +sync_plugin() { + local src_dir="$1" + local target_name="$2" + + local target_dir_v2="$ROOT_DIR/plugins.v2/$target_name" + local target_dir_v1="$ROOT_DIR/plugins/$target_name" + + mkdir -p "$target_dir_v2" "$target_dir_v1" + + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete --delete-excluded --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '*.pyo' \ + --exclude '.DS_Store' \ + "$src_dir/" "$target_dir_v2/" + rsync -a --delete --delete-excluded --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '*.pyo' \ + --exclude '.DS_Store' \ + "$src_dir/" "$target_dir_v1/" + else + find "$target_dir_v2" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + find "$target_dir_v1" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -R "$src_dir/." "$target_dir_v2/" + cp -R "$src_dir/." "$target_dir_v1/" + find "$target_dir_v2" "$target_dir_v1" -name '__pycache__' -type d -prune -exec rm -rf {} + + find "$target_dir_v2" "$target_dir_v1" \( -name '*.pyc' -o -name '*.pyo' -o -name '.DS_Store' \) -delete + fi + find "$target_dir_v2" "$target_dir_v1" -type d -exec chmod 755 {} + + find "$target_dir_v2" "$target_dir_v1" -type f -exec chmod 644 {} + + + echo "$target_dir_v2" + echo "$target_dir_v1" +} + +echo "已同步官方插件仓库目录:" +ROOT_DIR="$ROOT_DIR" python3 - <<'PY' | while IFS=$'\t' read -r src_dir target_name; do +import json +import os +from pathlib import Path + +root_dir = Path(os.environ["ROOT_DIR"]) +package = json.loads((root_dir / "package.json").read_text(encoding="utf-8")) +for plugin_id in package: + if (root_dir / plugin_id / "__init__.py").exists(): + print(f"{plugin_id}\t{plugin_id.lower()}") +PY + sync_plugin "$ROOT_DIR/$src_dir" "$target_name" +done diff --git a/scripts/update-draft-release-assets.sh b/scripts/update-draft-release-assets.sh new file mode 100755 index 0000000..5c8b0ae --- /dev/null +++ b/scripts/update-draft-release-assets.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/update-draft-release-assets.sh [--skip-check] + +Rebuilds or verifies local release assets, updates an existing GitHub Draft +Release's notes, uploads all assets with --clobber, then downloads and verifies +the release assets. +EOF +} + +TAG="" +SKIP_CHECK=0 +for arg in "$@"; do + case "$arg" in + --skip-check) + SKIP_CHECK=1 + ;; + --help|-h) + show_help + exit 0 + ;; + *) + if [[ -z "$TAG" ]]; then + TAG="$arg" + else + echo "未知参数: $arg" >&2 + show_help >&2 + exit 2 + fi + ;; + esac +done + +if [[ -z "$TAG" ]]; then + echo "缺少 release tag。" >&2 + show_help >&2 + exit 2 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "未找到 gh 命令,无法更新 GitHub Draft Release。" >&2 + exit 1 +fi + +if [[ "$SKIP_CHECK" == "1" ]]; then + bash scripts/verify-release-assets.sh dist +else + bash scripts/release-preflight.sh +fi + +notes_file="$(mktemp)" +release_assets_file="$(mktemp)" +stale_assets_file="$(mktemp)" +asset_stage_dir="$(mktemp -d)" +cleanup() { + rm -f "$notes_file" + rm -f "$release_assets_file" + rm -f "$stale_assets_file" + rm -rf "$asset_stage_dir" +} +trap cleanup EXIT + +bash scripts/generate-release-notes.sh "$TAG" >"$notes_file" + +cp dist/*.zip "$asset_stage_dir/" +cp dist/SHA256SUMS.txt "$asset_stage_dir/PLUGIN_SHA256SUMS.txt" +cp dist/MANIFEST.json "$asset_stage_dir/PLUGIN_MANIFEST.json" +cp dist/skills/*.zip "$asset_stage_dir/" +cp dist/skills/SHA256SUMS.txt "$asset_stage_dir/SKILL_SHA256SUMS.txt" +cp dist/skills/MANIFEST.json "$asset_stage_dir/SKILL_MANIFEST.json" + +gh release view "$TAG" --json assets >"$release_assets_file" +ASSET_STAGE_DIR="$asset_stage_dir" RELEASE_ASSETS_FILE="$release_assets_file" python3 - <<'PY' >"$stale_assets_file" +import json +import os +from pathlib import Path + +stage_dir = Path(os.environ["ASSET_STAGE_DIR"]) +expected = {path.name for path in stage_dir.iterdir() if path.is_file()} +release = json.loads(Path(os.environ["RELEASE_ASSETS_FILE"]).read_text(encoding="utf-8")) +for asset in release.get("assets") or []: + name = str(asset.get("name") or "") + if not name or name in expected: + continue + if name.endswith(".zip") or name in { + "PLUGIN_SHA256SUMS.txt", + "PLUGIN_MANIFEST.json", + "SKILL_SHA256SUMS.txt", + "SKILL_MANIFEST.json", + }: + print(name) +PY +while IFS= read -r asset_name; do + if [[ -z "$asset_name" ]]; then + continue + fi + echo "删除旧 Release 附件: $asset_name" + gh release delete-asset "$TAG" "$asset_name" -y +done <"$stale_assets_file" + +gh release edit "$TAG" --notes-file "$notes_file" +gh release upload "$TAG" "$asset_stage_dir"/* --clobber +bash scripts/verify-release-download.sh "$TAG" +echo "draft_release_assets_update_ok tag=$TAG" diff --git a/scripts/verify-ci-artifact.sh b/scripts/verify-ci-artifact.sh new file mode 100755 index 0000000..49d85ca --- /dev/null +++ b/scripts/verify-ci-artifact.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + bash scripts/verify-release-preflight-artifact.sh --help + exit 0 +fi + +echo "warning: scripts/verify-ci-artifact.sh 已弃用,请改用 scripts/verify-release-preflight-artifact.sh" >&2 +exec bash scripts/verify-release-preflight-artifact.sh "$@" diff --git a/scripts/verify-dist.sh b/scripts/verify-dist.sh new file mode 100755 index 0000000..3d90b87 --- /dev/null +++ b/scripts/verify-dist.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + DIST_DIR=dist bash scripts/verify-dist.sh + +Verifies plugin ZIPs, SHA256SUMS.txt and MANIFEST.json under DIST_DIR. +Defaults to dist/. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +DIST_DIR="${DIST_DIR:-dist}" python3 - <<'PY' +from hashlib import sha256 +import json +import os +from pathlib import Path +import zipfile + +dist_dir = Path(os.environ["DIST_DIR"]) +manifest = dist_dir / "SHA256SUMS.txt" +json_manifest = dist_dir / "MANIFEST.json" +if not dist_dir.exists(): + print(f"{dist_dir} 目录不存在") + raise SystemExit(1) +if not manifest.exists(): + print(f"{manifest} 不存在") + raise SystemExit(1) +if not json_manifest.exists(): + print(f"{json_manifest} 不存在") + raise SystemExit(1) + +zip_files = sorted(dist_dir.glob("*.zip")) +if not zip_files: + print("dist 目录没有 ZIP 文件") + raise SystemExit(1) + +expected = {} +for line in manifest.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + digest, filename = line.split(None, 1) + except ValueError: + print(f"SHA256SUMS.txt 行格式错误: {line}") + raise SystemExit(1) + expected[filename.strip()] = digest.strip() + +zip_names = {path.name for path in zip_files} +manifest_names = set(expected) +missing = sorted(zip_names - manifest_names) +extra = sorted(manifest_names - zip_names) +if missing or extra: + if missing: + print("SHA256SUMS.txt 缺少 ZIP:") + print("\n".join(missing)) + if extra: + print("SHA256SUMS.txt 包含不存在的 ZIP:") + print("\n".join(extra)) + raise SystemExit(1) + +manifest_data = json.loads(json_manifest.read_text(encoding="utf-8")) +manifest_plugins = manifest_data.get("plugins") +if not isinstance(manifest_plugins, list): + print("MANIFEST.json 缺少 plugins 列表") + raise SystemExit(1) +package = {} +package_file = Path("package.json") +if package_file.exists(): + package = json.loads(package_file.read_text(encoding="utf-8")) +manifest_by_zip = {} +for item in manifest_plugins: + if not isinstance(item, dict) or not item.get("zip"): + print("MANIFEST.json 插件条目格式错误") + raise SystemExit(1) + manifest_by_zip[item["zip"]] = item +missing_json = sorted(zip_names - set(manifest_by_zip)) +extra_json = sorted(set(manifest_by_zip) - zip_names) +if missing_json or extra_json: + if missing_json: + print("MANIFEST.json 缺少 ZIP:") + print("\n".join(missing_json)) + if extra_json: + print("MANIFEST.json 包含不存在的 ZIP:") + print("\n".join(extra_json)) + raise SystemExit(1) + +for zip_file in zip_files: + actual = sha256(zip_file.read_bytes()).hexdigest() + if expected[zip_file.name] != actual: + print(f"{zip_file} SHA256 不匹配") + raise SystemExit(1) + manifest_item = manifest_by_zip[zip_file.name] + if manifest_item.get("sha256") != actual: + print(f"{zip_file} MANIFEST.json SHA256 不匹配") + raise SystemExit(1) + if manifest_item.get("size") != zip_file.stat().st_size: + print(f"{zip_file} MANIFEST.json size 不匹配") + raise SystemExit(1) + plugin_name = zip_file.name.rsplit("-", 1)[0] + package_meta = package.get(plugin_name) + if package_meta: + if manifest_item.get("id") != plugin_name: + print(f"{zip_file} MANIFEST.json id 不匹配") + raise SystemExit(1) + if manifest_item.get("name") != package_meta.get("name"): + print(f"{zip_file} MANIFEST.json name 不匹配") + raise SystemExit(1) + if manifest_item.get("version") != package_meta.get("version"): + print(f"{zip_file} MANIFEST.json version 不匹配") + raise SystemExit(1) + required_readme = f"{plugin_name}/README.md" + required_init = f"{plugin_name}/__init__.py" + with zipfile.ZipFile(zip_file) as zip_obj: + names = set(zip_obj.namelist()) + bad_entries = [ + name + for name in names + if "__pycache__" in name or name.endswith((".pyc", ".pyo", ".DS_Store")) + ] + if required_readme not in names: + print(f"{zip_file} 缺少 {required_readme}") + raise SystemExit(1) + if required_init not in names: + print(f"{zip_file} 缺少 {required_init}") + raise SystemExit(1) + if bad_entries: + print(f"{zip_file} 包含不应发布的生成文件:") + print("\n".join(sorted(bad_entries))) + raise SystemExit(1) + +print(f"dist_verify_ok files={len(zip_files)}") +PY diff --git a/scripts/verify-release-assets.sh b/scripts/verify-release-assets.sh new file mode 100755 index 0000000..2d2f060 --- /dev/null +++ b/scripts/verify-release-assets.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +ASSET_DIR="${1:-dist}" +show_help() { + cat <<'EOF' +Usage: + bash scripts/verify-release-assets.sh [asset_dir] + +Verifies a release asset directory containing plugin ZIPs, Skill ZIPs, +SHA256SUMS and MANIFEST files. Defaults to dist/. +EOF +} + +if [[ "$ASSET_DIR" == "--help" || "$ASSET_DIR" == "-h" ]]; then + show_help + exit 0 +fi +if [[ ! -d "$ASSET_DIR" ]]; then + echo "发布产物目录不存在: $ASSET_DIR" >&2 + exit 1 +fi + +if [[ -f "$ASSET_DIR/PLUGIN_SHA256SUMS.txt" && -f "$ASSET_DIR/PLUGIN_MANIFEST.json" && -f "$ASSET_DIR/SKILL_SHA256SUMS.txt" && -f "$ASSET_DIR/SKILL_MANIFEST.json" ]]; then + tmp_dir="$(mktemp -d)" + cleanup() { + rm -rf "$tmp_dir" + } + trap cleanup EXIT + plugin_dir="$tmp_dir/plugin" + skill_dir="$tmp_dir/skills" + mkdir -p "$plugin_dir" "$skill_dir" + cp "$ASSET_DIR/PLUGIN_SHA256SUMS.txt" "$plugin_dir/SHA256SUMS.txt" + cp "$ASSET_DIR/PLUGIN_MANIFEST.json" "$plugin_dir/MANIFEST.json" + cp "$ASSET_DIR/SKILL_SHA256SUMS.txt" "$skill_dir/SHA256SUMS.txt" + cp "$ASSET_DIR/SKILL_MANIFEST.json" "$skill_dir/MANIFEST.json" + ASSET_DIR="$ASSET_DIR" PLUGIN_DIR="$plugin_dir" SKILL_DIR="$skill_dir" python3 - <<'PY' +import json +import os +import shutil +from pathlib import Path + +asset_dir = Path(os.environ["ASSET_DIR"]) +plugin_dir = Path(os.environ["PLUGIN_DIR"]) +skill_dir = Path(os.environ["SKILL_DIR"]) + +plugin_manifest = json.loads((asset_dir / "PLUGIN_MANIFEST.json").read_text(encoding="utf-8")) +skill_manifest = json.loads((asset_dir / "SKILL_MANIFEST.json").read_text(encoding="utf-8")) + +for item in plugin_manifest.get("plugins") or []: + zip_name = item.get("zip") + if not zip_name: + continue + src = asset_dir / zip_name + if not src.exists(): + print(f"Release 附件缺少插件 ZIP: {zip_name}") + raise SystemExit(1) + shutil.copy2(src, plugin_dir / zip_name) + +for item in skill_manifest.get("skills") or []: + zip_name = item.get("zip") + if not zip_name: + continue + src = asset_dir / zip_name + if not src.exists(): + print(f"Release 附件缺少 Skill ZIP: {zip_name}") + raise SystemExit(1) + shutil.copy2(src, skill_dir / zip_name) +PY + DIST_DIR="$plugin_dir" bash scripts/verify-dist.sh + DIST_DIR="$skill_dir" bash scripts/verify-skill-dist.sh + echo "release_assets_verify_ok dir=$ASSET_DIR" + exit 0 +fi + +skill_asset_dir="" +if [[ -d "$ASSET_DIR/skills" ]]; then + skill_asset_dir="$ASSET_DIR/skills" +elif [[ -d "$ASSET_DIR/dist/skills" ]]; then + skill_asset_dir="$ASSET_DIR/dist/skills" +fi + +if [[ -z "$skill_asset_dir" ]]; then + echo "发布产物目录缺少 Skill 产物子目录: $ASSET_DIR" >&2 + exit 1 +fi + +DIST_DIR="$ASSET_DIR" bash scripts/verify-dist.sh +DIST_DIR="$skill_asset_dir" bash scripts/verify-skill-dist.sh +echo "release_assets_verify_ok dir=$ASSET_DIR" diff --git a/scripts/verify-release-download.sh b/scripts/verify-release-download.sh new file mode 100755 index 0000000..2bda544 --- /dev/null +++ b/scripts/verify-release-download.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/verify-release-download.sh + +Downloads GitHub Release or Draft Release assets for and verifies +plugin ZIP, Skill ZIP, SHA256SUMS and MANIFEST files. +EOF +} + +TAG="${1:-}" +if [[ "$TAG" == "--help" || "$TAG" == "-h" ]]; then + show_help + exit 0 +fi +if [[ -z "$TAG" ]]; then + echo "缺少 release tag。" >&2 + show_help >&2 + exit 2 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "未找到 gh 命令,无法下载 GitHub Release 附件。" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +gh release download "$TAG" --dir "$tmp_dir" --clobber +bash scripts/verify-release-assets.sh "$tmp_dir" +echo "release_download_verify_ok tag=$TAG" diff --git a/scripts/verify-release-preflight-artifact.sh b/scripts/verify-release-preflight-artifact.sh new file mode 100755 index 0000000..09c2330 --- /dev/null +++ b/scripts/verify-release-preflight-artifact.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +RUN_ID="${1:-}" +WORKFLOW_NAME="${WORKFLOW_NAME:-Release Preflight}" +WORKFLOW_FILE="${WORKFLOW_FILE:-ci.yml}" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/verify-release-preflight-artifact.sh [run_id] + +Downloads the latest successful Release Preflight workflow artifact for main, +or the artifact from the specified GitHub Actions run id, then verifies the +plugin ZIP, Skill ZIP, SHA256SUMS and MANIFEST files. +EOF +} + +if [[ "$RUN_ID" == "--help" || "$RUN_ID" == "-h" ]]; then + show_help + exit 0 +fi +if ! command -v gh >/dev/null 2>&1; then + echo "未找到 gh 命令,无法下载 GitHub Actions artifact。" >&2 + exit 1 +fi +if ! command -v unzip >/dev/null 2>&1; then + echo "未找到 unzip 命令,无法解压 GitHub Actions artifact。" >&2 + exit 1 +fi + +REPO="${GITHUB_REPOSITORY:-}" +if [ -z "$REPO" ]; then + REPO="$(gh repo view --json nameWithOwner --jq '.nameWithOwner')" +fi +if [ -z "$REPO" ] || [ "$REPO" = "null" ]; then + echo "无法识别当前 GitHub 仓库。" >&2 + exit 1 +fi + +resolve_run_id() { + local selector="$1" + gh run list --repo "$REPO" --workflow "$selector" --branch main --status success --limit 1 --json databaseId --jq '.[0].databaseId' 2>/dev/null || true +} + +if [ -z "$RUN_ID" ]; then + RUN_ID="$(resolve_run_id "$WORKFLOW_NAME")" + if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then + RUN_ID="$(resolve_run_id "$WORKFLOW_FILE")" + fi +fi +if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then + echo "未找到可用的成功 $WORKFLOW_NAME / $WORKFLOW_FILE run。" >&2 + exit 1 +fi + +artifact_count="$(gh api "repos/$REPO/actions/runs/$RUN_ID/artifacts" --jq '.artifacts | length')" +if [ "$artifact_count" -lt 1 ]; then + echo "$WORKFLOW_NAME run $RUN_ID 没有 artifact。" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d)" +cleanup() { + rm -rf "$tmp_dir" +} +trap cleanup EXIT + +gh run download "$RUN_ID" --repo "$REPO" --dir "$tmp_dir" >/dev/null +artifact_dir="$( + find "$tmp_dir" -mindepth 1 -maxdepth 1 -type d | while IFS= read -r candidate_dir; do + if [ -f "$candidate_dir/SHA256SUMS.txt" ] && [ -f "$candidate_dir/MANIFEST.json" ]; then + printf '%s\n' "$candidate_dir" + break + fi + done +)" +if [ -z "$artifact_dir" ]; then + echo "$WORKFLOW_NAME run $RUN_ID artifact 下载后没有可校验的发布产物目录。" >&2 + exit 1 +fi + +bash scripts/verify-release-assets.sh "$artifact_dir" +echo "release_preflight_artifact_verify_ok run=$RUN_ID" diff --git a/scripts/verify-skill-dist.sh b/scripts/verify-skill-dist.sh new file mode 100755 index 0000000..f773805 --- /dev/null +++ b/scripts/verify-skill-dist.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + DIST_DIR=dist/skills bash scripts/verify-skill-dist.sh + +Verifies Skill ZIPs, SHA256SUMS.txt and MANIFEST.json under DIST_DIR. +Defaults to dist/skills. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +DIST_DIR="${DIST_DIR:-dist/skills}" python3 - <<'PY' +from hashlib import sha256 +import json +import os +from pathlib import Path +import subprocess +import tempfile +import zipfile + +dist_dir = Path(os.environ["DIST_DIR"]) +manifest = dist_dir / "SHA256SUMS.txt" +json_manifest = dist_dir / "MANIFEST.json" +if not dist_dir.exists(): + print(f"{dist_dir} 目录不存在") + raise SystemExit(1) +if not manifest.exists(): + print(f"{manifest} 不存在") + raise SystemExit(1) +if not json_manifest.exists(): + print(f"{json_manifest} 不存在") + raise SystemExit(1) + +zip_files = sorted(dist_dir.glob("*.zip")) +if not zip_files: + print("Skill dist 目录没有 ZIP 文件") + raise SystemExit(1) + +expected = {} +for line in manifest.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + digest, filename = line.split(None, 1) + except ValueError: + print(f"Skill SHA256SUMS.txt 行格式错误: {line}") + raise SystemExit(1) + expected[filename.strip()] = digest.strip() + +zip_names = {path.name for path in zip_files} +manifest_names = set(expected) +missing = sorted(zip_names - manifest_names) +extra = sorted(manifest_names - zip_names) +if missing or extra: + if missing: + print("Skill SHA256SUMS.txt 缺少 ZIP:") + print("\n".join(missing)) + if extra: + print("Skill SHA256SUMS.txt 包含不存在的 ZIP:") + print("\n".join(extra)) + raise SystemExit(1) + +manifest_data = json.loads(json_manifest.read_text(encoding="utf-8")) +manifest_skills = manifest_data.get("skills") +if not isinstance(manifest_skills, list): + print("Skill MANIFEST.json 缺少 skills 列表") + raise SystemExit(1) +manifest_by_zip = {} +for item in manifest_skills: + if not isinstance(item, dict) or not item.get("zip"): + print("Skill MANIFEST.json 条目格式错误") + raise SystemExit(1) + manifest_by_zip[item["zip"]] = item +missing_json = sorted(zip_names - set(manifest_by_zip)) +extra_json = sorted(set(manifest_by_zip) - zip_names) +if missing_json or extra_json: + if missing_json: + print("Skill MANIFEST.json 缺少 ZIP:") + print("\n".join(missing_json)) + if extra_json: + print("Skill MANIFEST.json 包含不存在的 ZIP:") + print("\n".join(extra_json)) + raise SystemExit(1) + +for zip_file in zip_files: + actual = sha256(zip_file.read_bytes()).hexdigest() + if expected[zip_file.name] != actual: + print(f"{zip_file} SHA256 不匹配") + raise SystemExit(1) + manifest_item = manifest_by_zip[zip_file.name] + if manifest_item.get("sha256") != actual: + print(f"{zip_file} Skill MANIFEST.json SHA256 不匹配") + raise SystemExit(1) + if manifest_item.get("size") != zip_file.stat().st_size: + print(f"{zip_file} Skill MANIFEST.json size 不匹配") + raise SystemExit(1) + skill_name = manifest_item.get("id") + version = manifest_item.get("version") + if not skill_name or not version: + print(f"{zip_file} Skill MANIFEST.json 缺少 id/version") + raise SystemExit(1) + if zip_file.name != f"{skill_name}-{version}.zip": + print(f"{zip_file} 文件名与 Skill MANIFEST.json id/version 不匹配") + raise SystemExit(1) + required = { + f"{skill_name}/SKILL.md", + f"{skill_name}/README.md", + f"{skill_name}/CHANGELOG.md", + f"{skill_name}/install.sh", + } + with zipfile.ZipFile(zip_file) as zip_obj: + names = set(zip_obj.namelist()) + bad_entries = [ + name + for name in names + if "__pycache__" in name or name.endswith((".pyc", ".pyo", ".DS_Store")) + ] + missing_required = sorted(required - names) + if missing_required: + print(f"{zip_file} 缺少 Skill 必需文件:") + print("\n".join(missing_required)) + raise SystemExit(1) + if bad_entries: + print(f"{zip_file} 包含不应发布的生成文件:") + print("\n".join(sorted(bad_entries))) + raise SystemExit(1) + if skill_name == "agent-resource-officer": + external_agent_required = { + f"{skill_name}/EXTERNAL_AGENTS.md", + f"{skill_name}/scripts/aro_request.py", + } + missing_external_agent = sorted(external_agent_required - names) + if missing_external_agent: + print(f"{zip_file} 缺少外部智能体入口文件:") + print("\n".join(missing_external_agent)) + raise SystemExit(1) + with tempfile.TemporaryDirectory() as tmpdir: + with zipfile.ZipFile(zip_file) as zip_obj: + zip_obj.extractall(tmpdir) + helper = Path(tmpdir) / skill_name / "scripts" / "aro_request.py" + raw = subprocess.check_output(["python3", str(helper), "external-agent"], text=True) + payload = json.loads(raw) + if payload.get("schema_version") != "external_agent.v1": + print(f"{zip_file} external-agent schema_version 无效") + raise SystemExit(1) + if not payload.get("guide_file_exists"): + print(f"{zip_file} external-agent guide_file_exists=false") + raise SystemExit(1) + if len(payload.get("tools") or []) != 4: + print(f"{zip_file} external-agent tools 数量无效") + raise SystemExit(1) + +print(f"skill_dist_verify_ok files={len(zip_files)}") +PY diff --git a/scripts/write-dist-sha256.sh b/scripts/write-dist-sha256.sh new file mode 100755 index 0000000..7ec72ff --- /dev/null +++ b/scripts/write-dist-sha256.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +show_help() { + cat <<'EOF' +Usage: + bash scripts/write-dist-sha256.sh + +Regenerates dist/SHA256SUMS.txt and dist/MANIFEST.json from the current plugin +ZIP files in dist/. +EOF +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + show_help + exit 0 +fi + +python3 - <<'PY' +from hashlib import sha256 +import json +from pathlib import Path + +dist_dir = Path("dist") +package = json.loads(Path("package.json").read_text(encoding="utf-8")) +zip_files = sorted(dist_dir.glob("*.zip")) +if not zip_files: + print("dist 目录没有生成 ZIP 文件") + raise SystemExit(1) + +lines = [] +plugins = [] +for zip_file in zip_files: + plugin_id = zip_file.name.rsplit("-", 1)[0] + meta = package.get(plugin_id) or {} + digest = sha256(zip_file.read_bytes()).hexdigest() + lines.append(f"{digest} {zip_file.name}") + plugins.append( + { + "id": plugin_id, + "name": meta.get("name") or plugin_id, + "version": meta.get("version") or "unknown", + "zip": zip_file.name, + "sha256": digest, + "size": zip_file.stat().st_size, + } + ) +(dist_dir / "SHA256SUMS.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") +(dist_dir / "MANIFEST.json").write_text( + json.dumps({"plugins": plugins}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", +) +print(f"sha256_manifest_ok files={len(zip_files)}") +PY diff --git a/skills/agent-resource-officer/CHANGELOG.md b/skills/agent-resource-officer/CHANGELOG.md new file mode 100644 index 0000000..bca783c --- /dev/null +++ b/skills/agent-resource-officer/CHANGELOG.md @@ -0,0 +1,231 @@ +# agent-resource-officer changelog + +## 0.1.46 + +- Added calibration guidance for long-running external-agent sessions with `校准影视技能`. +- Tightened title-resource routing so `下载` stays on MP/PT, `转存` defaults to 115, and explicit Quark transfer remains opt-in. +- Documented Cookie refresh and repair flows for Quark and HDHive browser-cookie recovery. + +## 0.1.42 + +- Added `quark-cookie-refresh` to run the local Quark browser-cookie export tool, write the full webpage cookie back into MoviePilot/AgentResourceOfficer, and restart `moviepilot-v2`. +- Added `quark-transfer-repair` to refresh the Quark webpage cookie and optionally retry one failed Quark transfer command after MoviePilot comes back. +- Documented the fixed natural-language intents `刷新夸克Cookie` and `修复夸克转存`, plus the narrower auto-repair trigger guidance for explicit Quark login-state failures only. + +## 0.1.41 + +- Added `hdhive-cookie-refresh` to run the local HDHive browser-cookie export tool, write the full webpage cookie back into MoviePilot/AgentResourceOfficer, and restart `moviepilot-v2`. +- Added `hdhive-checkin-repair` to refresh the HDHive webpage cookie and immediately retry one `影巢签到`. +- Documented the fixed natural-language intents `刷新影巢Cookie` and `修复影巢签到`, plus the auto-repair guidance for HDHive sign-in cookie failures. +- Sanitized helper output so browser-export diagnostics no longer echo raw cookie/token material back to the caller. + +## 0.1.40 + +- Added `deprecated_aliases` to the helper-facing `external-agent` payload. +- Marked `workbuddy` as deprecated in the helper command catalog while keeping it as a compatibility alias. +- Synced README / EXTERNAL_AGENTS wording so human-facing docs match the new alias semantics. + +## 0.1.39 + +- Added `entry_playbooks` to the helper-facing external-agent payload and request-template summaries so agents can read ready-to-run helper commands, HTTP endpoints, Tool names, and recommended fields from one place. +- Tightened helper selftest coverage for the new playbook metadata and missing-detail request-template summaries. + +## 0.1.38 + +- Added orchestration metadata to the helper and template summaries, including service/client roles, entry patterns, and the preferred startup -> decide -> route -> followup flow. +- Clarified that external agents, MP built-in agents, and the Feishu channel all reuse the same assistant protocol instead of maintaining separate state machines. + +## 0.1.35 + +- Added `execution_loop_contract` to the external-agent payload so a new external agent can bootstrap itself from a structured startup -> decide -> route -> policy -> followup loop. +- Documented the minimal external-agent execution loop in the Skill and external-agent guides. + +## 0.1.34 + +- Added `execution_policy_contract` to the external-agent payload so external agents can consume the helper's execution behavior classes without reading repository docs first. +- Documented the five recommended behavior branches (`auto_continue`, `auto_continue_then_wait_confirmation`, `wait_user_confirmation`, `show_only`, `stop`) across the Skill and external-agent guides. + +## 0.1.33 + +- Extended the helper-owned execution policy summary to legacy helper summaries such as `decide`, `auto`, `doctor`, and `recover`, so external agents no longer need two different parsing paths. +- Added selftest coverage for legacy helper summary auto-continue and wait-confirmation behavior. + +## 0.1.32 + +- Added a helper-owned command execution policy summary for `--summary-only`, including `recommended_agent_behavior`, `auto_run_command`, `confirm_command`, `display_command`, `stop_after_auto`, and `reason`. +- Added `auto_continue_rule` to the external-agent payload so other agents can decide when to auto-run the preferred command and when to stop for confirmation. +- Added selftest coverage for the new auto-continue decision layer. + +## 0.1.31 + +- Preserved compact command execution semantics in helper summaries: `command_policy`, `preferred_requires_confirmation`, `fallback_requires_confirmation`, and `can_auto_run_preferred`. +- Taught `summary_command()` to use those confirmation flags instead of only falling back to the old helper inspect/execute flow. +- Added selftest coverage for top-level command confirmation behavior. + +## 0.1.30 + +- Added top-level compact command extraction for `route`, `pick`, `workflow`, `plan-execute`, and `followup`. +- Added `--summary-only` / `--command-only` support for those commands, so external agents can ask the helper for just the next command. +- Added `next_command_rule` to the external-agent payload and documented the top-level `preferred_command` / `compact_commands` contract. + +## 0.1.29 + +- Added `preferences_recipe_command` to the external-agent payload. +- Taught recipe command generation that `preferences_onboarding` maps to the direct `preferences` helper command. +- Added selftest coverage for the preferences onboarding recipe and payload handoff. + +## 0.1.28 + +- Added `local_ingest_recipe_command` to the external-agent payload. +- Taught recipe command generation that `mp_ingest_status` and `mp_local_diagnose` map to direct workflow helper commands. +- Preserved `diagnosis_summary` in compact helper output so local/PT ingest diagnostics remain structured for external agents. + +## 0.1.27 + +- Added `post_execute_recipe_command` to the external-agent payload. +- Taught recipe command generation that `execution_followup` maps to `python3 scripts/aro_request.py followup`. +- Documented the `templates --recipe followup` low-token entry for post-execution tracking. + +## 0.1.26 + +- Added `followup`, a direct helper command for the plugin-owned `query_execution_followup` action. +- Added positional `plan-xxx` support for `followup`, so `python3 scripts/aro_request.py followup plan-xxx` works without `--plan-id`. +- Added `followup_command` to the external-agent handoff payload, so other agents can continue after `plan-execute` without guessing the next raw action. + +## 0.1.25 + +- Preserved `follow_up_hint` in compact helper output, so `plan-execute` and related commands no longer drop the plugin's next-step hint. +- Added a selftest case for `follow_up_hint` passthrough. + +## 0.1.24 + +- Added positional argument support for `workflow`, so `python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠` works without `--workflow` and `--keyword`. +- Added positional session support for `session`, `session-clear`, and `history`, so `python3 scripts/aro_request.py session default` and `python3 scripts/aro_request.py history agent:demo` work without `--session`. +- Added positional `plan-xxx` support for `plans` and `plans-clear`. + +## 0.1.23 + +- Added positional argument support for `pick`, so `python3 scripts/aro_request.py pick 1` and `python3 scripts/aro_request.py pick 1 详情` work without `--choice` or `--action`. +- Added positional plan support for `plan-execute`, so `python3 scripts/aro_request.py plan-execute plan-xxx` works without `--plan-id`. +- Updated the external-agent handoff payload to prefer the shorter positional `pick` command. + +## 0.1.22 + +- Added positional text support for `route`, so `python3 scripts/aro_request.py route "盘搜搜索 大君夫人"` works without `--text`. +- Kept the old `--text` form for compatibility. + +## 0.1.21 + +- Added direct `--command-only` helper output for the `mp_pt` and `recommend` recipes. +- Changed recipe command selection to execute the first safe read step directly even when later recipe steps require confirmation. + +## 0.1.20 + +- Added `mp_pt_recipe_command` and `mp_recommend_recipe_command` to the external-agent handoff payload. +- Documented `mp_pt` and `recommend` request-template recipes for MP native PT and recommendation flows. + +## 0.1.19 + +- Added workflow helper flags for MP download task management: `--status`, `--hash`, `--target`, `--control`, `--downloader`, and `--delete-files`. +- Added examples for querying, pausing, resuming, and deleting MP download tasks through AgentResourceOfficer. + +## 0.1.18 + +- Added `--mode` to the workflow helper so `mp_recommend_search` can continue a recommended title into MP, HDHive, or PanSou. +- Documented the recommendation-to-search chain for external agents. + +## 0.1.17 + +- Changed HDHive search helper default to `media_type=auto`, so uncertain titles are not filtered as movies before TV candidates can be found. + +## 0.1.16 + +- Added `scoring-policy` helper command so external agents can explain plugin-owned scoring rules without re-scoring. + +## 0.1.15 + +- Documented compact `score_summary` for choosing scored cloud/PT results without parsing long messages. + +## 0.1.14 + +- Added compact `preference_status` to assistant responses so external agents can detect onboarding without a separate verbose call. + +## 0.1.13 + +- Added `preferences` helper command to read, save, or reset source preferences for external agents. +- Documented cloud/PT source-specific scoring and MP native search/download/subscribe/recommend workflows. +- Updated the external-agent handoff prompt to check preferences before automated resource tasks. +- Changed `workflow` helper behavior so read-only workflows execute directly while write workflows still generate a dry-run plan by default. + +## 0.1.12 + +- Added `external-agent` helper command to print a compact external-agent prompt and minimal tool contract. +- Added `external-agent --full` to print the bundled external-agent handoff guide directly from the Skill package. +- Kept `workbuddy` as a compatibility alias for existing setups. + +## 0.1.11 + +- Compact output now preserves Feishu migration fields such as `ready_to_start`, `safe_to_enable`, `missing_requirements`, and `migration_hint`. + +## 0.1.10 + +- Compact output now preserves service health fields, warnings, defaults, and Quark/P115 readiness fields for lower-token diagnostics. + +## 0.1.9 + +- Added `session-clear` and `sessions-clear` helper commands so agents can clear abandoned assistant sessions and pending 115 recovery state. + +## 0.1.8 + +- Added `--compact` as a compatibility no-op because compact output is already the default. + +## 0.1.7 + +- Compact `feishu-health` output now preserves key status fields such as `plugin_version`, `running`, `legacy_bridge_running`, and `conflict_warning`. + +## 0.1.6 + +- Added `feishu-health` for checking the built-in AgentResourceOfficer Feishu Channel status. +- Documented the matching MoviePilot Agent Tool `agent_resource_officer_feishu_health`. + +## 0.1.5 + +- Expanded local `selftest` coverage for maintain command generation and request-template summary parsing. + +## 0.1.4 + +- `maintain` preview now sends a clean GET dry-run request without `execute=true`. + +## 0.1.3 + +- Bumped helper script to `0.1.3`. +- Added `plans-clear` for exact saved-plan cleanup and bulk cleanup filters. + +## 0.1.2 + +- Bumped helper script to `0.1.2`. +- Added `--plan-id` support for exact `plans` inspection and `plan-execute`. +- Recovery helper commands now preserve `plan_id` when the plugin recommends executing a saved plan. +- Compact helper output now preserves `plan_id` and `execute_plan_body` from dry-run workflow responses. + +## 0.1.1 + +- Bumped helper script to `0.1.1`. +- Completed the `commands` catalog so every helper subcommand is represented. +- Marked `workflow` as a dry-run plan write in the command catalog. +- Added `version` command and `helper_version` in command catalog/readiness output. + +## 0.1.0 + +- Added `install.sh` with dry-run and custom target support for installing the skill into configurable skill paths. +- Added installer target guards to prevent accidental overwrites of unsafe or non-skill directories. +- Added `commands` catalog with stable `commands.v1` schema. +- Added `readiness` for config, local selftest, and live plugin selfcheck. +- Added `config-check` without printing secrets or expanded local paths. +- Added `selftest` for helper command-generation logic. +- Added low-token decision helpers: + - `decide --summary-only` + - `doctor --summary-only` + - `auto --summary-only` + - `recover --summary-only` +- Added `--command-only` and `--confirmed` for safer machine execution. diff --git a/skills/agent-resource-officer/EXTERNAL_AGENTS.md b/skills/agent-resource-officer/EXTERNAL_AGENTS.md new file mode 100644 index 0000000..0d161fd --- /dev/null +++ b/skills/agent-resource-officer/EXTERNAL_AGENTS.md @@ -0,0 +1,192 @@ +# 外部智能体接入 Agent影视助手 + +当前插件版本:`Agent影视助手 0.2.68` + +当前 helper 版本:`agent-resource-officer 0.1.46` + +让 `OpenClaw`、`Hermes`、`WorkBuddy` 或其他外部智能体,也能稳定调用 MoviePilot 的搜片、转存、下载、签到和修复能力。 + +核心思路很简单:外部智能体负责理解你说的话、调用 `Agent影视助手`、展示结果;真正的资源搜索、转存、下载和账号操作,都交给 MoviePilot 里的插件执行。 + +--- + +## 一步接入 + +把下面这段直接发给你的外部智能体: + +```text +请从这个仓库创建并使用 agent-resource-officer Skill: +https://github.com/liuyuexi1987/MoviePilot-Plugins + +创建后请依次读取: +1. skills/agent-resource-officer/SKILL.md +2. skills/agent-resource-officer/EXTERNAL_AGENTS.md +3. docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md + +连接配置: +ARO_BASE_URL=http://MoviePilot地址:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN + +如果你的客户端支持 MoviePilot 官方 MCP,也请同时接入: +MCP 地址:http://MoviePilot地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN + +分工规则: +1. 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询,可以优先用 MCP。 +2. 云盘搜索、盘搜、影巢、转存、夸克转存、115转存、下载、更新检查、编号选择、翻页、详情、Cookie 修复,继续优先用 agent-resource-officer skill / helper。 +3. 只有当前会话真的加载出 mcp__moviepilot__* 工具,才算 MCP 已接通;没接通时不要假装在用 MCP。 + +请把配置写入 ~/.config/agent-resource-officer/config。 +然后运行 readiness 验证连接,成功后按文档规则接入。 +``` + +`ARO_API_KEY` 在 MoviePilot 管理后台的系统设置 / 安全设置里找。 + +--- + +## 连接地址怎么填 + +先判断 MoviePilot 和智能体是不是在同一台机器。 + +### 同机部署 + +如果 MoviePilot 和智能体在同一台电脑或同一个容器网络里,可以这样填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +这也是最简单的情况。 + +### 跨机器部署 + +如果 MoviePilot 在 NAS,智能体在 Win / Mac 电脑上,`ARO_BASE_URL` 必须填 NAS 的实际地址: + +```bash +ARO_BASE_URL=http://192.168.1.100:3000 +ARO_API_KEY=你的 MoviePilot API_TOKEN +``` + +不要填: + +```bash +ARO_BASE_URL=http://127.0.0.1:3000 +``` + +这里的 `127.0.0.1` 只代表智能体自己这台机器,不是 NAS。 + +如果你有多套 MoviePilot,要特别注意: + +- `ARO_BASE_URL` 指向哪套 MoviePilot,`下载 / MP搜索 / PT搜索 / 转存` 就使用哪套 MoviePilot。 +- 如果当前 MoviePilot 只用于网盘或 STRM,不要在这套实例里确认 PT 下载。 +- 如果 MoviePilot 和 qBittorrent 不在一台机器,可在 Agent影视助手设置里填写 `PT 下载保存路径`,路径要按目标 NAS / qB 的真实下载目录填写。 + +跨机器部署详细说明见 [AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md](../../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md)。 + +--- + +## 手动添加 MCP + +有些智能体不会自动读取或启用 MoviePilot MCP,需要你在智能体的 MCP 设置里手动添加。 + +填写: + +```text +MCP 地址:http://你的MP地址:3000/api/v1/mcp +认证头:X-API-KEY=你的 MoviePilot API_TOKEN +``` + +如果 MoviePilot 在 NAS,地址要写 NAS 的实际地址: + +```text +MCP 地址:http://你的NAS地址:3000/api/v1/mcp +``` + +添加后,需要在智能体里确认 MCP 已启用,并且当前会话能看到类似 `mcp__moviepilot__*` 的工具。 + +如果看不到这些工具,就说明 MCP 没有真正加载成功。此时不要让智能体假装在用 MCP,资源流继续走 `agent-resource-officer skill / helper`。 + +--- + +## 怎么用 + +接入完成后,直接对智能体说: + +| 命令 | 作用 | +|---|---| +| `搜索 蜘蛛侠` | 搜索云盘资源,默认走盘搜 | +| `云盘搜索 蜘蛛侠` | 盘搜 + 影巢一起搜 | +| `MP搜索 蜘蛛侠` / `PT搜索 蜘蛛侠` | 走 MoviePilot 原生 PT 搜索 | +| `转存 蜘蛛侠` | 默认等同 `115转存 蜘蛛侠` | +| `115转存 蜘蛛侠` | 搜索后转存到 115 | +| `夸克转存 蜘蛛侠` | 搜索后转存到夸克 | +| `下载 蜘蛛侠` | 搜索并生成 PT 下载计划 | +| `更新检查 蜘蛛侠` | 检查是否有新资源 | +| `115登录` | 扫码登录 115 | +| `影巢签到` | 执行影巢签到 | + +完整命令列表见 [ALL_COMMANDS.md](../../docs/ALL_COMMANDS.md)。 + +--- + +## MCP 要不要接 + +MoviePilot 官方 MCP 可以接,但它和 `agent-resource-officer skill / helper` 的定位不同。 + +推荐这样分工: + +| 场景 | 推荐入口 | +|---|---| +| 插件列表、下载器状态、站点状态、历史记录、工作流、调度器等 MoviePilot 管理查询 | 官方 MCP | +| 盘搜、影巢、云盘搜索、115/夸克转存、编号选择、翻页、详情、Cookie 修复 | `agent-resource-officer skill / helper` | +| `MP搜索 / PT搜索 / 下载 / 更新检查` 这类片名资源流 | 优先 `agent-resource-officer skill / helper` | + +MCP 地址通常是: + +```text +http://你的MP地址:3000/api/v1/mcp +``` + +认证头: + +```text +X-API-KEY=你的 MoviePilot API_TOKEN +``` + +注意:只有当前智能体客户端真的加载出了 `mcp__moviepilot__*` 工具,才算 MCP 已接通。没有接通时,不要让智能体假装在用 MCP;资源流继续走 `agent-resource-officer`。 + +--- + +## 给智能体看的执行规则 + +这部分规则已经写在 `agent-resource-officer` Skill 里,普通用户不用背。 + +接入时只要让外部智能体读取本仓库里的 Skill,它就会知道哪些命令必须走 `route / pick`、哪些动作需要确认、哪些结果不能重排编号。 + +--- + +## 长线程维护 + +微信、飞书、WorkBuddy、Claw 这类长线程用久后,可能会出现: + +- `15详情` 被误解成 `选择 15` +- 编号续接到旧搜索结果 +- 一直套用旧格式或旧规则 + +这时直接对智能体说: + +```text +校准影视技能 +``` + +这条命令会让智能体重新加载影视助手的关键规则。不要在普通 `搜索 / 更新检查 / 检查` 前主动清会话,否则会破坏正常编号续接。 + +--- + +## 相关文档 + +- [全部命令一览](../../docs/ALL_COMMANDS.md) +- [跨机器部署](../../docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md) +- [Skill 说明](./SKILL.md) +- [外部智能体详细规范](./EXTERNAL_AGENTS.md) diff --git a/skills/agent-resource-officer/PROMPTS.md b/skills/agent-resource-officer/PROMPTS.md new file mode 100644 index 0000000..cb3107a --- /dev/null +++ b/skills/agent-resource-officer/PROMPTS.md @@ -0,0 +1,352 @@ +# AgentResourceOfficer Prompt Examples + +## Startup + +```text +使用 agent-resource-officer skill,先调用 auto,自动读取 startup 和推荐 request_templates。 +``` + +## MCP Guardrail + +```text +如果当前客户端没有明确接入 MoviePilot 官方 MCP,或者当前会话里看不到 MoviePilot MCP 工具,就不要声称自己在用 MCP。此时继续使用 agent-resource-officer skill/helper。MCP 只优先用于插件列表、下载器状态、站点状态、历史记录这类管理查询;片名资源流不走 MCP 优先。资源工作流、编号选择、分页、Cookie 修复仍优先走 agent-resource-officer。 +``` + +```text +如果当前会话已经存在对应的 mcp__moviepilot__* 工具,而用户要查的是 MoviePilot 管理信息(例如插件列表、下载器状态、站点状态、下载历史、入库历史、工作流、调度器),就直接调用 MCP,不要先绕回 agent-resource-officer raw GET,也不要先试 curl。 +``` + +```text +如果用户命令明显是资源流,例如“云盘搜索/盘搜/影巢/MP搜索/PT搜索/转存/夸克转存/115转存/下载/更新/更新检查/检查/选择/详情/n/下一页/编号”,不要先调用 MCP、tool_search、curl 或 raw API 探测,直接使用 agent-resource-officer helper 的 route/pick。 +``` + +```text +如果用户明确说“MP搜索 / MP 搜索 / PT搜索 / PT 搜索 + 片名”,必须原样调用 route,不要改写成“搜索/盘搜搜索/云盘搜索/智能搜索”。这是用户指定 MoviePilot 原生/PT 搜索,不是普通盘搜。 +``` + +```text +如果当前客户端没有 MoviePilot MCP 工具,处理“MP搜索 / MP 搜索 / PT搜索 / PT 搜索”时不要输出“MCP 不可用所以改走盘搜”。正确做法是直接执行 agent-resource-officer helper:route "<用户原话>",并原样转发返回结果。 +``` + +```text +处理“MP 搜索 罪无可逃”这类片名资源命令时,第一步只能是运行:python3 scripts/aro_request.py route "MP 搜索 罪无可逃" --session 。不要先调用 search_media、search_torrents、TMDB、MoviePilot raw API 或 MCP。 +``` + +## Low Token Auto + +```text +使用 agent-resource-officer skill,先调用 auto --summary-only,只返回自动启动流的最小决策摘要。 +``` + +## Decide + +```text +使用 agent-resource-officer skill,先调用 decide --summary-only,告诉我现在应该继续旧会话还是按推荐 recipe 开始新流程。 +``` + +## Command Only Decide + +```text +使用 agent-resource-officer skill,调用 decide --command-only,只返回下一步 helper 命令。 +``` + +## Confirmed Command + +```text +使用 agent-resource-officer skill,在我已经确认执行后,调用 decide --command-only --confirmed,只返回执行用 helper 命令。 +``` + +## Manual Startup + +```text +使用 agent-resource-officer skill,先调用 startup,读取 recommended_request_templates,然后按推荐 recipe 获取低 token 请求模板。 +``` + +## PanSou Search + +```text +使用 agent-resource-officer skill,盘搜搜索“大君夫人”,分别展示 115 和夸克结果,让我选择编号。不要直接转存,等我确认编号。 +``` + +## Generic Search + +```text +使用 agent-resource-officer skill,搜索“大君夫人”。注意:普通“搜索/找片”默认先走盘搜,不要先脑补成影巢候选或 MP 搜索。 +``` + +```text +使用 agent-resource-officer skill,执行普通“搜索/找片”后,优先原样转述 route 返回的条目列表。不要把结果二次改写成“资源状态 / 推荐清单 / 要现在下载吗”这类摘要;不要额外补“费用、评分、推荐星级”表述。只保留资源官已经给出的编号、网盘、来源、提取码、摘要、链接和下一步提示。 +``` + +## Cloud Search + +```text +使用 agent-resource-officer skill,云盘搜索“蜘蛛侠”。这条入口只比较盘搜和影巢,不进入 MP/PT。 +``` + +```text +使用 agent-resource-officer skill,执行“云盘搜索 大君夫人”时,必须直接调用 route "云盘搜索 大君夫人"。不要偷换成“盘搜搜索 大君夫人”,不要先自己做网页搜索,也不要先把结果加工成“推荐资源/分析结论”。优先原样展示插件返回的 `盘搜结果` 和 `影巢结果` 两段、原始编号、原始链接和下一步提示。 +``` + +```text +使用 agent-resource-officer skill,执行“云盘搜索 大君夫人”后,不要把返回改写成自己的表格摘要,不要把 115 和夸克各自重新从 1 开始编号,也不要只摘“亮点”。如果插件返回了全局编号和 `影巢结果` 段落,就原样保留;如果插件提示“影巢候选未自动展开”,也原样保留这句。 +``` + +```text +使用 agent-resource-officer skill,夸克转存失败后,如果插件只返回“夸克转存失败:无法转存到 /飞书”,不要自行追加“默认转存目录不存在”“换 path=/ 试试”这类路径猜测。除非插件明确指出路径问题,否则只说明“原因未明,先不要擅自推断路径问题”。 +``` + +## Update Check + +```text +使用 agent-resource-officer skill,更新检查“大君夫人”。这条入口必须先直接调用 route "更新检查 大君夫人",不要先清空会话,不要先网页搜索,不要先走影巢候选。先把TMDB 参考进度、盘搜最新集资源、影巢最新集资源原样展示给我,再让我自己选编号。 +更新检查返回后必须原样保留插件 message 的 emoji 分区和条目行,例如 `🟨 盘搜结果`、`🟦 影巢结果`、`🗄 #25 夸克`、`📺 #1 115`、`🕒05/02`、`📌 E01-E09`。不要改写成 `#: ... 来源: ... 详情: ... 日期: ...` 这种字段表,也不要把条目压缩成总结。 +``` + +```text +使用 agent-resource-officer skill,刷新影巢Cookie。不要 route 这句话,直接运行 hdhive-cookie-refresh,把本机浏览器里的 hdhive.com 完整 Cookie 写回 MoviePilot 和 AgentResourceOfficer。 +``` + +```text +使用 agent-resource-officer skill,修复影巢签到。不要 route 这句话,直接运行 hdhive-checkin-repair:先从本机浏览器刷新影巢 Cookie,再自动重试一次影巢签到,并把最终结果回给我。 +``` + +```text +使用 agent-resource-officer skill,刷新夸克Cookie。不要 route 这句话,直接运行 quark-cookie-refresh,把本机浏览器里的 pan.quark.cn 完整 Cookie 写回 MoviePilot 和 AgentResourceOfficer。 +``` + +```text +使用 agent-resource-officer skill,修复夸克转存。如果刚才的失败明确是登录态问题,直接运行 quark-transfer-repair;如果你还保留着刚才失败的原始命令(例如“选择 7”或“夸克转存 21世纪大君夫人”),优先运行 quark-transfer-repair --retry-text "<原命令>",刷新完 Cookie 后自动再试一次。 +``` + +```text +使用 agent-resource-officer skill,执行“检查 大君夫人”时,把它当成“更新检查 大君夫人”的简写。不要把它当成普通搜索,也不要走影巢候选。 +``` + +```text +使用 agent-resource-officer skill,执行“更新 大君夫人”时,先把它等价成“更新检查 大君夫人”。如果返回里已经列出盘搜/影巢最新集资源,不要再改写成“你要更新的是不是选项1”。只有在更新检查明确要求继续 PT 搜索时,才提示我是否执行 PT搜索。 +``` + +```text +使用 agent-resource-officer skill,处理普通“搜索/找片/更新检查”时,不要先调用 `session-clear default`。只有用户明确要求“清空会话/重置会话”时,才允许先清会话。 +``` + +```text +使用 agent-resource-officer skill,如果“影巢签到”或“影巢签到日志”明确提示网页登录态失效、Cookie 失效、require login 或自动登录拿不到有效 Cookie,先提醒我确认已在 Edge 登录 https://hdhive.com,然后自动执行 hdhive-checkin-repair,再把新的签到结果发给我。不要先让我手工复制 Cookie。 +``` + +```text +使用 agent-resource-officer skill,如果夸克转存失败里明确出现“require login [guest]”“夸克登录态已过期”“当前夸克登录态不足”,先提醒我确认已在 Edge 登录 https://pan.quark.cn,然后自动执行 quark-transfer-repair;如果能拿到刚才失败的原始转存命令,就带上 --retry-text 直接重试一次。不要对 41031、分享受限、分享者封禁这类错误误触发 Cookie 修复。 +``` + +```text +使用 agent-resource-officer skill,执行“清空夸克默认转存目录”或“清空夸克默认目录”时,直接原样透传给 route。不要改写成 115 清理,不要先做搜索,不要先做更新检查。 +不要先 grep `aro_request.py` 或自己判断 helper 是否“内置支持”这个命令;这类清空命令本来就是通过 `route "<原话>"` 进入插件路由,不是 helper 的独立子命令。 +``` + +```text +使用 agent-resource-officer skill,执行“清空115转存目录”“清空115默认转存目录”或“清空115默认目录”时,直接原样透传给 route。不要改写成夸克清理,不要先做搜索,不要先做更新检查。 +不要先 grep `aro_request.py` 或自己判断 helper 是否“内置支持”这个命令;这类清空命令本来就是通过 `route "<原话>"` 进入插件路由,不是 helper 的独立子命令。 +``` + +```text +使用 agent-resource-officer skill,返回盘搜/影巢/更新检查的资源列表时,必须保留插件原始编号。不要把 `7/8/9/14` 这类编号改写成无编号段落,也不要只在总结里提编号。用户后续需要直接回复编号执行。 +``` + +```text +使用 agent-resource-officer skill,普通 route/pick 命令默认已经输出适合聊天展示的纯文本 message。请优先原样转发这段输出,不要重新解析后再自己生成资源列表。只有需要读取结构化字段时,才给命令加 --json-output。 +``` + +```text +使用 agent-resource-officer skill,展示盘搜结果时,保留“🟦 115 结果 / 🟨 夸克结果”分组标题,但每条资源不要再写 `[115]` 或 `[quark]`。条目格式用“编号. 📺 标题”或“编号. 🗄 标题”,日期保留时钟标记,例如“— 🕒05/07”或插件返回的 display_datetime。如果当前聊天前端会把换行折叠成一段,请把资源列表放进 text 代码块,或至少在分组标题后保留一个空行,确保夸克结果逐条换行显示。 +``` + +```text +使用 agent-resource-officer skill,搜索结果列表不要展示 115/夸克原始分享链接。链接只在“选择 编号 详情”的复制友好详情卡片里展示。 +``` + +```text +使用 agent-resource-officer skill,用户说“15详情”“15 的详情”“我要看看 15 的详情”“看十六详情”“详情十六”这类话时,必须当成继续当前编号会话并查看详情,不要执行转存/下载。优先原样调用 route/pick;如果你需要改写命令,只能改写成“选择 15 详情”这一类保留“详情”的命令,绝对不要改成“选择 15”或单独数字。 +``` + +```text +使用 agent-resource-officer skill,用户说“下载 蜘蛛侠”“转存 蜘蛛侠”“夸克转存 蜘蛛侠”“115转存 蜘蛛侠”这类写入命令时,必须保留原话交给 route。插件会先做 MP/TMDB 影片确认;其中“下载”只走 MP/PT,先展示 PT 资源列表,不要自动提交下载;“转存”默认等同“115转存”,只有明确说“夸克转存”才走夸克。如果有多个影片候选,先让用户选影片,选完后再用正确片名和年份继续 PT / 盘搜 / 影巢搜索。不要把这些命令改写成“智能执行 蜘蛛侠”,也不要跳过影片确认。 +``` + +```text +使用 agent-resource-officer skill,如果用户有多套 MoviePilot,`ARO_BASE_URL` 指向哪一套,资源命令就会发给哪一套。`下载` / `MP搜索` / `PT搜索` 使用目标 MoviePilot 里配置的下载器;本机 Mac/Win MoviePilot 也可能远程控制 NAS qBittorrent。若当前连接的是网盘/STRM 专用 MoviePilot,或它的 `/待整理` 只是云盘整理目录,不要在这套实例里确认 PT 下载,应先让用户把 `ARO_BASE_URL` 切到 NAS 上负责正常下载的 MoviePilot。 +``` + +```text +使用 agent-resource-officer skill,如果 Agent影视助手插件设置了 `mp_download_save_path`,PT 下载会显式使用这个 MoviePilot `save_path`。不要在聊天里临时猜路径;这个值必须按目标 MoviePilot/NAS 的真实存储映射配置,例如有效的 `local:/...` 或其他 MoviePilot 支持的存储前缀。 +``` + +```text +使用 agent-resource-officer skill,执行“下载 片名”返回 PT 资源列表时,必须原样展示插件 message 里的完整编号列表、做种、体积、评分、建议和下一步提示。不要把结果压缩成“PT资源已列出,回编号选详情或下载”这类一句话摘要。 +``` + +```text +使用 agent-resource-officer skill,在 PT 结果列表里,“1”或“下载1”表示给第 1 条生成下载计划;“1详情”才是查看详情。只有插件已经返回“PT 下载计划已生成”之后,用户再回复裸编号“1”或“执行计划”才是确认执行。不要把“下载1”当成旧计划确认。 +``` + +```text +使用 agent-resource-officer skill,用户说“校准影视技能”时,运行 python3 scripts/aro_request.py calibrate 或 route "校准影视技能",把返回的硬规则应用到当前会话,然后只回复“影视技能已校准。”。这个命令用于长线程、微信线程或会话压缩后重新校准资源流语义,避免把“下载”改成云盘转存、把“详情”改成执行。 +``` + +```text +使用 agent-resource-officer skill,展示影巢资源结果时,也按“🟦 115 结果 / 🟨 夸克结果”分组展示。每条资源用纯数字编号,格式类似“1. 📺/🗄 标题 · 免费/积分 · 大小 · 集数 · 规格”,不要写成“#1”。在 WorkBuddy 这类 Markdown 前端,每条资源之间保留一个空行,避免压成一个长段落。可以在列表后追加“智能建议”,但必须引用原编号,不能替代列表;需要复制链接或完整信息时,引导使用“选择 编号 详情”。 +``` + +```text +使用 agent-resource-officer skill,资源列表后可以保留或追加“智能建议”。顺序必须是:先完整展示原始编号列表,再单独写“智能建议:”。智能建议不限制长短,可以自然分析取舍,但必须引用原编号;不要用建议替代列表,不要重新编号。建议口吻要像真人帮用户挑资源,重点讲画质、集数完整度、字幕、体积、来源可靠性、115/夸克明确偏好;不要把评分公式或“4K +25”这类加分项当成主要理由。 +``` + +```text +使用 agent-resource-officer skill,如果我说“把刚才那个 22 转存”“原来的 #22”“下载 10”“选择 14”这类话,先把它当成继续上一轮编号会话,不要先重新搜索。优先复用当前 session,或用 decide / sessions / session 恢复最近一轮匹配会话,然后直接 pick 对应编号。只有会话真的不存在时,才允许重搜,并明确告诉我编号已经重建。 +``` + +```text +使用 agent-resource-officer skill,如果“影巢搜索”因为暂无结果而自动补查盘搜,或“云盘搜索”里的影巢段没有展开,优先原样展示插件返回。不要把它改写成只剩“有新集了”“现在两边都有了”“最高分如下”这类摘要;必须保留原始编号、原始链接和下一步提示。可以在列表后追加智能建议,建议不限制长短。 +``` + +## PT Search + +```text +使用 agent-resource-officer skill,PT搜索“蜘蛛侠”。这条入口等同于 MP搜索,走 MoviePilot 原生 PT 搜索和评分。 +如果插件先返回 MP/TMDB 候选列表,不要替用户默认选第一项;把候选列表展示出来,让我回复编号后再继续搜索 PT 资源。 +展示 PT 结果时必须原样保留插件返回的 message,不要重新压缩成自己的列表,不要改写英文发布标题;插件标题里有防止微信误识别链接的隐藏断点,也有为手机微信阅读准备的 emoji 标记,必须保留。 +``` + +## HDHive Search + +```text +使用 agent-resource-officer skill,影巢搜索“蜘蛛侠”。如果有多个候选影片,先让我选择影片;再展示资源列表。 +``` + +## Direct Share Link + +```text +使用 agent-resource-officer skill,处理这个分享链接并转存到默认目录:https://pan.quark.cn/s/xxxx +``` + +## Custom Path + +```text +使用 agent-resource-officer skill,把这个夸克链接转存到 /飞书:链接 https://pan.quark.cn/s/xxxx path=/飞书 +``` + +## Continue Choice + +```text +使用 agent-resource-officer skill,继续当前会话,选择 1。如果返回 confirmation_message,先给我确认提示。 +``` + +## Health Check + +```text +使用 agent-resource-officer skill,执行 selfcheck,确认 AgentResourceOfficer 协议和请求模板都正常。 +``` + +## Local Selftest + +```text +使用 agent-resource-officer skill,先运行 selftest,验证本地 helper 的命令生成逻辑。 +``` + +## Command Catalog + +```text +使用 agent-resource-officer skill,运行 commands,查看 helper 支持的命令、联网需求和写入风险。 +``` + +## Helper Version + +```text +使用 agent-resource-officer skill,运行 version,确认当前 helper 版本。 +``` + +## Config Check + +```text +使用 agent-resource-officer skill,运行 config-check,确认连接配置存在,但不要输出 API Key。 +``` + +## External Agent + +```text +你是 MoviePilot Agent影视助手的外部智能体入口。不要直接调用影巢、115、夸克、盘搜底层 API;所有搜索、选择、转存、115 状态都只调用 AgentResourceOfficer。每个用户或群聊固定使用 session=agent:会话ID。新会话先 startup;用户发搜索/链接时调用 route;用户发选择/详情/下一页时调用 pick。不要输出 API Key、Cookie、Token。 +展示资源列表时,不要压缩掉关键字段:网盘、解锁分、大小、清晰度、来源、集数/更新信息、字幕、详情摘要都要尽量保留。 +用户只说“搜索/找 某片”时,先原样透传给 route,不要擅自续跑旧 session,也不要先脑补成影巢候选选择。默认搜索应先走盘搜;只有用户明确说“影巢搜索”才进影巢,明确说“MP搜索/PT搜索”才进 MP/PT。 +用户明确说“MP搜索 / MP 搜索 / PT搜索 / PT 搜索 某片”时,必须保持原命令进入 route,不要改写为“搜索/盘搜搜索/云盘搜索/智能搜索”。 +只有用户明确说“智能搜索 / 资源决策 / 智能决策”时,才进入跨来源智能决策;不要把普通明确来源命令自动升级成智能决策。 +用户只说“更新/查更新 某片”时,先原样透传给 route,并优先走“更新检查 某片”;不要先清空会话,不要先影巢候选,不要先网页搜索。更新检查的职责是直接列出TMDB 参考进度、盘搜最新集资源、影巢最新集资源,让用户自己判断和选择编号。 +``` + +## External Agent Helper + +```text +使用 agent-resource-officer skill,运行 external-agent,输出可交给 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体的紧凑提示词和最小工具约定。需要完整说明时运行 external-agent --full。 +``` + +## Ask Another Agent To Create Skill + +```text +请阅读 https://github.com/liuyuexi1987/MoviePilot-Plugins ,重点阅读 docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md、skills/agent-resource-officer/SKILL.md、skills/agent-resource-officer/EXTERNAL_AGENTS.md。然后在你的环境里创建或安装 agent-resource-officer Skill。Skill 里只固化通用流程、工具调用方式、session 规则和错误处理,不要写入 API Key、Cookie、Token。 + +创建的 Skill 必须写入这些硬规则: +1. 资源流命令直接走 agent-resource-officer 的 route/pick,不要先走 MCP、tool_search、curl 或 raw API。资源流包括:云盘搜索、盘搜、影巢、MP搜索、PT搜索、转存、夸克转存、115转存、下载、更新、更新检查、检查、选择、详情、n、下一页和编号续选。 +2. route/pick 默认输出就是适合聊天展示的纯文本 message,请优先原样转发,不要重新改写资源列表;只有需要程序化读取字段时才加 --json-output。 +3. 如果原始输出里有“智能建议”,必须保留;如果没有,也可以在原始列表后追加智能建议。智能建议不限制长短,但必须引用原始编号,不能替代列表、不能重新编号;建议要围绕画质、集数完整度、字幕、体积、来源可靠性、115/夸克明确偏好来写,不要把评分公式或加分项原样展示成理由。 +4. 如果用户有多套 MoviePilot,先确认 `ARO_BASE_URL` 当前指向哪一套。`下载` / `MP搜索` / `PT搜索` 的下载器和保存路径由目标 MoviePilot/qBittorrent 决定;网盘/STRM 专用实例不要用于确认 PT 下载,PT 下载应切到 NAS 上负责正常下载的 MoviePilot。 +5. 如果插件配置了 `mp_download_save_path`,它会作为 PT 下载的显式保存路径;不要自行猜测或覆盖这个路径。 + +创建后请用 external-agent 输出接入信息,并自测:用户说“盘搜搜索 大君夫人”时走 route;用户再说“选择 3”时沿用同一个 agent:会话ID 走 pick。 +``` + +## Readiness + +```text +使用 agent-resource-officer skill,运行 readiness,确认配置、本地 helper 和 MoviePilot 插件接口都可用。 +``` + +## Doctor + +```text +使用 agent-resource-officer skill,先调用 doctor,给我一个只读的启动/健康/会话/恢复总览,再决定是否继续旧会话。 +``` + +## Low Token Doctor + +```text +使用 agent-resource-officer skill,先调用 doctor --summary-only,只返回最省 token 的决策摘要和下一步命令建议。 +``` + +## Recovery + +```text +使用 agent-resource-officer skill,先调用 sessions 和 recover,告诉我当前最值得继续的会话;如果需要继续执行,先展示 confirmation_message。 +``` + +## Low Token Recovery + +```text +使用 agent-resource-officer skill,先调用 recover --summary-only,只返回恢复决策摘要和下一步命令建议。 +``` + +## Plan Audit + +```text +使用 agent-resource-officer skill,先看最近 plans 和 history,不要默认只看 default 会话;如果发现未执行计划,再告诉我是否值得继续。 +``` + +## Execute Exact Plan + +```text +使用 agent-resource-officer skill,精确执行这个 dry-run 计划:plan-execute --plan-id plan-xxx。执行前先确认这是我要的 plan_id。 +``` + +## Clear Exact Plan + +```text +使用 agent-resource-officer skill,清理这个已确认不需要的 dry-run 计划:plans-clear --plan-id plan-xxx。不要批量清理其他计划。 +``` diff --git a/skills/agent-resource-officer/README.md b/skills/agent-resource-officer/README.md new file mode 100644 index 0000000..1b57a52 --- /dev/null +++ b/skills/agent-resource-officer/README.md @@ -0,0 +1,648 @@ +# agent-resource-officer + +公开版 AgentResourceOfficer Skill 模板,用来让外部智能体通过 MoviePilot 插件接口控制盘搜、影巢、115、夸克、MP/PT 搜索、下载、更新检查、编号选择和 Cookie 修复等资源工作流。插件是服务端执行层;Skill/helper 是客户端调度层。 + +当前 helper 版本:`0.1.46` + +## 当前状态 + +- 当前插件版本:`Agent影视助手 0.2.68` +- 当前最小循环:`startup -> decide --summary-only -> route --summary-only -> followup --summary-only` +- 当前优先读取字段:`recommended_agent_behavior`、`auto_run_command`、`confirm_command`、`display_command` +- 当前 AI 失败样本只读诊断入口: + - `python3 scripts/aro_request.py route --text "失败样本 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "工作清单 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "样本洞察 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "重放样本 3" --summary-only` + - `python3 scripts/aro_request.py route --text "重放 3" --summary-only` + - `python3 scripts/aro_request.py route --text "确认" --summary-only` + - `python3 scripts/aro_request.py templates --recipe ai_reingest --compact` +- 当前最低成本入口: + - `python3 scripts/aro_request.py readiness` + - `python3 scripts/aro_request.py external-agent` + - `python3 scripts/aro_request.py decide --summary-only` + - `python3 scripts/aro_request.py route --text "智能搜索 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "资源决策 蜘蛛侠" --summary-only` + - `python3 scripts/aro_request.py route --text "资源决策 蜘蛛侠 详情" --summary-only` +- 当前搜索口径: + - `搜索 <片名>` / `找 <片名>` 默认先走盘搜 + - `云盘搜索 <片名>` 固定走盘搜 + 影巢 + - `影巢搜索 <片名>` 明确走影巢直接列表 + - `MP搜索 <片名>` / `PT搜索 <片名>` 明确走 MoviePilot 原生 PT 搜索;片名有歧义时先返回 MP/TMDB 候选,用户选编号后再搜索 PT +- `转存 <片名>` 默认等同 `115转存 <片名>`,会先做影片确认,再只从 115 资源里择优转存 +- `夸克转存 <片名>` 才会走夸克资源转存 +- `下载 <片名>` 走 MP/PT 直接下载 +- 当前更新口径: + - `更新 <片名>` / `更新检查 <片名>` / `检查 <片名>` 先走更新检查 + - 直接展示TMDB 参考进度、盘搜最新集资源、影巢最新集资源 + - 不要先清空会话,不要先改走影巢候选 + - 资源列表必须保留原始编号,方便后续直接回编号 +- 当前破坏性目录命令: + - `清空夸克默认转存目录` + - `清空夸克默认目录` + - `清空115转存目录` + - `清空115默认转存目录` + - `清空115默认目录` + - 只在用户原话明确提出时执行,不要从模糊“清理一下”里自行推断 +- 当前影巢签到修复入口: + - `python3 scripts/aro_request.py hdhive-cookie-refresh` + - `python3 scripts/aro_request.py hdhive-checkin-repair` + - 推荐做法:先确保 Edge 已登录 `https://hdhive.com`,再用上面两条命令自动写回完整 Cookie,不要手工复制 Cookie +- 当前夸克登录修复入口: + - `python3 scripts/aro_request.py quark-cookie-refresh` + - `python3 scripts/aro_request.py quark-transfer-repair` + - 推荐做法:先确保 Edge 已登录 `https://pan.quark.cn`,登录态失效时优先刷新 Cookie;只有明确是 `require login [guest]` 这类登录态问题时才自动修复 + +公开仓库: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +## 使用方式 + +1. 获取仓库: + +```bash +git clone https://github.com/liuyuexi1987/MoviePilot-Plugins.git +cd MoviePilot-Plugins +``` + +2. 把整个目录复制到自己的 Skill 搜索路径,例如: + +```text +/agent-resource-officer +``` + +也可以直接运行安装脚本: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/agent-resource-officer +``` + +3. 配置连接信息: + +```text +~/.config/agent-resource-officer/config +``` + +示例: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=your_moviepilot_api_token +ARO_HDHIVE_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/hdhive-cookie-export +ARO_QUARK_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/quark-cookie-export +``` + +`ARO_BASE_URL` 按实际部署填写:同机可以用 `http://127.0.0.1:3000`,局域网可以用 `http://你的局域网IP:3000`,公网反代可以用自己的 HTTPS 域名。 + +如果你要让 helper 直接调用本机“影巢 Cookie 导出”工具,可选配置: + +```text +ARO_HDHIVE_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/hdhive-cookie-export +ARO_HDHIVE_COOKIE_EXPORT_PYTHON=/绝对路径/python +ARO_HDHIVE_COOKIE_BROWSER=edge +ARO_HDHIVE_COOKIE_SITE_URL=https://hdhive.com +ARO_HDHIVE_COOKIE_RESTART_CONTAINER=moviepilot-v2 +ARO_QUARK_COOKIE_EXPORT_DIR=/绝对路径/MoviePilot-Plugins/tools/quark-cookie-export +ARO_QUARK_COOKIE_EXPORT_PYTHON=/绝对路径/python +ARO_QUARK_COOKIE_BROWSER=edge +ARO_QUARK_COOKIE_SITE_URL=https://pan.quark.cn +ARO_QUARK_COOKIE_RESTART_CONTAINER=moviepilot-v2 +``` + +如果你直接使用本仓库,helper 也会优先自动尝试仓库里的: + +- `tools/hdhive-cookie-export/` +- `tools/quark-cookie-export/` + +`route` 支持两种写法: + +- `python3 scripts/aro_request.py route "盘搜搜索 大君夫人"` +- `python3 scripts/aro_request.py route --text "盘搜搜索 大君夫人"` +- `python3 scripts/aro_request.py route "云盘搜索 大君夫人"` +- `python3 scripts/aro_request.py route "智能搜索 蜘蛛侠"` + +`route`、`pick`、`workflow`、`plan-execute`、`followup` 还支持: + +- `--summary-only` +- `--command-only` + +适合外部智能体只拿“下一步怎么做”的最小结果。 + +夸克默认目录清空入口: + +```bash +python3 scripts/aro_request.py route "清空夸克默认转存目录" +``` + +这条命令只针对当前配置的夸克默认转存目录,按当前层项目执行清空:当前层文件会直接删除,当前层文件夹也会一并删除(删除文件夹时会连同文件夹内内容一起清掉)。不要把它当成 115 清理,也不要从普通清理意图里自动触发,更不要先 grep helper 源码判断“支不支持”。 + +115 默认目录清空入口: + +```bash +python3 scripts/aro_request.py route "清空115转存目录" +python3 scripts/aro_request.py route "清空115默认转存目录" +``` + +这条命令只针对当前配置的 115 默认转存目录,按当前层项目执行清空:当前层文件会直接删除,当前层文件夹也会一并删除(删除文件夹时会连同文件夹内内容一起清掉)。它是显式破坏性命令,不要从普通清理意图里自动触发,也不要先 grep helper 源码判断“支不支持”。 + +`pick`、`plan-execute`、`followup` 也支持更短的位置参数写法: + +- `python3 scripts/aro_request.py pick 1` +- `python3 scripts/aro_request.py pick 1 详情` +- `python3 scripts/aro_request.py plan-execute plan-xxx` +- `python3 scripts/aro_request.py followup plan-xxx` + +影巢 Cookie 刷新与签到修复: + +```bash +python3 scripts/aro_request.py hdhive-cookie-refresh +python3 scripts/aro_request.py hdhive-checkin-repair +``` + +前者会从本机浏览器导出完整网页 Cookie 并自动写回 MoviePilot/AgentResourceOfficer;后者会在刷新 Cookie 后直接再跑一次 `影巢签到`。当 `影巢签到` 或 `影巢签到日志` 明确提示网页登录态失效时,优先使用这两条命令,不要手工复制 Cookie。 + +夸克 Cookie 刷新与转存修复: + +```bash +python3 scripts/aro_request.py quark-cookie-refresh +python3 scripts/aro_request.py quark-transfer-repair +python3 scripts/aro_request.py quark-transfer-repair --retry-text "选择 7" --session default +``` + +前者会从本机浏览器导出夸克 Cookie 并自动写回 `AgentResourceOfficer` / `QuarkShareSaver`;后者会在刷新 Cookie 后检查夸克健康状态,必要时还能顺手重试一条刚才失败的转存命令。只有明确报出 `require login [guest]`、`夸克登录态已过期` 这类登录态问题时,才建议走这条修复链;分享受限、分享者封禁等错误不要误判成 Cookie 失效。 + +`plan-execute` 返回里会保留插件给出的 `recommended_action` 和 `follow_up_hint`。如果不想自己解析下一步,也可以直接执行 `python3 scripts/aro_request.py followup --session 'agent:<会话ID>'`。 + +`workflow`、`session`、`history`、`plans` 也支持常用短写法: + +- `python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠` +- `python3 scripts/aro_request.py session agent:demo` +- `python3 scripts/aro_request.py history agent:demo` +- `python3 scripts/aro_request.py plans plan-xxx` + +4. 让外部智能体使用本 Skill。 + +## 推荐入口 + +```bash +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py doctor --limit 5 +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py feishu-health +python3 scripts/aro_request.py recover --summary-only +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py templates --recipe followup --compact +python3 scripts/aro_request.py templates --recipe ai_reingest --compact +python3 scripts/aro_request.py version +python3 scripts/aro_request.py selftest +python3 scripts/aro_request.py commands +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py config-check +python3 scripts/aro_request.py readiness +python3 scripts/aro_request.py startup +python3 scripts/aro_request.py templates --recipe bootstrap +python3 scripts/aro_request.py templates --recipe mp_pt +python3 scripts/aro_request.py templates --recipe recommend +python3 scripts/aro_request.py preferences --session agent:demo +python3 scripts/aro_request.py selfcheck +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py route "盘搜搜索 大君夫人" +python3 scripts/aro_request.py route "智能搜索 蜘蛛侠" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 详情" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 计划" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 确认" +python3 scripts/aro_request.py route "资源决策 蜘蛛侠 直接执行" +python3 scripts/aro_request.py route "失败样本 蜘蛛侠" +python3 scripts/aro_request.py route "工作清单 蜘蛛侠" +python3 scripts/aro_request.py route "样本洞察 蜘蛛侠" +python3 scripts/aro_request.py route "重放样本 3" +python3 scripts/aro_request.py route "重放 3" +python3 scripts/aro_request.py route "确认" +python3 scripts/aro_request.py route "先计划" +python3 scripts/aro_request.py route "确认执行" +python3 scripts/aro_request.py route "先看详情" +python3 scripts/aro_request.py route "计划" +python3 scripts/aro_request.py route "详情" +python3 scripts/aro_request.py route "智能计划 蜘蛛侠" +python3 scripts/aro_request.py route "智能执行 蜘蛛侠" +python3 scripts/aro_request.py route "计划最佳" +python3 scripts/aro_request.py route "执行最佳" +python3 scripts/aro_request.py pick 1 +``` + +`auto` 会先读取 `startup.recommended_request_templates`,再自动拉取推荐的低 token recipe。 + +`selftest` 不连接 MoviePilot,只验证本地 helper 的决策和命令生成逻辑。 + +`version` 会输出当前 helper 版本。 + +`commands` 会输出 helper 命令目录、是否联网、是否可能写入。`writes` 固定为布尔值,具体触发条件在 `write_condition`。 + +`external-agent` 会输出可直接交给 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体的系统提示词和最小工具约定;`external-agent --full` 会输出完整接入说明。输出中会明确给出 `compat_aliases` 和 `deprecated_aliases`。旧命令 `workbuddy` 仍保留为兼容别名,但已标记为 deprecated。 + +如果你对接的是 MP 内置智能体,优先读取 `request_templates` 和原生 Agent Tool,不要让模型自己拼底层影巢、盘搜、115、夸克接口。飞书入口同样复用 `route / pick / followup`,只是消息来源不同。 + +从 `0.2.66` 开始,`request_templates` 还会直接给出 `entry_playbooks`,把外部智能体、MP 内置智能体、飞书入口各自该调什么 helper / HTTP / Tool 以及优先读取哪些字段直接列出来。新接入方优先读这个结构,不要再自己拼第二套启动脚手架。 + +如果外部智能体已经确定是 MP 原生 PT 搜索/下载/订阅任务,优先拉 `mp_pt` recipe;如果是热门推荐、豆瓣热映、Bangumi 番剧续接,优先拉 `recommend` recipe。推荐列表里的条目现在支持: +- `选择 1 决策` +- `选择 1 计划` +- `选择 1 确认` +- `详情 1` +也支持直接对当前榜单首项继续发: +- `详情` +- `计划` +- `确认` +也支持会话内短命令: +- `决策 1` +- `计划 1` +- `确认 1` +也支持单句直达当前榜单首项: +- `智能发现 热门电影 详情` +- `智能发现 热门电影 计划` +- `智能发现 热门电影 确认` +以及单句直达具体来源: +- `智能发现 热门电影 盘搜` +- `智能发现 热门电影 影巢` +- `智能发现 热门电影 原生` +如果已经从推荐会话切到了 `盘搜 / 影巢 / 原生`,也可以直接发: +- `回推荐` +- `盘搜 / 影巢 / 原生` +- 在 `盘搜 / 原生` handoff 会话里,也支持: + - `详情 / 计划 / 确认 / 决策` +如果先看了 `详情 1`,之后还可以直接继续发: +- `详情` +- `决策` +- `计划` +- `确认` +- `盘搜` +- `影巢` +- `原生` +以及推荐会话内 follow-up: +- `电影` +- `电视剧` +- `豆瓣` +- `热映` +- `番剧` + +注意:`workflow` 会直接执行只读工作流;涉及下载、订阅、解锁或转存的写入工作流会默认保存待确认执行的 `plan_id`。 + +当前 PT 主线默认仍走 `plan_id` 确认链路。即使偏好里开启了 `auto_ingest_enabled=true`,外部智能体也应先展示评分和风险,再等待用户确认执行计划。 + +首次交给外部智能体使用时,建议先运行 `preferences`。如果返回需要初始化偏好,智能体应询问用户:清晰度、杜比视界/HDR、字幕、电视剧是否全集优先、PT 最低做种、影巢积分上限、默认目录、是否允许高分资源自动入库。偏好会用于云盘和 PT 分源评分。 + +如果你希望“新会话默认就更保守或更激进”,不要在智能体侧硬编码阈值,直接到 Agent影视助手 插件设置里修改默认评分策略:`PT 最低做种数`、`建议确认分数线`、`自动入库分数线`、`默认允许高分自动入库`。 + +`route`、`pick`、`workflow` 等主响应会带上低 token 的 `preference_status`。如果其中 `needs_onboarding=true`,智能体应先完成偏好询问与保存,再继续自动选择或入库。 + +偏好也可以直接走主入口自然语言:`偏好` 查看,`保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库` 写入,`重置偏好` 清除。 + +如果用户已经提前说明“只用夸克”“没有 115”“不用盘搜”“只用 MP/PT”,也可以直接保存进偏好,例如: + +- `保存偏好 只有夸克 不用115` +- `保存偏好 只用盘搜 不用影巢` +- `保存偏好 只用 MP/PT` + +之后优先用 `智能搜索`: + +- `python3 scripts/aro_request.py route "智能搜索 蜘蛛侠"` +- `python3 scripts/aro_request.py route "资源决策 蜘蛛侠"` +- `python3 scripts/aro_request.py route "智能计划 蜘蛛侠"` +- `python3 scripts/aro_request.py route "智能执行 蜘蛛侠"` + +这条入口会先按偏好过滤可用源和可用云盘,再按默认顺序 `盘搜 -> 影巢 -> MP/PT` 做统一搜索决策;如果前面某一源已经给出足够高分、风险可控的候选,就不会继续无意义展开后面的源。 + +如果你已经做过一次 `智能搜索`,也可以直接在当前会话里发: + +- `python3 scripts/aro_request.py route "计划最佳"` +- `python3 scripts/aro_request.py route "执行最佳"` +- `python3 scripts/aro_request.py route "换影巢"` +- `python3 scripts/aro_request.py route "换盘搜"` +- `python3 scripts/aro_request.py route "换PT"` +- `python3 scripts/aro_request.py route "保守一点"` +- `python3 scripts/aro_request.py route "激进一点"` +- `python3 scripts/aro_request.py route "只用夸克"` +- `python3 scripts/aro_request.py route "只用115"` +- `python3 scripts/aro_request.py route "只走PT"` +- `python3 scripts/aro_request.py route "不用影巢"` +- `python3 scripts/aro_request.py route "按保存偏好"` + +它会按当前智能搜索会话里的首选结果,直接生成待确认 `plan_id`,但不会立刻执行下载、解锁或转存。 +如果用户已经明确要求立即执行,再用 `智能执行` 或 `执行最佳`;这两个入口会直接走写入链。 + +AI 失败样本链现在分两步: + +- `失败样本 / 工作清单 / 样本洞察`:只读诊断 +- `重放样本 3` 或会话内 `重放 3`:只生成待确认计划 +- `确认`:执行当前会话里最近一条 AI 重放计划 +- 重放后可直接继续:`诊断`、`入库状态` + +真正执行仍然要回复 `执行计划 `,不会直接裸重放。 + +搜索类响应可能带有 `score_summary`,包含 `best` 和 `top_recommendations`。外部智能体应优先读取这个结构化摘要,而不是解析长文本;存在 `hard_risk_reasons` 时不要自动执行,`risk_reasons` 只作为确认前需要解释的提醒。 + +`score_summary.decision` 是优先读取的下一步建议层,里面会给出 `label`、`decision_hint`、`preferred_command`、`fallback_command`、`compact_commands` 和 `recommended_commands`。外部智能体应优先复用前两档短命令,不要自己再拼另一套确认话术。 + +执行计划后的回执,以及后续的 `execution_followup`、`smart_followup`、`mp_lifecycle_status`、`mp_ingest_status`、`mp_recent_activity`,现在会统一附带 `followup_summary`。外部智能体应优先读取 `preferred_command`、`fallback_command` 和 `compact_commands` 来决定“接下来查下载、查入库还是查诊断”,不要再靠不同 message 文案分支判断。 + +从 `0.2.63` 开始,compact 主响应顶层也会直接给出统一的 `command_source`、`command_policy`、`preferred_requires_confirmation`、`fallback_requires_confirmation`、`can_auto_run_preferred`、`preferred_command`、`fallback_command`、`compact_commands`。优先级已经固定为: + +1. `error_summary` +2. `followup_summary` +3. `score_summary.decision` + +外部智能体如果只想要“下一条最短命令”,直接读取顶层字段即可,不必自己再判断嵌套结构来源;如果还要判断“这一步能不能直接执行”,则读取 `command_policy` 和两个 `*_requires_confirmation` 标志。 + +从 helper `0.1.30` 开始,`route / pick / workflow / plan-execute / followup` 也能直接把这层顶层字段压成 `--summary-only` / `--command-only` 输出。外部智能体如果不想自己解析 JSON,可以直接调用 helper。 + +从 helper `0.1.31` 开始,这些摘要还会继续保留: + +- `command_policy` +- `preferred_requires_confirmation` +- `fallback_requires_confirmation` +- `can_auto_run_preferred` + +也就是外部智能体不只知道“下一条命令是什么”,还知道“这条命令能不能直接跑,还是该先停下来确认”。 + +从 helper `0.1.32` 开始,`--summary-only` 会直接给出一层更适合自动续跑的决策字段: + +- `recommended_agent_behavior` +- `auto_run_command` +- `confirm_command` +- `display_command` +- `stop_after_auto` +- `reason` + +推荐解释: + +- `auto_continue`:可以直接执行 `auto_run_command` +- `auto_continue_then_wait_confirmation`:先执行 `auto_run_command`,然后停下来把 `confirm_command` 展示给用户确认 +- `wait_user_confirmation`:不要自动执行,先让用户确认 `confirm_command` +- `show_only`:只展示 `display_command` +- `stop`:当前没有适合继续自动执行的短命令 + +从 helper `0.1.33` 开始,这套决策字段不只覆盖 `route / pick / workflow / plan-execute / followup`,也会覆盖 `decide / auto / doctor / recover` 这类老摘要入口。外部智能体可以统一只读: + +- `recommended_agent_behavior` +- `auto_run_command` +- `confirm_command` + +如果原摘要本身已经带业务层 `reason`,helper 会额外补 `execution_reason`,避免把原原因覆盖掉。 + +推荐把外部智能体的执行分支压成这 5 类: + +- `auto_continue`:直接执行 `auto_run_command` +- `auto_continue_then_wait_confirmation`:先执行 `auto_run_command`,再向用户确认 `confirm_command` +- `wait_user_confirmation`:不要自动执行,先展示 `confirm_command` +- `show_only`:只展示 `display_command` +- `stop`:当前不要继续自动执行 + +推荐的最小启动流也已经固定: + +1. `startup` +2. `decide --summary-only` +3. `route "<用户原始指令>" --summary-only` +4. 按 `recommended_agent_behavior` 决定自动继续、确认或停止 +5. 涉及执行计划后,再走 `followup --summary-only` + +评分由插件内置规则执行。外部智能体如需解释规则,可读取 `scoring-policy` 或 `capabilities.scoring_policy`;不要在智能体侧重新打分,也不要绕过 `hard_risk_reasons`。 + +`config-check` 只检查连接配置来源和是否存在,不输出真实 API Key。 + +`readiness` 会一次运行配置检查、本地 selftest 和 MoviePilot 插件 selfcheck。 + +WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体接入时,可以直接复用: + +- [外部智能体接入 Agent影视助手](../../docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md) +- Skill 包内外部智能体接入文件:`skills/agent-resource-officer/EXTERNAL_AGENTS.md` +- `PROMPTS.md` 里的外部智能体提示词段落 + +`decide` 是单次决策入口: + +- 有可恢复会话时,返回 `decision=continue_session` +- 没有可恢复会话时,返回 `decision=start_recipe` + +无论落到哪一边,低 token 摘要都会尽量附带下一步 helper 命令。 + +只需要下一步命令时,用: + +```bash +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py decide --command-only --confirmed +``` + +默认会在需要确认的场景输出查看命令;已经获得用户确认后,再加 `--confirmed` 输出执行命令。 + +如果已确定任务类型,可以直接指定 recipe 获取更具体的下一步命令: + +```bash +python3 scripts/aro_request.py decide --recipe mp_pt --command-only +python3 scripts/aro_request.py decide --recipe recommend --command-only +``` + +如果只想拿自动启动流的最小决策结果,直接用: + +```bash +python3 scripts/aro_request.py auto --summary-only +``` + +`doctor` 是只读诊断入口,会一次返回 `startup + selfcheck + sessions + recover` 的压缩结果,适合外部智能体在真正执行前做开场检查。 + +`feishu-health` 会检查 `AgentResourceOfficer` 内置飞书入口是否启用、长连接是否运行,以及飞书 SDK / 白名单 / 回复配置状态;MP 内置智能助手可直接使用 `agent_resource_officer_feishu_health`。 + +如果只想拿最省 token 的决策结果,直接用: + +```bash +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py recover --summary-only +``` + +它还会直接给出: + +- `helper_commands.inspect_helper_command` +- `helper_commands.execute_helper_command` + +## 恢复与排查 + +```bash +python3 scripts/aro_request.py sessions --limit 10 +python3 scripts/aro_request.py sessions --kind assistant_hdhive --limit 5 +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py recover --execute +python3 scripts/aro_request.py history --limit 10 +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans --limit 10 +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans --executed --include-actions --limit 5 +python3 scripts/aro_request.py plan-execute plan-xxx +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py followup plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +- `sessions` / `history` / `plans` / `recover` 默认不再强制绑到 `default` 会话。 +- 只有显式传 `--session` 或 `--session-id` 时,才会收窄到单个会话。 +- `followup` 会按最近已执行计划自动选择合适的只读后续动作,适合接在 `plan-execute` 后面。 +- `session-clear` / `sessions-clear` 是写入型清理命令,用于清理放弃的会话或 pending 115 恢复状态。 +- `plans-clear` 是写入型清理命令,优先使用 `--plan-id` 精确清理;批量清理时再使用 `--session`、`--executed`、`--unexecuted` 或 `--all-plans`。 + +长线程维护: + +如果外部智能体接的是微信、WorkBuddy、Claw、Hermes 或 OpenClaw 这类长期不断开的线程,用久以后可能会被旧测试上下文污染。典型表现是:`15详情` 被改写成 `选择 15`、编号续接到旧结果、或展示格式突然回到旧规则。 + +这时先清当前 session 和旧计划,再让智能体重新读取 Skill: + +```bash +python3 scripts/aro_request.py session-clear --session default +python3 scripts/aro_request.py plans-clear --session default +``` + +如果你给每个用户或群聊分配了固定 session,例如 `agent:wechat-room-1`,把 `default` 换成实际 session。不要把这一步放到普通搜索或更新检查前自动执行,否则会破坏正常编号续接。 + +## 偏好与评分 + +```bash +python3 scripts/aro_request.py preferences --session agent:demo +python3 scripts/aro_request.py preferences --session agent:demo --preferences-json '{"prefer_resolution":"4K","prefer_dolby_vision":true,"prefer_hdr":true,"prefer_chinese_subtitle":true,"prefer_complete_series":true,"pt_min_seeders":3,"hdhive_max_unlock_points":20,"auto_ingest_enabled":false}' +python3 scripts/aro_request.py route --text "保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库" --session agent:demo +python3 scripts/aro_request.py workflow --workflow mp_search --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode mp +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode hdhive +``` + +智能体也可以直接走自然语言路由: + +```bash +python3 scripts/aro_request.py route --text "看看最近有什么热门影视" +python3 scripts/aro_request.py route --text "豆瓣热门电影" +python3 scripts/aro_request.py route --text "今日番剧" +``` + +推荐列表出来后,可以用自然语言继续: + +```bash +python3 scripts/aro_request.py route --text "选择 1" +python3 scripts/aro_request.py route --text "选择 1 盘搜" +python3 scripts/aro_request.py route --text "选择1影巢" +``` + +MP 原生搜索结果出来后,也可以直接: + +```bash +python3 scripts/aro_request.py route --text "下载1" +python3 scripts/aro_request.py route --text "下载第1个" +python3 scripts/aro_request.py route --text "订阅蜘蛛侠" +python3 scripts/aro_request.py route --text "订阅并搜索蜘蛛侠" +python3 scripts/aro_request.py route --text "MP搜索 蜘蛛侠" --session agent:demo +python3 scripts/aro_request.py pick --choice 1 --session agent:demo +python3 scripts/aro_request.py route --text "计划选择 1" --session agent:demo +python3 scripts/aro_request.py route --text "最佳片源" --session agent:demo +python3 scripts/aro_request.py route --text "下载最佳" --session agent:demo +python3 scripts/aro_request.py route --text "执行计划" --session agent:demo +python3 scripts/aro_request.py route --text "执行 plan-xxxx" --session agent:demo +``` + +盘搜和影巢资源列表里的 `最佳片源`、`选择 1 详情` 是只读查看,不会转存或解锁。普通 `搜索/找 <片名>` 返回的盘搜列表,默认先按编号直接选;想先确认时再发 `选择 1 详情`。只有用户明确要求保留计划确认链时,才发 `计划选择 1`。 + +普通 `搜索/找 <片名>` 的返回应尽量原样展示资源官给出的编号列表,不要再二次改写成“资源状态”“推荐清单”“费用/评分/推荐星级”之类的摘要。最好的做法是保留原列表和下一步提示,只在前后补一两句极短说明。 + +`云盘搜索 <片名>` 也应尽量原样展示资源官给出的组合结果。不要把 `云盘搜索` 偷换成 `盘搜搜索`,也不要把插件已经给出的 `盘搜结果 / 影巢结果` 两段重新压成“剧集信息 / 推荐资源 / 分析结论”的导购摘要。优先保留: +- `盘搜结果` +- `影巢结果` +- 原始编号 +- 盘搜原始链接 +- 插件原生下一步提示 + +`云盘搜索` 返回后,不要自行改写成每个来源各自从 `1` 开始编号的小表格,也不要只摘“亮点”。如果插件返回了全局编号,就保留全局编号;如果插件提示“影巢候选未自动展开”,也应原样保留这句,而不是把它改成一句“影巢还有候选,需要可发影巢搜索”然后丢掉上文结构。 + +`MP搜索` / `PT搜索` 返回后,也不要自行改写成简表。尤其不要重写英文 release title,插件会在点号标题里加入隐藏断点,并用 `🧲`、`🌱`、`🎁`、`💾`、`⭐` 等 emoji 改善手机微信阅读;这些标记都应原样保留。 + +`更新检查` / `检查` 返回后,同样不要改写成 `#: 来源 / 详情 / 日期` 这种字段表。插件已经会输出 `🟨 盘搜结果`、`🟦 影巢结果`、`🗄 #编号 夸克`、`📺 #编号 115`、`🕒日期`、`📌 集数`,这些行应原样保留,最多在列表后追加一段很短的自然语言建议。 + +夸克转存失败时,不要自己补一段“可能是默认转存目录不存在或有问题”“换个 path=/ 试试”这类猜测。只有当插件明确指出路径问题时,才建议改路径;如果插件只返回 `夸克转存失败:无法转存到 /飞书`,更稳妥的表述应是“原因未明,先不要自行推断路径问题”。 + +下载任务也可以走同一入口。查询是读操作;暂停、恢复、删除会先返回 `plan_id`,确认后再执行: + +```bash +python3 scripts/aro_request.py route --text "下载任务" +python3 scripts/aro_request.py route --text "记录" +python3 scripts/aro_request.py route --text "记录 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_download_history --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py route --text "状态 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_lifecycle_status --keyword "蜘蛛侠" --limit 5 +python3 scripts/aro_request.py route --text "后续" +python3 scripts/aro_request.py route --text "跟进" +python3 scripts/aro_request.py route --text "跟进 蜘蛛侠" +python3 scripts/aro_request.py route --text "入库 蜘蛛侠" +python3 scripts/aro_request.py route --text "诊断 蜘蛛侠" +python3 scripts/aro_request.py route --text "最近" +python3 scripts/aro_request.py route --text "识别 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py route --text "暂停下载 1" +python3 scripts/aro_request.py route --text "恢复下载 1" +python3 scripts/aro_request.py route --text "删除下载 1" +``` + +PT 环境诊断也可以直接询问;站点结果只返回脱敏摘要,不会暴露 Cookie: + +```bash +python3 scripts/aro_request.py route --text "站点状态" +python3 scripts/aro_request.py route --text "下载器状态" +python3 scripts/aro_request.py workflow --workflow mp_sites --status active --limit 30 +python3 scripts/aro_request.py workflow --workflow mp_downloaders +``` + +MP 订阅也可以交给 Agent影视助手统一调度。查询是读操作;搜索、暂停、恢复、删除订阅会先返回 `plan_id`: + +```bash +python3 scripts/aro_request.py route --text "订阅列表" +python3 scripts/aro_request.py route --text "搜索订阅 1" +python3 scripts/aro_request.py route --text "暂停订阅 1" +python3 scripts/aro_request.py route --text "恢复订阅 1" +python3 scripts/aro_request.py route --text "删除订阅 1" +python3 scripts/aro_request.py workflow --workflow mp_subscribes --status all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_subscribe_control --control search --target 1 +``` + +MP 整理/入库历史是只读查询,适合让智能体确认下载后是否已经落库: + +```bash +python3 scripts/aro_request.py route --text "入库历史" +python3 scripts/aro_request.py route --text "入库失败 蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_transfer_history --keyword "蜘蛛侠" --status all --limit 10 +``` + +- 云盘资源按清晰度、HDR/DV、字幕、完整度、目录和网盘类型评分;影巢额外受积分上限保护。 +- PT 资源按做种数、免费/促销、下载热度、清晰度、HDR/DV、字幕、标题匹配、站点和发布组评分;高分也默认先返回 `plan_id`,不会直接下载。 +- 下载、订阅、影巢解锁、网盘转存默认先生成 `plan_id`,确认后再执行。 + +## 说明 + +- 这是面向公开仓库的通用模板。 +- 重点使用 `AgentResourceOfficer` 的 `assistant/startup` 和 `assistant/request_templates`。 +- HTTP 调用使用 `?apikey=MP_API_TOKEN`。 +- 不包含个人路径、API Key、Cookie 或 Token。 +- 推荐搭配支持 Skill 和工具调度的外部智能体使用,例如腾讯 WorkBuddy、Hermes、OpenClaw(小龙虾),或其他兼容 Skill 工作流的客户端。 +- 版本记录见:`skills/agent-resource-officer/CHANGELOG.md`。 diff --git a/skills/agent-resource-officer/SKILL.md b/skills/agent-resource-officer/SKILL.md new file mode 100644 index 0000000..0058381 --- /dev/null +++ b/skills/agent-resource-officer/SKILL.md @@ -0,0 +1,755 @@ +--- +name: agent-resource-officer +description: Control AgentResourceOfficer, the MoviePilot resource workflow hub, from an external agent. Use when an agent should route title-based resource commands including PanSou, HDHive, 115, Quark, MP/PT search, downloads, update checks, numbered choices, paging, cookie repair, startup/recovery state, request templates, or saved plans through AgentResourceOfficer instead of calling MoviePilot MCP search tools, TMDB, HDHive, 115, Quark, or PanSou APIs directly. +--- + +# AgentResourceOfficer Skill + +Use this skill when the user wants an external agent to operate MoviePilot title-based resource workflows through `AgentResourceOfficer`, including PanSou, HDHive, 115, Quark, MP/PT search, download, update-check, numbered picking, paging, and repair flows. + +The plugin is the capability layer. The agent should orchestrate, display choices, ask for confirmation when required, and call the stable assistant endpoints. + +## Configuration + +Public repository: + +```text +https://github.com/liuyuexi1987/MoviePilot-Plugins +``` + +Install this skill on the machine running the external agent: + +```bash +git clone https://github.com/liuyuexi1987/MoviePilot-Plugins.git +cd MoviePilot-Plugins +bash skills/agent-resource-officer/install.sh --dry-run +bash skills/agent-resource-officer/install.sh +``` + +Preferred config file: + +```text +~/.config/agent-resource-officer/config +``` + +Format: + +```text +ARO_BASE_URL=http://127.0.0.1:3000 +ARO_API_KEY=your_moviepilot_api_token +``` + +Rules: + +- `ARO_BASE_URL` must be the MoviePilot address reachable from the machine running the external agent. +- Use `127.0.0.1` only when MoviePilot is on the same machine. +- If the user has multiple MoviePilot instances, `ARO_BASE_URL` decides which one receives `下载` / `MP搜索` / `PT搜索` / `转存`. +- `下载` / `MP搜索` / `PT搜索` use the downloader configured inside that MoviePilot instance, so do not assume "local MP" means "local download". +- If the target MoviePilot is only for cloud-drive/STRM workflows, do not confirm PT downloads through it. +- If the server plugin sets `mp_download_save_path`, treat it as a server-side safety setting. Never invent that path in chat. + +## Routing Boundary + +Use MoviePilot MCP only when it is truly connected in the current client and the corresponding MCP tools are visible in the active tool list. + +Default boundary: + +- MoviePilot native read-only or light management queries may prefer MCP when MCP is really available. +- Title-based resource workflows stay on `agent-resource-officer`. +- If MCP is not explicitly available, continue to use helper/HTTP route flow and do not pretend MCP exists. + +Resource commands that must go straight to `route` / `pick`: + +- `搜索` / `找` +- `盘搜` / `盘搜搜索` +- `影巢` / `影巢搜索` +- `云盘搜索` +- `MP搜索` / `PT搜索` +- `转存` / `115转存` / `夸克转存` +- `下载` +- `更新` / `更新检查` / `检查` +- `选择` / `详情` / `n` / `下一页` +- numbered follow-ups + +Core rules: + +- `转存 <片名>` means `115转存 <片名>` by default; only explicit `夸克转存` should use Quark. +- `下载 <片名>` means MoviePilot/PT only. Never rewrite it into cloud search or cloud transfer. +- Ambiguous write-intent titles such as `下载 蜘蛛侠` or `转存 蜘蛛侠` must first resolve MoviePilot/TMDB candidates, then continue with the exact chosen title and year. +- `下载1` generates or selects the current PT download plan. It is not confirmation for an older saved plan. +- `1详情` and similar variants must preserve detail intent. Never collapse them into `1` or `选择 1`. +- Before confirming PT download execution, make sure the connected MoviePilot is the real download instance, not a cloud-drive/STRM-only instance. +- If the user says `校准影视技能`, run `python3 scripts/aro_request.py calibrate` or `python3 scripts/aro_request.py route "校准影视技能"` first, apply the returned hard rules to the current session, then reply only `影视技能已校准。`. +- For explicit title searches such as `MP 搜索 罪无可逃`, the first and only initial action is helper `route "<原话>" --session `. Do not pre-call TMDB, MCP search, raw MoviePilot API, or torrent search before that helper route. + +Environment overrides: + +- `ARO_BASE_URL` +- `MP_BASE_URL` +- `MOVIEPILOT_URL` +- `ARO_API_KEY` +- `MP_API_TOKEN` +- `ARO_HDHIVE_COOKIE_EXPORT_DIR` +- `ARO_HDHIVE_COOKIE_EXPORT_PYTHON` +- `ARO_HDHIVE_COOKIE_BROWSER` +- `ARO_HDHIVE_COOKIE_SITE_URL` +- `ARO_HDHIVE_COOKIE_RESTART_CONTAINER` +- `ARO_QUARK_COOKIE_EXPORT_DIR` +- `ARO_QUARK_COOKIE_EXPORT_PYTHON` +- `ARO_QUARK_COOKIE_BROWSER` +- `ARO_QUARK_COOKIE_SITE_URL` +- `ARO_QUARK_COOKIE_RESTART_CONTAINER` + +Never print API keys, cookies, or tokens back to the user. + +If this skill is installed from the `MoviePilot-Plugins` repository checkout, the helper will first try the bundled cookie export tools in: + +- `tools/hdhive-cookie-export/` +- `tools/quark-cookie-export/` + +The install helper copies these tools into the installed skill directory as `tools/...`, so a standalone installed skill can call `hdhive-cookie-refresh`, `hdhive-checkin-repair`, `quark-cookie-refresh`, and `quark-transfer-repair` directly. You can still override them with `ARO_HDHIVE_COOKIE_EXPORT_DIR` and `ARO_QUARK_COOKIE_EXPORT_DIR`. + +Optional install helper: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/agent-resource-officer +``` + +## Request Helper + +Prefer the bundled helper: + +```bash +python3 scripts/aro_request.py startup +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py doctor --limit 5 +python3 scripts/aro_request.py doctor --limit 5 --summary-only +python3 scripts/aro_request.py feishu-health +python3 scripts/aro_request.py recover --summary-only +python3 scripts/aro_request.py version +python3 scripts/aro_request.py selftest +python3 scripts/aro_request.py hdhive-cookie-refresh +python3 scripts/aro_request.py hdhive-checkin-repair +python3 scripts/aro_request.py quark-cookie-refresh +python3 scripts/aro_request.py quark-transfer-repair +python3 scripts/aro_request.py commands +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py config-check +python3 scripts/aro_request.py readiness +python3 scripts/aro_request.py selfcheck +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py sessions --kind assistant_hdhive --limit 5 +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py templates --recipe bootstrap +python3 scripts/aro_request.py route "盘搜搜索 大君夫人" +python3 scripts/aro_request.py pick 1 +``` + +The helper uses `?apikey=...`, which is the recommended HTTP auth mode for plugin assistant endpoints. + +Use `selftest` to validate local helper logic without connecting to MoviePilot: + +```bash +python3 scripts/aro_request.py selftest +``` + +Use `version` to print the local helper version: + +```bash +python3 scripts/aro_request.py version +``` + +Use `commands` when an external agent needs the local helper command catalog: + +```bash +python3 scripts/aro_request.py commands +``` + +The command catalog uses `schema_version=commands.v1`; `writes` is always boolean and details live in `write_condition`. + +Use `external-agent` when handing this Skill to WorkBuddy, Hermes, OpenClaw(小龙虾), a WeChat-side agent, or another external agent: + +```bash +python3 scripts/aro_request.py external-agent +python3 scripts/aro_request.py external-agent --full +python3 scripts/aro_request.py calibrate +``` + +`external-agent` prints the compact prompt and minimal tool contract. `external-agent --full` prints the full bundled handoff guide. `workbuddy` remains a compatibility alias only; new integrations should use `external-agent`. + +`calibrate` prints a compact calibration card for long-lived external-agent threads. Use it when a WeChat/WorkBuddy/Claw/Hermes/OpenClaw session has been compressed or starts rewriting commands incorrectly, for example changing `下载 <片名>` into cloud transfer or changing `15详情` into execution. + +When a user says plain `搜索 <片名>` or `找 <片名>`, pass that text through to `route` first. Do not guess that the user meant HDHive, and do not continue an old result session by sending `选择 1` unless the user actually chose an item in the current round. Default plain search should start from PanSou. + +When the user clearly refers to a previously shown numbered result, for example `刚才那个 22`、`上次的 #22`、`把原来的 22 转存`、`下载 10`、`选择 14`, do not restart search first. Reuse the current session, or recover the latest matching session with `decide --summary-only` / `sessions` / `session`, then continue with `pick`. Only restart the search when the old session is truly gone and cannot be recovered. + +When a user says `转存 <片名>`, route that text directly first. Treat it as a 115-transfer intent, equivalent to `115转存 <片名>`: prefer PanSou + HDHive 115 resources, and let AgentResourceOfficer execute the one-stop transfer flow instead of rewriting it into a PT download request. Only use Quark when the user explicitly says `夸克转存`. + +When a user says `下载 <片名>`, route that text directly first. Treat it as an MP/PT search-and-download intent, not a browsing/listing intent. If the title is ambiguous, show MoviePilot/TMDB title candidates first. Once the title is unambiguous, the plugin should search PT internally and directly return up to three pending download plans for the best PT candidates instead of showing the full PT list. It must not auto-submit a real download from the title command. Only after the plugin has returned pending plans may the user confirm by replying the displayed方案编号 such as `1`, `2`, or `3`, or `执行计划`; route that reply as-is so the plugin can execute the matching pending plan. `下载1` means "generate/select download plan for result 1", not confirmation for an older saved plan. If there is no pending plan in the current session, a bare number must be treated as the current result-list continuation. + +When a user says `MP搜索 <片名>`, `MP 搜索 <片名>`, `PT搜索 <片名>`, or `PT 搜索 <片名>`, route that exact text directly first. Treat it as an explicit MoviePilot native/PT search request. Do not rewrite it into `搜索 <片名>`, `盘搜搜索 <片名>`, `云盘搜索 <片名>`, or smart search. + +If the same command includes a natural-language latest-episode intent such as `给我最新集`, `最新集`, or `最新一集`, still route the original text directly. AgentResourceOfficer will strip that suffix from the title, detect the highest episode in PT results, and show only candidates containing that latest episode. Do not add older episode batches back into the summary unless the user asks for all results. + +If the same command includes a clear episode filter such as `第4集`, `第四集`, `E04`, or `S01E04`, still route the original text directly. AgentResourceOfficer will strip the episode suffix from the title and show only candidates containing that target episode, then renumber the filtered list safely. Do not remove this intent or rewrite it as a generic title search. + +For `下载 <片名>` results, relay the plugin's returned message exactly like `MP搜索` / `PT搜索`. If the plugin returns a PT resource list, show the numbered resources, score lines, recommendation, and next-step hints. Never replace the list with a one-line summary such as `PT资源已列出,回编号选详情或下载`. In PT result lists, keep the distinction clear: `选择 N` is read-only detail/review, `下载N` generates a pending download plan, and only a later `执行计划` or matching number after that pending plan executes it. + +If `MP搜索` / `PT搜索` returns a MoviePilot media candidate list, do not choose for the user. Show the candidates, ask the user to reply with a number, then call `pick ` to continue the PT search. For ambiguous titles such as `蜘蛛侠`, this candidate step is expected and safer than assuming the 2002 movie. + +If the original `MP搜索` / `PT搜索` command included `最新集` / `给我最新集` and then returned a media candidate list, the user's numeric reply must be routed in the same helper session. Do not run a fresh bare `route "1"` in another/default session, and do not summarize older episode batches as latest results. The plugin will preserve the latest-episode filter after the candidate is selected; relay that returned message as-is. + +After `下载 <片名>` returns a title candidate list, preserve the same helper session and route the user's numeric reply exactly as the reply text, for example `python3 scripts/aro_request.py route "5" --session `. Do not reconstruct it as `下载 <候选标题 年份>`, because that loses the candidate session and can change behavior. If the selected title has no PT resources, say that MP/PT currently has no downloadable result; do not silently fall back to PanSou, HDHive, Quark, 115, or cloud transfer. Cloud resources require an explicit `云盘搜索` / `转存` / `夸克转存` / `115转存` command. + +For `MP搜索` / `PT搜索` results, relay the plugin's returned message exactly. Do not compress it into a new custom list such as `PT 资源共 N 条`, and do not rewrite release titles. Preserve the plugin's emoji markers (`🧲`, `🌱`, `🎁`, `💾`, `⭐`, etc.) and invisible breaks in dotted release names; they are intentional for WeChat/mobile readability. + +Do not renumber MP/PT result lists. If the plugin returns visible item numbers like `2, 4, 21, 29`, keep those exact numbers in the user-facing reply and in follow-up commands such as `选择 2` or `下载2`. Never rewrite them to `1, 2, 3, 4`, because MP/PT detail and download actions are keyed to the plugin's visible numbers. Do not append your own “当前最高分候选” or “回复选择 N” footer when the plugin message already includes recommendation and next-step hints. + +When the current client has no MoviePilot MCP tools, do not announce an MCP fallback for `MP搜索` / `PT搜索`. Just call `python3 scripts/aro_request.py route "<原始用户命令>" --session ` and relay the returned message. + +When a user says `云盘搜索 <片名>`, route that exact text first. Do not silently replace it with `盘搜搜索 <片名>`. Cloud search is a distinct entry that should compare PanSou and HDHive together; if HDHive stays ambiguous, preserve the plugin's own `影巢结果` hint instead of collapsing everything into a PanSou-only recommendation. + +When a user says `更新 <片名>`, `更新检查 <片名>`, `查更新 <片名>`, or `检查 <片名>`, route that text directly first and treat it as the update-check entry. Do not clear the session first, do not guess that the user meant HDHive candidate search, and do not replace it with a generic search flow. The update flow should first show official reference progress plus PanSou and HDHive latest-episode resources, then let the user choose a numbered resource if needed. + +For update-check results, relay the plugin's returned message exactly. Preserve the emoji sections and item lines such as `🟨 盘搜结果`, `🟦 影巢结果`, `🗄 #25 夸克`, `📺 #1 115`, `🕒05/02`, and `📌 E01-E09`. Do not transform them into field-table prose like `#: ... 来源: ... 详情: ... 日期: ...`, and do not replace the list with a summary. + +When a user says `刷新影巢Cookie`, do not route that phrase into AgentResourceOfficer. Treat it as a host-side repair action and run: + +```bash +python3 scripts/aro_request.py hdhive-cookie-refresh +``` + +This command exports the current HDHive webpage cookie from the local browser, writes it back into MoviePilot and AgentResourceOfficer, and restarts `moviepilot-v2`. + +When a user says `修复影巢签到`, do not route that phrase directly. Run: + +```bash +python3 scripts/aro_request.py hdhive-checkin-repair +``` + +This command refreshes the HDHive webpage cookie from the local browser export tool, restarts `moviepilot-v2`, then retries one HDHive sign-in through AgentResourceOfficer. + +When `影巢签到` or `影巢签到日志` clearly shows cookie/login failure, prefer the automatic repair flow instead of asking the user to hand-copy cookies. First remind the user to ensure they are logged into `https://hdhive.com` in Edge, then run `hdhive-checkin-repair`, and finally show the new sign-in result. + +When a user says `刷新夸克Cookie`, do not route that phrase into AgentResourceOfficer. Treat it as a host-side repair action and run: + +```bash +python3 scripts/aro_request.py quark-cookie-refresh +``` + +This command exports the current Quark webpage cookie from the local browser, writes it back into MoviePilot and AgentResourceOfficer, and restarts `moviepilot-v2`. + +When a user says `修复夸克转存`, do not route that phrase directly. Prefer: + +```bash +python3 scripts/aro_request.py quark-transfer-repair --retry-text "<刚才失败的原始转存命令>" +``` + +If there is no safe transfer command to retry, run `python3 scripts/aro_request.py quark-transfer-repair` first to refresh the cookie and verify Quark health, then ask the user to retry the original transfer. + +Only use the Quark automatic repair flow when the failure clearly points to login/cookie problems, for example `require login [guest]`, `夸克登录态已过期`, or `当前夸克登录态不足`. Do not trigger it for share-link restrictions, deleted links, or ordinary 403/41031 share bans. + +For ordinary search, cloud search, HDHive resource lists, and update-check lists, preserve the plugin's original numbering exactly. Do not reformat a numbered resource list into unnumbered prose, do not collapse numbered items into a separate summary, and do not move the actionable numbers only into a later recommendation paragraph. Smart recommendations are welcome after the original list, and can be as detailed as useful, as long as they reference the original item numbers and do not replace the list. + +The helper's default `route` and `pick` commands print a chat-friendly plain text `message`. Relay that output directly to the user. If you need to parse structured fields programmatically, add `--json-output`; do not parse the plain display text and then reconstruct your own resource list. + +For numbered detail follow-ups, keep the detail action. `15详情`, `15 的详情`, `我要看看 15 的详情`, `十六详情`, and `详情十六` are read-only detail requests. They must not be changed into `选择 15` or a direct transfer/download command. + +For PanSou result lists, keep the source section headings (`🟦 115 结果`, `🟨 夸克结果`) and do not repeat provider tags inside every item. Display items as `编号. emoji 标题` rather than `编号. [115] ...` or `编号. [quark] ...`. Dates should keep the clock marker, for example `— 🕒05/07` or the returned `display_datetime`. Preserve physical line breaks between the source heading and each numbered item; if the chat frontend renders Markdown and may collapse normal line breaks, wrap the resource list itself in a fenced `text` block or insert real blank lines after each source heading so Quark items do not collapse into one paragraph. + +Do not show raw 115/Quark share links in search result lists. Links belong in the copy-friendly detail card returned by `选择 编号 详情`. + +For HDHive/影巢 resource lists, use the same source grouping style: `🟦 115 结果` and `🟨 夸克结果`. Keep each resource as plain numbered items like `1. emoji 标题 · 积分 · 大小 · 集数 · 规格`, not `#1`. Put a real blank line between resource items in Markdown-like chat frontends so WorkBuddy does not collapse the list into one paragraph. A recommendation section is allowed at the end, but keep it after the original list and reference original numbers. If the user needs a shareable link or full metadata, tell them to use `选择 编号 详情`; the detail card is the copy-friendly view. + +After displaying a resource list, add or preserve a `智能建议` section when the data is enough to compare quality. Do not over-constrain the recommendation length; explain the tradeoffs naturally around common viewing and storage decisions such as picture quality, episode completeness, subtitle clarity, file size, source reliability, and whether the user explicitly wants 115 or Quark. Do not expose raw score formulas such as `4K +25` as the main explanation. The only hard rule is that recommendations must reference the original item numbers and must not replace or renumber the original list. + +For cloud search results, prefer the plugin's raw combined layout: keep the `盘搜结果` section, keep the `影巢结果` section, and keep raw links when the plugin returned them. Do not hide the source-specific sections behind your own summary. A short recommendation is allowed only after the raw list and next-step hint. + +For cloud search, never renumber items per source in your own prose. If the plugin returned global numbering like `1..16` plus `17..24`, preserve that exact numbering. Do not convert it into separate `115 1..6 / 夸克 1..10` local indices, and do not collapse the response into a custom “标题/画质/日期/链接” table that drops the plugin's next-step instructions. + +When `影巢搜索` or `云盘搜索` falls back to PanSou because HDHive returned no usable result, keep the plugin's original fallback text and numbered resource list. Do not rewrite it into your own progress bulletin like “有新集了”“现在两边都有了” or a custom compact table that hides links, numbering, or next-step hints. + +When a Quark transfer fails, do not invent a path diagnosis unless the plugin explicitly said so. If the plugin only returned `夸克转存失败:无法转存到 /飞书`, treat the cause as unknown and do not add guesses like “默认转存目录不存在” or “换成 path=/ 试试” on your own. Only recommend a different path when the plugin itself clearly pointed to a path problem or the user explicitly asked to try another path. + +Use `config-check` to verify connection settings without printing secrets: + +```bash +python3 scripts/aro_request.py config-check +``` + +Use `readiness` after configuration to run config check, local selftest, and live plugin selfcheck together: + +```bash +python3 scripts/aro_request.py readiness +``` + +Update-check examples: + +```bash +python3 scripts/aro_request.py route "更新 大君夫人" +python3 scripts/aro_request.py route "更新检查 大君夫人" +python3 scripts/aro_request.py route "检查 大君夫人" +``` + +Quark cleanup examples: + +```bash +python3 scripts/aro_request.py route "清空夸克默认转存目录" +python3 scripts/aro_request.py route "清空夸克默认目录" +``` + +Use Quark cleanup only when the user explicitly asked to clear the Quark default transfer directory. Treat it as a destructive cloud-drive write. It targets the current layer entries of the configured Quark default directory: files are deleted directly, and current-layer folders are deleted together with their contents. Do not infer it from vague cleanup requests, do not silently replace it with 115 cleanup, and do not grep helper source to decide whether this command is supported. + +115 cleanup examples: + +```bash +python3 scripts/aro_request.py route "清空115转存目录" +python3 scripts/aro_request.py route "清空115默认转存目录" +python3 scripts/aro_request.py route "清空115默认目录" +``` + +Use 115 cleanup only when the user explicitly asked to clear the 115 default transfer directory. Treat it as a destructive cloud-drive write. It targets the current layer entries of the configured 115 default directory: files are deleted directly, and current-layer folders are deleted together with their contents. Do not grep helper source to decide whether this command is supported; route the original phrase directly. + +For update requests, do not start with: + +```bash +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py route "影巢搜索 大君夫人" +``` + +unless the user explicitly asked to abandon the current state or explicitly asked for HDHive-only search. + +For ordinary search and update requests, do not start with: + +```bash +python3 scripts/aro_request.py session-clear default +``` + +unless the user explicitly asked to clear or reset the session. + +Use `feishu-health` only when diagnosing the built-in AgentResourceOfficer Feishu Channel: + +```bash +python3 scripts/aro_request.py feishu-health +``` + +For MoviePilot's built-in Agent, use the native tool `agent_resource_officer_feishu_health` instead of calling the Feishu health API manually. + +## Core Startup Flow + +Fast path: + +```bash +python3 scripts/aro_request.py decide --summary-only +python3 scripts/aro_request.py auto +python3 scripts/aro_request.py auto --summary-only +python3 scripts/aro_request.py doctor --limit 5 +``` + +`auto` calls `startup`, reads `recommended_request_templates`, then fetches the recommended low-token recipe. + +`decide` is the single low-token decision entry: + +- if there is a resumable session, it returns `decision=continue_session` +- otherwise it returns `decision=start_recipe` + +If you want the automatic flow but only need the decision summary, prefer: + +```bash +python3 scripts/aro_request.py auto --summary-only +``` + +`doctor` is the read-only diagnostic entry. It combines: + +- `assistant/startup` +- `assistant/selfcheck` +- `assistant/sessions` +- `assistant/recover` + +Use it when an external agent needs one compact bootstrap/health/recovery snapshot before deciding whether to start a new task or continue an old one. + +It also returns local helper suggestions: + +- `helper_commands.inspect_helper_command` +- `helper_commands.execute_helper_command` + +For `auto --summary-only` and `decide --summary-only`, the start-recipe branch also returns: + +- `inspect_helper_command` +- `execute_helper_command` + +If a caller only wants the next helper command, use: + +```bash +python3 scripts/aro_request.py decide --command-only +python3 scripts/aro_request.py auto --command-only +python3 scripts/aro_request.py recover --command-only +python3 scripts/aro_request.py decide --command-only --confirmed +``` + +`--command-only` prints an inspect command only when the next action itself requires confirmation. If the current recipe starts with a safe read step, such as `mp_pt` or `recommend`, it prints that executable read command directly even when later write steps still require confirmation. + +If token budget is tight, prefer: + +```bash +python3 scripts/aro_request.py doctor --summary-only +python3 scripts/aro_request.py recover --summary-only +``` + +Manual path: + +1. Call startup: + +```bash +python3 scripts/aro_request.py startup +``` + +2. Read `recommended_request_templates`. + +3. Fetch templates by the recommended recipe: + +```bash +python3 scripts/aro_request.py templates --recipe continue +``` + +If startup has recoverable state, it may recommend `continue`. Otherwise it normally recommends `bootstrap`. + +## Recipes + +Supported recipe names and short aliases: + +- `bootstrap` -> `safe_bootstrap` +- `plan` -> `plan_then_confirm` +- `maintain` -> `maintenance_cycle` +- `continue` -> `continue_existing_session` +- `preferences` / `prefs` / `片源偏好` / `偏好画像` -> `preferences_onboarding` +- `mp_pt` / `mp` / `pt` -> `mp_pt_mainline` +- `recommend` / `热门` / `推荐` -> `mp_recommendation` +- `local_ingest` / `ingest` / `local` / `本地入库` / `入库诊断` -> `local_ingest` + +Use: + +```bash +python3 scripts/aro_request.py templates --recipe plan --policy-only +python3 scripts/aro_request.py templates --recipe preferences --policy-only +python3 scripts/aro_request.py templates --recipe mp_pt --policy-only +python3 scripts/aro_request.py templates --recipe recommend --policy-only +``` + +The response includes: + +- `recommended_recipe` +- `recommended_recipe_detail.first_call` +- `recommended_recipe_detail.calls` +- `first_confirmation_template` +- `confirmation_message` +- `auth.mode=query_apikey` +- `url_template` + +## Main Interaction Flow + +For natural-language resource work, use `route`: + +```bash +python3 scripts/aro_request.py route --text "MP搜索 蜘蛛侠" +python3 scripts/aro_request.py route --text "影巢搜索 蜘蛛侠" +python3 scripts/aro_request.py route --text "盘搜搜索 大君夫人" +python3 scripts/aro_request.py route --text "链接 https://pan.quark.cn/s/xxxx path=/飞书" +``` + +For numbered continuation, use `pick`. Positional and flagged forms are both supported: + +```bash +python3 scripts/aro_request.py pick 1 +python3 scripts/aro_request.py pick 11 --path /飞书 +python3 scripts/aro_request.py pick 1 详情 +python3 scripts/aro_request.py pick 详情 +python3 scripts/aro_request.py pick 下一页 +``` + +Common diagnostic helpers also support shorter positional forms: + +```bash +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +For session inspection and recovery: + +```bash +python3 scripts/aro_request.py sessions +python3 scripts/aro_request.py session default +python3 scripts/aro_request.py session-clear default +python3 scripts/aro_request.py sessions-clear --has-pending-p115 --limit 10 +python3 scripts/aro_request.py recover +python3 scripts/aro_request.py recover --execute +python3 scripts/aro_request.py templates --recipe followup --compact +python3 scripts/aro_request.py history --limit 10 +python3 scripts/aro_request.py history agent:demo +python3 scripts/aro_request.py plans --limit 10 +python3 scripts/aro_request.py plans plan-xxx +python3 scripts/aro_request.py plans --executed --include-actions --limit 5 +python3 scripts/aro_request.py plan-execute plan-xxx +python3 scripts/aro_request.py followup --session agent:<用户ID> +python3 scripts/aro_request.py followup plan-xxx +python3 scripts/aro_request.py plans-clear plan-xxx +``` + +Notes: + +- `sessions`, `history`, `plans`, and `recover` no longer force `session=default` when you do not pass `--session`. +- Use `--session` or `--session-id` only when you want to narrow to one conversation. +- Use `sessions --kind ...` or `sessions --has-pending-p115` when you want recovery-oriented filtering. +- Use `followup` after `plan-execute` when you want the plugin to choose the correct read-only next step automatically. +- Use `session-clear` or `sessions-clear` to clear abandoned assistant state after user confirmation. +- Use `plans-clear --plan-id ...` for exact saved-plan cleanup. Treat bulk cleanup flags as write-side-effect operations requiring confirmation. +- For long-lived WeChat, WorkBuddy, Claw, Hermes, or OpenClaw threads, stale compressed context can cause bad rewrites such as changing `15详情` into `选择 15`. When that happens, clear the current ARO session and saved plans, then reload this skill. Do not run session cleanup before ordinary search or update-check commands, because normal numbered follow-up depends on session continuity. + +Long-thread cleanup example: + +```bash +python3 scripts/aro_request.py session-clear --session default +python3 scripts/aro_request.py plans-clear --session default +``` + +## Preferences And Scoring + +Before the first automated resource task in a new user profile, check preferences: + +```bash +python3 scripts/aro_request.py preferences --session agent:<用户ID> +python3 scripts/aro_request.py templates --recipe preferences --compact +python3 scripts/aro_request.py scoring-policy +``` + +Most assistant responses also include compact `preference_status`. If `preference_status.needs_onboarding=true`, pause automation, ask the user for preferences, then save them before choosing downloads, unlocks, or transfers. + +Search responses may include compact `score_summary`. Prefer `score_summary.best` and `score_summary.top_recommendations` over parsing the natural-language message. Treat `hard_risk_reasons` as blocking automation; treat `risk_reasons` as warnings to explain before asking for confirmation. If `score_level=confirm`, explain the reasons and ask the user before executing. + +If `needs_onboarding=true`, ask the user for a compact preference profile and save it: + +```bash +python3 scripts/aro_request.py preferences --session agent:<用户ID> --preferences-json '{"prefer_resolution":"4K","prefer_dolby_vision":true,"prefer_hdr":true,"prefer_chinese_subtitle":true,"prefer_complete_series":true,"prefer_cloud_provider":"115","pt_require_free":false,"pt_min_seeders":3,"hdhive_max_unlock_points":20,"p115_default_path":"/待整理","quark_default_path":"/飞书","auto_ingest_enabled":false,"auto_ingest_score_threshold":90}' +``` + +You may also manage preferences through the main natural-language route: + +```bash +python3 scripts/aro_request.py route --text "偏好" --session agent:<用户ID> +python3 scripts/aro_request.py route --text "保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库" --session agent:<用户ID> +python3 scripts/aro_request.py route --text "重置偏好" --session agent:<用户ID> +``` + +Scoring rules are source-specific and plugin-owned. Use `scoring-policy` or `capabilities` to read the current policy when you need to explain the rules to the user. Do not invent a separate score in the agent. + +- Cloud resources: HDHive, PanSou 115, PanSou Quark, direct 115/Quark links. Score quality, Dolby Vision/HDR, subtitles, completeness, file size, drive preference, and target directory. HDHive also checks point cost. +- PT resources: MoviePilot native site search/download/subscribe. Score seeders, free/promo status, volume factor, resolution, Dolby Vision/HDR, subtitles, release group/site, size, and title match. +- PT seeders are a hard gate. Default minimum is `3`; seeders `0` means never auto-download. +- HDHive point cost is a hard gate. Default max is `20`; unknown points cannot auto-unlock. +- Auto ingest is off by default. Even when `can_auto_execute=true`, the current PT interaction policy should still prefer `plan_id` first unless an internal system path explicitly executes the saved plan. + +For MP native workflows: + +```bash +python3 scripts/aro_request.py workflow --workflow mp_search --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow mp_media_detail 蜘蛛侠 +python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword "蜘蛛侠" --choice 1 +python3 scripts/aro_request.py workflow --workflow mp_download_history --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_lifecycle_status --keyword "蜘蛛侠" --limit 5 +python3 scripts/aro_request.py workflow --workflow mp_ingest_status --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_ingest_failures --keyword "蜘蛛侠" --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_recent_activity --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_local_diagnose --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_subscribe --keyword "蜘蛛侠" +python3 scripts/aro_request.py workflow --workflow mp_transfer_history --keyword "蜘蛛侠" --status all --limit 10 +python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20 +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode mp +python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice 1 --mode pansou +``` + +`mp_search_download`, `mp_subscribe`, and `mp_subscribe_and_search` are write-side-effect workflows. They should return a saved `plan_id` first; execute with `plan-execute` only after the user confirms. + +`mp_transfer_history` is read-only. Use it after downloads or transfers to check whether MoviePilot has already organized the media into the library. Prefer the structured `items` fields and path previews; do not ask for full local paths unless the user explicitly needs troubleshooting detail. + +`mp_download_history` is read-only. Use it before `mp_transfer_history` when the user asks whether a PT/native MP resource was ever submitted for download. It also reports a compact transfer status when the download hash can be linked to MoviePilot transfer history. + +`mp_lifecycle_status` is read-only and should be the default troubleshooting query for “where is this resource now?”. It combines active download tasks, download history, and transfer/import history in one call. + +`mp_ingest_status` is read-only and should be the shortest answer path for “has this PT/local resource entered the library yet?”. It returns a structured `diagnosis_summary` with `stage`, `confidence`, `evidence`, `risk_reasons`, `recommended_action`, and `follow_up_hint`. + +`mp_ingest_failures` is read-only and focuses on transfer/import failures. Use it when the user asks “why did this fail to ingest?” or wants the recent failed records without reading the full transfer history. + +`mp_recent_activity` is read-only and gives a quick view of recent downloads and recent ingest activity. Use it when there is no exact title yet and the user asks what MoviePilot did recently. + +`mp_local_diagnose` is read-only and should be the one-stop path for “为什么没入库 / where is it stuck locally?”. Prefer it after `mp_ingest_status` or execution follow-up when the plugin already detected failure clues. + +`mp_media_detail` is read-only. Use it before search/download/subscribe when the title is ambiguous or the agent needs to confirm MoviePilot's native media recognition, TMDB/Douban/IMDB IDs, year, and media type. + +`mp_search_detail` is read-only. Use it after or together with MP native search when the user wants to inspect a numbered PT candidate. It shows seeders, promotion, size, score reasons, and risks. Do not download from this detail step; ask for confirmation or generate a plan before downloading. + +`mp_search_best` is read-only and token-efficient. Use it when the user asks the agent to recommend the best PT candidate after MP native search. It searches, ranks by the plugin-owned score, and returns the best candidate detail. It still does not download. + +After an MP search session, `下载最佳` generates a saved download plan for the current highest-scoring PT candidate. It does not download immediately; after user confirmation, execute the returned `plan_id` with `plan-execute`, route the natural text `执行计划` / `执行 plan-...`, or route the same resource number again when the plugin prompt says that number can confirm the pending plan. Then prefer `followup` so the plugin itself can decide whether the best next read is download history, lifecycle, subscribes, or transfer history. + +Even if a PT candidate scores high, the current default interaction policy is still `plan_id` first. Treat `can_auto_execute` as a score signal for explanation only; do not assume `下载1` or `下载最佳` will bypass confirmation. + +For cloud-drive result sessions, `最佳片源` is read-only. It returns the highest-scoring PanSou or HDHive resource detail and must not transfer or unlock by itself. `选择 N 详情` is also read-only. For ordinary `搜索/找 <片名>` sessions, prefer direct numbered picks first and use `计划选择 N` only when the user explicitly wants a saved confirmation plan. Use direct `选择 N` for immediate transfer/unlock after the user confirms that intent. + +For ordinary `搜索/找 <片名>` sessions, relay the plugin's original numbered list and next-step hints first. You may add a smart recommendation after the list, including a shortlist or tradeoff explanation, but do not replace, renumber, or hide the original list body. + +`mp_recommend_search` is the low-token recommendation chain. Without `choice`, it returns a recommendation list and stores the session. With `choice`, it immediately continues the selected title into `mode=mp`, `mode=hdhive`, or `mode=pansou`. + +After a recommendation list, natural-language picks are valid: + +```text +选择 1 +计划选择 1 +选择 1 盘搜 +选择1影巢 +选 2 mp +``` + +After an MP native search result, natural-language write commands are valid. They still follow the plugin's confirmation/plan rules: + +```text +下载1 +下载第1个 +订阅蜘蛛侠 +订阅并搜索蜘蛛侠 +``` + +Download task management also uses the same route. Querying tasks is read-only. Pausing, resuming, and deleting tasks are write actions and should return a saved `plan_id` first: + +```text +下载任务 +记录 +记录 蜘蛛侠 +状态 蜘蛛侠 +入库 蜘蛛侠 +整理失败 蜘蛛侠 +最近 +最近下载 +诊断 蜘蛛侠 +后续 +跟进 +跟进 蜘蛛侠 +识别 蜘蛛侠 +选择 1 +最佳片源 +下载最佳 +暂停下载 1 +恢复下载 1 +删除下载 1 +``` + +PT environment diagnostics are read-only and safe. Site results are sanitized and must not expose cookies: + +```text +站点状态 +下载器状态 +``` + +MP subscription management follows the same rule. Querying subscriptions is read-only; searching, pausing, resuming, and deleting subscriptions are write actions and should return a saved `plan_id` first: + +```text +订阅列表 +搜索订阅 1 +暂停订阅 1 +恢复订阅 1 +删除订阅 1 +``` + +Transfer/import history is read-only and safe. Use it to answer “did this land in the library?”: + +```text +入库历史 +入库失败 蜘蛛侠 +整理成功 地狱乐 +``` + +Natural-language route examples that should call recommendations: + +```text +看看最近有什么热门影视 +热门电影 +豆瓣热门电影 +正在热映 +今日番剧 +``` + +## Confirmation Rules + +Do not execute confirmation-required calls silently. + +If `recommended_recipe_detail.confirmation_message` says a step needs confirmation, show that message to the user before executing that step. + +Common confirmation points: + +- `saved_plan_execute` +- `maintain_execute` +- `pick_continue` +- `mp_search_download` +- `mp_subscribe` +- `mp_subscribe_and_search` + +## Maintenance And Health + +Use selfcheck for protocol health: + +```bash +python3 scripts/aro_request.py selfcheck +``` + +Preview maintenance without writing: + +```bash +python3 scripts/aro_request.py maintain +``` + +Execute maintenance only after confirmation: + +```bash +python3 scripts/aro_request.py maintain --execute +``` + +## Guardrails + +- Do not call HDHive, 115, Quark, or PanSou raw APIs directly when `AgentResourceOfficer` can handle the workflow. +- Do not unlock paid resources or execute write-side-effect calls without explicit confirmation. +- Respect `hdhive_resource_enabled` and `hdhive_max_unlock_points` returned by readiness/capabilities. The default point limit is 20. If a HDHive resource is above the limit or the plugin cannot confirm its points, tell the user the exact point cost/risk and ask them to raise the limit or set it to 0 before retrying. Do not bypass the guardrail. +- Prefer `include_templates=false` for low token startup. +- Use full templates only when parameters are unclear. +- Keep user-facing output short: show options, ask for a number, report result. + +## Relationship To MoviePilot-Skill + +`MoviePilot-Skill` is useful for MP native API operations such as subscriptions, downloads, sites, storage, and dashboard data. + +This skill is for the resource workflow hub: + +- HDHive search and unlock +- PanSou search +- 115 and Quark share routing +- MP native search/download/subscribe/recommendation orchestration +- cloud/PT scoring and preference-aware automation advice +- 115 login/status/pending tasks +- session recovery +- recipe-guided assistant calls + +Use both together when needed, but keep their auth modes separate: + +- AgentResourceOfficer plugin endpoints: `?apikey=MP_API_TOKEN` +- MP native API skill: usually `X-API-KEY` diff --git a/skills/agent-resource-officer/install.sh b/skills/agent-resource-officer/install.sh new file mode 100755 index 0000000..e3d78e7 --- /dev/null +++ b/skills/agent-resource-officer/install.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +CODEX_HOME_DIR="${CODEX_HOME:-"${HOME}/.codex"}" +TARGET_DIR="${CODEX_HOME_DIR}/skills/agent-resource-officer" +DRY_RUN=0 + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --target) + if [[ "$#" -lt 2 ]]; then + echo "--target requires a directory" >&2 + exit 2 + fi + TARGET_DIR="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +echo "Source: ${SCRIPT_DIR}" +echo "Repository: ${REPO_ROOT}" +echo "Target: ${TARGET_DIR}" + +TARGET_DIR="${TARGET_DIR%/}" +if [[ -z "${TARGET_DIR}" || "${TARGET_DIR}" == "/" || "${TARGET_DIR}" == "." || "${TARGET_DIR}" == "${HOME}" || "${TARGET_DIR}" == "${CODEX_HOME_DIR}" ]]; then + echo "Refusing unsafe target: ${TARGET_DIR}" >&2 + exit 2 +fi + +if [[ -e "${TARGET_DIR}" && ! -d "${TARGET_DIR}" ]]; then + echo "Refusing non-directory target: ${TARGET_DIR}" >&2 + exit 2 +fi + +if [[ "$DRY_RUN" == "1" ]]; then + echo "Dry run: no files changed." + exit 0 +fi + +mkdir -p "$(dirname "${TARGET_DIR}")" +if [[ -d "${TARGET_DIR}" && ! -f "${TARGET_DIR}/SKILL.md" ]]; then + if [[ -n "$(find "${TARGET_DIR}" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + echo "Refusing to overwrite non-skill directory: ${TARGET_DIR}" >&2 + exit 2 + fi +fi + +rm -rf "${TARGET_DIR}" +mkdir -p "${TARGET_DIR}" + +if command -v rsync >/dev/null 2>&1; then + rsync -a \ + --exclude '.DS_Store' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + "${SCRIPT_DIR}/" "${TARGET_DIR}/" +else + cp -R "${SCRIPT_DIR}/." "${TARGET_DIR}/" + find "${TARGET_DIR}" -name '.DS_Store' -delete + find "${TARGET_DIR}" -name '__pycache__' -type d -prune -exec rm -rf {} + + find "${TARGET_DIR}" -name '*.pyc' -delete +fi + +mkdir -p "${TARGET_DIR}/tools" +for tool_name in hdhive-cookie-export quark-cookie-export; do + source_tool_dir="${REPO_ROOT}/tools/${tool_name}" + target_tool_dir="${TARGET_DIR}/tools/${tool_name}" + if [[ -d "${source_tool_dir}" ]]; then + if command -v rsync >/dev/null 2>&1; then + rsync -a \ + --exclude '.DS_Store' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + "${source_tool_dir}/" "${target_tool_dir}/" + else + mkdir -p "${target_tool_dir}" + cp -R "${source_tool_dir}/." "${target_tool_dir}/" + find "${target_tool_dir}" -name '.DS_Store' -delete + find "${target_tool_dir}" -name '__pycache__' -type d -prune -exec rm -rf {} + + find "${target_tool_dir}" -name '*.pyc' -delete + fi + fi +done + +echo "Installed agent-resource-officer skill." +echo "Bundled cookie tools: ${TARGET_DIR}/tools" diff --git a/skills/agent-resource-officer/scripts/aro_request.py b/skills/agent-resource-officer/scripts/aro_request.py new file mode 100755 index 0000000..349c2c4 --- /dev/null +++ b/skills/agent-resource-officer/scripts/aro_request.py @@ -0,0 +1,2546 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.parse +import urllib.request + + +CONFIG_PATH_DISPLAY = "~/.config/agent-resource-officer/config" +CONFIG_PATH = os.path.expanduser(CONFIG_PATH_DISPLAY) +SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(os.path.dirname(SKILL_DIR)) +EXTERNAL_AGENT_GUIDE_PATH = os.path.join(SKILL_DIR, "EXTERNAL_AGENTS.md") +WORKBUDDY_GUIDE_PATH = EXTERNAL_AGENT_GUIDE_PATH +HELPER_VERSION = "0.1.46" +HELPER_COMMANDS = [ + "auto", + "calibrate", + "commands", + "config-check", + "decide", + "doctor", + "feishu-health", + "readiness", + "selftest", + "startup", + "selfcheck", + "scoring-policy", + "templates", + "route", + "pick", + "preferences", + "workflow", + "plan-execute", + "followup", + "maintain", + "recover", + "session", + "session-clear", + "sessions", + "sessions-clear", + "history", + "plans", + "plans-clear", + "raw", + "hdhive-cookie-refresh", + "hdhive-checkin-repair", + "quark-cookie-refresh", + "quark-transfer-repair", + "version", + "external-agent", + "workbuddy", +] +CALIBRATION_COMMANDS = { + "校准影视技能", + "影视技能校准", + "校准资源技能", + "资源技能校准", + "校准aro", + "aro校准", + "校准agentresourceofficer", +} +WRITE_WORKFLOWS = { + "pansou_transfer", + "hdhive_unlock", + "share_transfer", + "mp_search_download", + "mp_download_control", + "mp_subscribe", + "mp_subscribe_control", + "mp_subscribe_and_search", +} +HDHIVE_COOKIE_TOOL_DIR_CANDIDATES = [ + os.path.join(SKILL_DIR, "tools", "hdhive-cookie-export"), + os.path.join(REPO_ROOT, "tools", "hdhive-cookie-export"), + os.path.expanduser("~/Services/工具项目/影巢Cookie导出 YingChaoCookieExport"), +] +QUARK_COOKIE_TOOL_DIR_CANDIDATES = [ + os.path.join(SKILL_DIR, "tools", "quark-cookie-export"), + os.path.join(REPO_ROOT, "tools", "quark-cookie-export"), + os.path.expanduser("~/Services/工具项目/夸克Cookie导出 QuarkCookieExport"), +] + + +def read_config(): + config = {} + if not os.path.exists(CONFIG_PATH): + return config + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as file_obj: + for line in file_obj.read().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + except OSError: + return {} + return config + + +def config_value(config, *names): + for name in names: + value = os.environ.get(name) or config.get(name) + if value: + return value.strip() + return "" + + +def config_source(config, *names): + for name in names: + if os.environ.get(name): + return f"env:{name}" + if config.get(name): + return f"config:{name}" + return "" + + +def cookie_tool_setting(config, key, default=""): + value = os.environ.get(key) or config.get(key) or default + return str(value or "").strip() + + +def sanitize_cookie_tool_text(text): + safe_lines = [] + for raw_line in str(text or "").splitlines(): + line = raw_line.strip() + if not line: + continue + lowered = line.lower() + if "token=" in lowered or "csrf_access_token" in lowered or "refresh_token" in lowered: + continue + safe_lines.append(line) + return "\n".join(safe_lines) + + +def resolve_hdhive_cookie_tool_dir(config): + explicit = cookie_tool_setting(config, "ARO_HDHIVE_COOKIE_EXPORT_DIR") + candidates = [explicit] if explicit else [] + candidates.extend(HDHIVE_COOKIE_TOOL_DIR_CANDIDATES) + for candidate in candidates: + if candidate and os.path.isdir(candidate): + return candidate + return "" + + +def resolve_hdhive_cookie_tool_runtime(config): + tool_dir = resolve_hdhive_cookie_tool_dir(config) + if not tool_dir: + return {} + script_path = os.path.join(tool_dir, "export_yc_cookie.py") + if not os.path.exists(script_path): + return {} + python_bin = cookie_tool_setting(config, "ARO_HDHIVE_COOKIE_EXPORT_PYTHON") + if not python_bin: + venv_python = os.path.join(tool_dir, ".venv", "bin", "python") + python_bin = venv_python if os.path.exists(venv_python) else sys.executable + return { + "tool_dir": tool_dir, + "script_path": script_path, + "python_bin": python_bin, + "site_url": cookie_tool_setting(config, "ARO_HDHIVE_COOKIE_SITE_URL", "https://hdhive.com"), + "browser": cookie_tool_setting(config, "ARO_HDHIVE_COOKIE_BROWSER", "edge"), + "restart_container": cookie_tool_setting(config, "ARO_HDHIVE_COOKIE_RESTART_CONTAINER", "moviepilot-v2"), + } + + +def resolve_quark_cookie_tool_dir(config): + explicit = cookie_tool_setting(config, "ARO_QUARK_COOKIE_EXPORT_DIR") + candidates = [explicit] if explicit else [] + candidates.extend(QUARK_COOKIE_TOOL_DIR_CANDIDATES) + for candidate in candidates: + if candidate and os.path.isdir(candidate): + return candidate + return "" + + +def resolve_quark_cookie_tool_runtime(config): + tool_dir = resolve_quark_cookie_tool_dir(config) + if not tool_dir: + return {} + script_path = os.path.join(tool_dir, "export_quark_cookie.py") + if not os.path.exists(script_path): + return {} + python_bin = cookie_tool_setting(config, "ARO_QUARK_COOKIE_EXPORT_PYTHON") + if not python_bin: + venv_python = os.path.join(tool_dir, ".venv", "bin", "python") + python_bin = venv_python if os.path.exists(venv_python) else sys.executable + return { + "tool_dir": tool_dir, + "script_path": script_path, + "python_bin": python_bin, + "site_url": cookie_tool_setting(config, "ARO_QUARK_COOKIE_SITE_URL", "https://pan.quark.cn"), + "browser": cookie_tool_setting(config, "ARO_QUARK_COOKIE_BROWSER", "edge"), + "restart_container": cookie_tool_setting(config, "ARO_QUARK_COOKIE_RESTART_CONTAINER", "moviepilot-v2"), + } + + +def run_hdhive_cookie_refresh(config): + runtime = resolve_hdhive_cookie_tool_runtime(config) + if not runtime: + return { + "success": False, + "message": "未找到影巢 Cookie 导出工具,请先配置 ARO_HDHIVE_COOKIE_EXPORT_DIR。", + } + cmd = [ + runtime["python_bin"], + runtime["script_path"], + runtime["site_url"], + "--browser", + runtime["browser"], + "--write-mp", + "--restart-container", + runtime["restart_container"], + ] + try: + proc = subprocess.run( + cmd, + cwd=runtime["tool_dir"], + capture_output=True, + text=True, + timeout=180, + ) + except Exception as exc: + return { + "success": False, + "message": f"运行影巢 Cookie 导出工具失败:{exc}", + "tool_dir": runtime["tool_dir"], + "script_path": runtime["script_path"], + "python_bin": runtime["python_bin"], + } + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() + safe_stdout = sanitize_cookie_tool_text(stdout) + safe_stderr = sanitize_cookie_tool_text(stderr) + lines = [line.strip() for line in safe_stderr.splitlines() if line.strip()] + return { + "success": proc.returncode == 0, + "message": lines[-1] if lines else ("影巢 Cookie 已刷新并写回 MoviePilot。" if proc.returncode == 0 else ""), + "returncode": proc.returncode, + "tool_dir": runtime["tool_dir"], + "script_path": runtime["script_path"], + "python_bin": runtime["python_bin"], + "browser": runtime["browser"], + "site_url": runtime["site_url"], + "restart_container": runtime["restart_container"], + "stdout": safe_stdout, + "stderr": safe_stderr, + } + + +def run_quark_cookie_refresh(config): + runtime = resolve_quark_cookie_tool_runtime(config) + if not runtime: + return { + "success": False, + "message": "未找到夸克 Cookie 导出工具,请先配置 ARO_QUARK_COOKIE_EXPORT_DIR。", + } + cmd = [ + runtime["python_bin"], + runtime["script_path"], + runtime["site_url"], + "--browser", + runtime["browser"], + "--write-mp", + "--restart-container", + runtime["restart_container"], + ] + try: + proc = subprocess.run( + cmd, + cwd=runtime["tool_dir"], + capture_output=True, + text=True, + timeout=180, + ) + except Exception as exc: + return { + "success": False, + "message": f"运行夸克 Cookie 导出工具失败:{exc}", + "tool_dir": runtime["tool_dir"], + "script_path": runtime["script_path"], + "python_bin": runtime["python_bin"], + } + stdout = (proc.stdout or "").strip() + stderr = (proc.stderr or "").strip() + safe_stdout = sanitize_cookie_tool_text(stdout) + safe_stderr = sanitize_cookie_tool_text(stderr) + lines = [line.strip() for line in safe_stderr.splitlines() if line.strip()] + return { + "success": proc.returncode == 0, + "message": lines[-1] if lines else ("夸克 Cookie 已刷新并写回 MoviePilot。" if proc.returncode == 0 else ""), + "returncode": proc.returncode, + "tool_dir": runtime["tool_dir"], + "script_path": runtime["script_path"], + "python_bin": runtime["python_bin"], + "browser": runtime["browser"], + "site_url": runtime["site_url"], + "restart_container": runtime["restart_container"], + "stdout": safe_stdout, + "stderr": safe_stderr, + } + + +def run_hdhive_checkin_repair(base_url, api_key, config, session="", session_id=""): + refresh = run_hdhive_cookie_refresh(config) + result = { + "success": False, + "helper_version": HELPER_VERSION, + "action": "hdhive_checkin_repair", + "refresh": refresh, + } + if not refresh.get("success"): + result["message"] = refresh.get("message") or "影巢 Cookie 刷新失败" + return result, 2 + time.sleep(3) + for _ in range(6): + try: + selfcheck = request(base_url, api_key, "GET", assistant_path("selfcheck")) + selfcheck_compact = compact(selfcheck) + if bool((selfcheck_compact or {}).get("ok") or (selfcheck_compact or {}).get("success")): + break + except Exception: + pass + time.sleep(3) + body = {"text": "影巢签到", "compact": True} + if session: + body["session"] = session + if session_id: + body["session_id"] = session_id + last_error = "" + checkin = None + for attempt in range(6): + try: + checkin = request(base_url, api_key, "POST", assistant_path("route"), body=body) + break + except Exception as exc: + last_error = str(exc) + if attempt < 5: + time.sleep(3) + else: + result["message"] = f"影巢 Cookie 已刷新,但签到重试失败:{last_error}" + result["checkin"] = {"success": False, "message": last_error} + return result, 2 + checkin_compact = compact(checkin) + result["checkin"] = checkin_compact + result["message"] = ( + (checkin_compact or {}).get("message") + or ((checkin_compact or {}).get("followup_summary") or {}).get("message") + or refresh.get("message") + or "" + ) + result["success"] = bool((checkin_compact or {}).get("success")) + return result, 0 if result["success"] else 2 + + +def run_quark_transfer_repair(base_url, api_key, config, retry_text="", session="", session_id=""): + refresh = run_quark_cookie_refresh(config) + result = { + "success": False, + "helper_version": HELPER_VERSION, + "action": "quark_transfer_repair", + "refresh": refresh, + } + if not refresh.get("success"): + result["message"] = refresh.get("message") or "夸克 Cookie 刷新失败" + return result, 2 + time.sleep(3) + for _ in range(6): + try: + selfcheck = request(base_url, api_key, "GET", assistant_path("selfcheck")) + selfcheck_compact = compact(selfcheck) + if bool((selfcheck_compact or {}).get("ok") or (selfcheck_compact or {}).get("success")): + break + except Exception: + pass + time.sleep(3) + if retry_text: + body = {"text": retry_text, "compact": True} + if session: + body["session"] = session + if session_id: + body["session_id"] = session_id + last_error = "" + retried = None + for attempt in range(6): + try: + retried = request(base_url, api_key, "POST", assistant_path("route"), body=body) + break + except Exception as exc: + last_error = str(exc) + if attempt < 5: + time.sleep(3) + else: + result["message"] = f"夸克 Cookie 已刷新,但转存重试失败:{last_error}" + result["retry"] = {"success": False, "message": last_error} + return result, 2 + retried_compact = compact(retried) + result["retry"] = retried_compact + result["message"] = (retried_compact or {}).get("message") or refresh.get("message") or "" + result["success"] = bool((retried_compact or {}).get("success")) + return result, 0 if result["success"] else 2 + + last_error = "" + health = None + for attempt in range(6): + try: + health = request(base_url, api_key, "GET", "/api/v1/plugin/AgentResourceOfficer/quark/health") + break + except Exception as exc: + last_error = str(exc) + if attempt < 5: + time.sleep(3) + else: + result["message"] = f"夸克 Cookie 已刷新,但健康检查失败:{last_error}" + result["health"] = {"success": False, "message": last_error} + return result, 2 + health_compact = compact(health) + result["health"] = health_compact + payload = data_payload(health) + result["success"] = bool((payload or {}).get("quark_cookie_valid")) + result["message"] = ( + (health_compact or {}).get("message") + or ("夸克 Cookie 已刷新并通过健康检查。" if result["success"] else "夸克 Cookie 已刷新,但健康检查仍未通过。") + ) + return result, 0 if result["success"] else 2 + + +def load_json_arg(value): + if not value: + return {} + if value.startswith("@"): + with open(value[1:], "r", encoding="utf-8") as file_obj: + data = json.load(file_obj) + return data if isinstance(data, dict) else {} + data = json.loads(value) + return data if isinstance(data, dict) else {} + + +def normalize_command_args(args): + extra = list(getattr(args, "extra", []) or []) + command = str(getattr(args, "command", "") or "").strip() + + # argparse's trailing `extra` is intentionally permissive so agents can + # write `route "text" --session s1`. Pull common helper options back out + # of that tail before treating it as user text. + option_targets = { + "--session": "session", + "--session-id": "session_id", + "--plan-id": "plan_id", + "--path": "target_path", + "--api-path": "api_path", + "--method": "method", + "--json": "json_body", + } + flag_targets = { + "--json-output": "json_output", + "--summary-only": "summary_only", + "--full": "full", + "--execute": "execute", + "--confirmed": "confirmed", + } + cleaned_extra = [] + index = 0 + while index < len(extra): + item = str(extra[index]).strip() + if not item: + index += 1 + continue + if item in flag_targets: + setattr(args, flag_targets[item], True) + index += 1 + continue + if item in option_targets: + if index + 1 < len(extra): + setattr(args, option_targets[item], str(extra[index + 1]).strip()) + index += 2 + continue + matched_option = False + for option_name, attr_name in option_targets.items(): + prefix = option_name + "=" + if item.startswith(prefix): + setattr(args, attr_name, item[len(prefix):].strip()) + matched_option = True + break + if matched_option: + index += 1 + continue + cleaned_extra.append(extra[index]) + index += 1 + extra = cleaned_extra + + if command == "route": + if not getattr(args, "text", None) and extra: + args.text = " ".join(extra).strip() + elif command == "pick": + if getattr(args, "choice", None) is None and extra: + first = extra.pop(0) + try: + args.choice = int(str(first).strip()) + except (TypeError, ValueError): + if not getattr(args, "action", None): + args.action = str(first).strip() + if not getattr(args, "action", None) and extra: + args.action = " ".join(str(item).strip() for item in extra if str(item).strip()).strip() + elif command == "plan-execute": + if not getattr(args, "plan_id", None) and extra: + args.plan_id = str(extra[0]).strip() + elif command == "followup": + if not getattr(args, "plan_id", None) and extra: + first = str(extra[0]).strip() + if first.startswith("plan-"): + args.plan_id = first + elif command == "workflow": + if getattr(args, "workflow", "hdhive_candidates") == "hdhive_candidates" and extra: + args.workflow = str(extra.pop(0)).strip() or args.workflow + if not getattr(args, "keyword", None) and extra: + args.keyword = " ".join(str(item).strip() for item in extra if str(item).strip()).strip() + elif command in {"session", "session-clear", "history"}: + if not getattr(args, "session", None) and extra: + args.session = str(extra[0]).strip() + elif command in {"plans", "plans-clear"}: + if not getattr(args, "plan_id", None) and extra: + first = str(extra[0]).strip() + if first.startswith("plan-"): + args.plan_id = first + + return args + + +def external_agent_payload(): + prompt = ( + "你是外部智能体,通过 AgentResourceOfficer 控制 MoviePilot 资源工作流。" + "不要直接调用影巢、115、夸克或盘搜原始 API。" + "MCP 只用于插件列表、下载器状态、站点状态、历史记录这类管理查询;片名资源搜索不要先走 MCP。" + "每个新会话先调用 startup 或 readiness;普通用户指令走 route;" + "如果 preferences 未初始化,先询问并保存片源偏好;" + "用户明确说 MP搜索、MP 搜索、PT搜索 或 PT 搜索时,第一步只能原样 route,不要先 search_media、search_torrents、TMDB、raw API 或 MCP,不要改写成盘搜、云盘或智能搜索。" + "用户明确说智能搜索、资源决策或智能决策时,才使用跨来源智能决策。" + "普通搜索和明确来源命令都先原样 route;不要自己轮询盘搜、影巢和 MP/PT。" + "云盘和 PT 使用不同评分规则:云盘看质量/完整度/字幕/影巢积分,PT 看做种/促销/质量/字幕。" + "编号选择走 pick;写入动作遵守 dry_run、plan_id、execute 的确认流程。" + "route/pick/workflow/plan-execute/followup 返回 compact JSON 时,优先读取顶层 command_source、preferred_command、fallback_command、compact_commands 作为下一步。" + "展示盘搜/云盘资源列表时,先保留原始分组、编号和真实换行;日期保留时钟标记如 🕒05/07;Markdown 前端可用 text 代码块防止夸克列表挤成一段。" + "列表后可以追加“智能建议”,可自然分析取舍且不限制长短;引用原编号推荐最优项,但不能用建议替代原始列表;不要把评分公式或加分项原样展示成理由。" + "输出时只展示用户需要选择或执行的信息,不回显 API Key、Cookie、Token。" + ) + return { + "success": True, + "schema_version": "external_agent.v1", + "helper_version": HELPER_VERSION, + "guide_file": EXTERNAL_AGENT_GUIDE_PATH, + "guide_file_exists": os.path.exists(EXTERNAL_AGENT_GUIDE_PATH), + "recommended_recipe": "external_agent", + "recipe_command": "python3 scripts/aro_request.py templates --recipe external_agent --compact", + "preferences_recipe_command": "python3 scripts/aro_request.py templates --recipe preferences --compact", + "smart_search_recipe_command": "python3 scripts/aro_request.py templates --recipe smart_search --compact", + "smart_decision_route_command": "python3 scripts/aro_request.py route '资源决策 <片名>' --session 'agent:<会话ID>' --summary-only", + "smart_decision_recipe_command": "python3 scripts/aro_request.py templates --recipe smart_decision --compact", + "smart_search_plan_recipe_command": "python3 scripts/aro_request.py templates --recipe smart_search_plan --compact", + "smart_search_execute_recipe_command": "python3 scripts/aro_request.py templates --recipe smart_search_execute --compact", + "mp_pt_recipe_command": "python3 scripts/aro_request.py templates --recipe mp_pt --compact", + "mp_recommend_recipe_command": "python3 scripts/aro_request.py templates --recipe recommend --compact", + "post_execute_recipe_command": "python3 scripts/aro_request.py templates --recipe followup --compact", + "local_ingest_recipe_command": "python3 scripts/aro_request.py templates --recipe local_ingest --compact", + "startup_command": "python3 scripts/aro_request.py startup", + "route_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>'", + "pick_command": "python3 scripts/aro_request.py pick <编号> --session 'agent:<会话ID>'", + "json_output_rule": "route/pick 默认输出适合聊天转发的纯文本 message;需要程序化解析时显式加 --json-output。", + "followup_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>'", + "next_command_rule": "优先读取 compact 主响应顶层的 preferred_command、fallback_command、compact_commands;只有这些字段为空时,再回退到 error_summary / followup_summary / score_summary.decision。", + "auto_continue_rule": "如果 summary-only 输出里 recommended_agent_behavior=auto_continue 或 auto_continue_then_wait_confirmation,则可以直接执行 auto_run_command;如果是 wait_user_confirmation,则先向用户展示 confirm_command;如果是 stop,则不要继续自动执行。", + "execution_policy_contract": { + "auto_continue": "直接执行 auto_run_command。", + "auto_continue_then_wait_confirmation": "先执行 auto_run_command,再停止并向用户展示 confirm_command。", + "wait_user_confirmation": "不要自动执行;先向用户展示 confirm_command 或 display_command。", + "show_only": "只展示 display_command,不要自动继续。", + "stop": "当前没有适合自动继续的命令,不要继续执行。", + }, + "execution_loop_contract": [ + { + "step": "startup", + "command": "python3 scripts/aro_request.py startup", + "purpose": "检查插件状态并拿到推荐 recipe。", + }, + { + "step": "decide", + "command": "python3 scripts/aro_request.py decide --summary-only", + "purpose": "读取下一步 helper 决策摘要。", + }, + { + "step": "route", + "command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", + "purpose": "处理搜索、链接、状态查询等主入口。", + }, + { + "step": "policy", + "command": "读取 recommended_agent_behavior / auto_run_command / confirm_command", + "purpose": "按统一 5 类执行范式决定自动继续、确认或停止。", + }, + { + "step": "followup", + "command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", + "purpose": "执行计划后继续追踪下载、入库或失败诊断。", + }, + ], + "entry_patterns": { + "external_agent": { + "label": "外部智能体", + "start_with": "startup", + "decide_with": "decide --summary-only", + "route_with": "route --summary-only", + "followup_with": "followup --summary-only", + "notes": "WorkBuddy、Hermes、OpenClaw(小龙虾)优先使用这套 Skill/helper。", + }, + "mp_builtin_agent": { + "label": "MP 内置智能体", + "start_with": "assistant/request_templates", + "decide_with": "agent_resource_officer_request_templates", + "route_with": "agent_resource_officer_smart_entry", + "followup_with": "agent_resource_officer_execution_followup", + "notes": "优先调用 Agent Tool / request_templates,不在模型侧直拼资源接口。", + }, + "feishu_channel": { + "label": "飞书入口", + "start_with": "飞书消息进入内置 Channel", + "decide_with": "插件内置命令解析", + "route_with": "route / pick / followup", + "followup_with": "followup --summary-only", + "notes": "飞书是消息入口,不单独维护另一套状态机。", + }, + }, + "entry_playbooks": { + "external_agent": { + "label": "外部智能体最小执行流", + "steps": [ + { + "step": "startup", + "helper_command": "python3 scripts/aro_request.py startup", + "purpose": "读取启动状态、恢复建议和推荐 recipe。", + }, + { + "step": "decide", + "helper_command": "python3 scripts/aro_request.py decide --summary-only", + "purpose": "决定继续会话、初始化偏好还是直接进入 route。", + }, + { + "step": "route", + "helper_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", + "purpose": "执行自然语言主入口。", + }, + { + "step": "followup", + "helper_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", + "purpose": "执行计划后继续追踪下载、入库或失败诊断。", + }, + ], + }, + "mp_builtin_agent": { + "label": "MP 内置智能体最小执行流", + "steps": [ + { + "step": "request_templates", + "tool": "agent_resource_officer_request_templates", + "purpose": "读取最小流程、确认策略和推荐入口。", + }, + { + "step": "route", + "tool": "agent_resource_officer_smart_entry", + "purpose": "处理搜索、链接、登录状态等主入口。", + }, + { + "step": "followup", + "tool": "agent_resource_officer_execution_followup", + "purpose": "执行计划后继续查看下载、入库和失败状态。", + }, + ], + }, + "feishu_channel": { + "label": "飞书入口最小执行流", + "steps": [ + {"step": "message_in", "purpose": "用户消息进入内置 Channel。"}, + {"step": "route", "purpose": "复用同一套 assistant 协议,不维护单独状态机。"}, + {"step": "reply", "purpose": "按确认策略回消息、展示编号或提示下一步。"}, + ], + }, + }, + "orchestration_contract": { + "service_role": "Agent影视助手 / AgentResourceOfficer 负责服务端能力执行。", + "client_role": "外部智能体、MP 内置智能体、飞书入口负责客户端调度与展示。", + "recommended_first_call": "startup", + "recommended_decision_call": "decide --summary-only", + "recommended_route_call": "route --summary-only", + "recommended_followup_call": "followup --summary-only", + "recommended_read_fields": [ + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "preferred_command", + "compact_commands", + ], + "confirmation_rule": "写入动作默认确认制;只有明确标记可自动继续的只读步骤才自动续跑。", + }, + "compat_aliases": ["workbuddy"], + "deprecated_aliases": ["workbuddy"], + "prompt": prompt, + "tools": [ + { + "name": "startup", + "purpose": "检查插件状态、恢复建议和低 token recipe。", + "command": "python3 scripts/aro_request.py startup", + "writes": False, + }, + { + "name": "route_text", + "purpose": "处理自然语言资源指令、链接转存、搜索和登录状态查询。", + "command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>'", + "json_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --json-output", + "writes": "depends_on_route", + }, + { + "name": "pick_continue", + "purpose": "继续编号选择、详情、审查、下一页等会话动作。", + "command": "python3 scripts/aro_request.py pick <编号> --session 'agent:<会话ID>'", + "json_command": "python3 scripts/aro_request.py pick <编号> --session 'agent:<会话ID>' --json-output", + "writes": "depends_on_choice", + }, + { + "name": "execution_followup", + "purpose": "在执行计划后自动追踪下载、订阅或入库状态。", + "command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>'", + "writes": False, + }, + ], + } + + +def compact(data): + if isinstance(data, dict): + payload = data.get("data") if isinstance(data.get("data"), dict) else data + keys = [ + "success", + "message", + "version", + "ok", + "action", + "recommended_recipe", + "selected_recipe", + "requested_recipe", + "invalid_recipe", + "selected_names", + "session", + "session_id", + "plan_id", + "workflow", + "plugin_version", + "plugin_enabled", + "services", + "warnings", + "defaults", + "enabled", + "running", + "sdk_available", + "app_id_configured", + "app_secret_configured", + "verification_token_configured", + "allow_all", + "reply_enabled", + "allowed_chat_count", + "allowed_user_count", + "command_mode", + "alias_count", + "legacy_bridge_running", + "conflict_warning", + "ready_to_start", + "safe_to_enable", + "missing_requirements", + "migration_hint", + "recommended_action", + "follow_up_hint", + "p115_ready", + "p115_direct_ready", + "hdhive_configured", + "quark_configured", + "quark_cookie_configured", + "quark_cookie_valid", + "default_target_path", + "plan_auto_selected", + "execute_plan_body", + "executed", + "removed", + "removed_count", + "cleared", + "cleared_count", + "cleared_session_ids", + "matched", + "remaining", + "has_pending", + "recommended_request_templates", + "recommended_recipe_detail", + "next_actions", + "recovery", + "scoring_policy", + "preference_status", + "score_summary", + "decision_summary", + "best_candidate", + "sources_checked", + "available_sources", + "blocked_sources", + "decision_mode", + "decision_reason", + "smart_plan_auto_selected", + "smart_execute_auto_selected", + "error_summary", + "diagnosis_summary", + "followup_summary", + "preferences", + "needs_onboarding", + "initialized", + "command_source", + "command_policy", + "preferred_requires_confirmation", + "fallback_requires_confirmation", + "can_auto_run_preferred", + "preferred_command", + "fallback_command", + "compact_commands", + "recommended_agent_behavior", + "auto_run_command", + "confirm_command", + "display_command", + "detail_short_command", + "plan_short_command", + "confirm_short_command", + ] + out = {key: data.get(key) for key in ["success", "message"] if key in data} + for key in keys: + if key in payload: + out[key] = payload.get(key) + if out: + return out + return data + + +def data_payload(result): + if isinstance(result, dict) and isinstance(result.get("data"), dict): + return result.get("data") + return result + + +def request(base_url, api_key, method, path, body=None, query=None): + query_items = list((query or {}).items()) + query_items.append(("apikey", api_key)) + url = base_url.rstrip("/") + "/" + path.lstrip("/") + url = url + "?" + urllib.parse.urlencode(query_items) + data = None + headers = {} + if body is not None: + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url, data=data, method=method.upper(), headers=headers) + with urllib.request.urlopen(req, timeout=120) as resp: + raw = resp.read() + try: + return json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + return {"raw": raw.decode("utf-8", errors="replace")} + + +def assistant_path(name): + return f"/api/v1/plugin/AgentResourceOfficer/assistant/{name}" + + +def print_json(data): + print(json.dumps(data, ensure_ascii=False, indent=2)) + + +def summary_command(summary, confirmed=False): + summary = summary or {} + explicit_behavior = str(summary.get("recommended_agent_behavior") or "").strip() + auto_run_command = str(summary.get("auto_run_command") or "").strip() + confirm_command = str(summary.get("confirm_command") or "").strip() + display_command = str(summary.get("display_command") or "").strip() + auto_run_short_command = str(summary.get("auto_run_short_command") or "").strip() + confirm_short_command = str(summary.get("confirm_short_command") or "").strip() + display_short_command = str(summary.get("display_short_command") or "").strip() + if explicit_behavior: + if confirmed and confirm_command: + return confirm_short_command or confirm_command + if explicit_behavior in {"auto_continue", "auto_continue_then_wait_confirmation"} and auto_run_command: + return auto_run_short_command or auto_run_command + if explicit_behavior == "wait_user_confirmation": + return confirm_short_command or confirm_command or display_short_command or display_command + if explicit_behavior == "show_only": + return display_short_command or display_command or auto_run_short_command or auto_run_command or confirm_short_command or confirm_command + if explicit_behavior == "stop": + return "" + if auto_run_command or confirm_command or display_command: + return auto_run_short_command or auto_run_command or confirm_short_command or confirm_command or display_short_command or display_command + preferred_command = str(summary.get("preferred_command") or "").strip() + fallback_command = str(summary.get("fallback_command") or "").strip() + preferred_short_command = str(summary.get("preferred_short_command") or "").strip() + fallback_short_command = str(summary.get("fallback_short_command") or "").strip() + preferred_requires_confirmation = bool(summary.get("preferred_requires_confirmation")) + fallback_requires_confirmation = bool(summary.get("fallback_requires_confirmation")) + if preferred_command: + if confirmed and fallback_command and fallback_requires_confirmation: + return fallback_short_command or fallback_command + if not confirmed and preferred_requires_confirmation: + return preferred_short_command or preferred_command + return preferred_short_command or preferred_command + if "first_requires_confirmation" in summary: + requires_confirmation = bool(summary.get("first_requires_confirmation")) + else: + requires_confirmation = bool(summary.get("requires_confirmation")) + command = str(summary.get("execute_helper_command") or "").strip() + if requires_confirmation and not confirmed: + command = str(summary.get("inspect_helper_command") or command).strip() + if not command: + command = str(summary.get("inspect_helper_command") or "").strip() + return command + + +def compact_command_summary(output): + payload = output if isinstance(output, dict) else {} + compact_commands = [ + str(item).strip() + for item in (payload.get("compact_commands") or []) + if str(item).strip() + ] + preferred_command = str(payload.get("preferred_command") or "").strip() + fallback_command = str(payload.get("fallback_command") or "").strip() + if preferred_command and not compact_commands: + compact_commands = [preferred_command] + if fallback_command: + compact_commands.append(fallback_command) + action = str(payload.get("action") or "").strip() + write_effect = str(payload.get("write_effect") or "").strip() + ok = bool(payload.get("ok")) if "ok" in payload else bool(payload.get("success")) + session = str(payload.get("session") or "").strip() + session_id = str(payload.get("session_id") or "").strip() + detail_short_command = str(payload.get("detail_short_command") or "").strip() + plan_short_command = str(payload.get("plan_short_command") or "").strip() + confirm_short_command = str(payload.get("confirm_short_command") or "").strip() + auto_run_command = str(payload.get("auto_run_command") or "").strip() + confirm_command = str(payload.get("confirm_command") or "").strip() + display_command = str(payload.get("display_command") or "").strip() + auto_run_short_command = detail_short_command if detail_short_command and auto_run_command in {"先看详情", "详情"} else "" + display_short_command = detail_short_command if detail_short_command and display_command in {"先看详情", "详情"} else "" + confirm_command_map = { + "执行最佳": confirm_short_command, + "确认执行": confirm_short_command, + "计划最佳": plan_short_command, + "先计划": plan_short_command, + } + confirm_short_for_explicit = confirm_command_map.get(confirm_command, "") + preferred_short_command = "" + fallback_short_command = "" + if preferred_command in {"先看详情", "详情"}: + preferred_short_command = detail_short_command + elif preferred_command in {"计划最佳", "先计划", "计划"}: + preferred_short_command = plan_short_command + elif preferred_command in {"执行最佳", "确认执行", "确认"}: + preferred_short_command = confirm_short_command + if fallback_command in {"先看详情", "详情"}: + fallback_short_command = detail_short_command + elif fallback_command in {"计划最佳", "先计划", "计划"}: + fallback_short_command = plan_short_command + elif fallback_command in {"执行最佳", "确认执行", "确认"}: + fallback_short_command = confirm_short_command + summary = { + "success": bool(payload.get("success", ok)), + "ok": ok, + "action": action, + "write_effect": write_effect, + "command_source": str(payload.get("command_source") or "").strip(), + "command_policy": str(payload.get("command_policy") or "").strip(), + "preferred_requires_confirmation": bool(payload.get("preferred_requires_confirmation")), + "fallback_requires_confirmation": bool(payload.get("fallback_requires_confirmation")), + "can_auto_run_preferred": bool(payload.get("can_auto_run_preferred")) if "can_auto_run_preferred" in payload else write_effect != "write", + "preferred_command": preferred_command or (compact_commands[0] if compact_commands else ""), + "fallback_command": fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), + "preferred_short_command": preferred_short_command, + "fallback_short_command": fallback_short_command, + "compact_commands": compact_commands[:2], + "preferred_helper_command": helper_route_command(preferred_command or (compact_commands[0] if compact_commands else ""), session=session, session_id=session_id), + "fallback_helper_command": helper_route_command(fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), session=session, session_id=session_id), + "requires_confirmation": write_effect == "write", + "message_head": str(payload.get("message") or payload.get("message_head") or "").strip(), + "recommended_agent_behavior": str(payload.get("recommended_agent_behavior") or "").strip(), + "auto_run_command": auto_run_command, + "confirm_command": confirm_command, + "display_command": display_command, + "auto_run_short_command": auto_run_short_command, + "display_short_command": display_short_command, + "detail_short_command": detail_short_command, + "plan_short_command": plan_short_command, + "confirm_short_command": confirm_short_for_explicit or confirm_short_command, + } + summary.update(command_execution_policy(summary)) + return summary + + +def command_execution_policy(summary): + summary = summary if isinstance(summary, dict) else {} + explicit_behavior = str(summary.get("recommended_agent_behavior") or "").strip() + explicit_auto = str(summary.get("auto_run_command") or "").strip() + explicit_confirm = str(summary.get("confirm_command") or "").strip() + explicit_display = str(summary.get("display_command") or "").strip() + if explicit_behavior: + return { + "recommended_agent_behavior": explicit_behavior, + "auto_run_command": explicit_auto, + "confirm_command": explicit_confirm, + "display_command": explicit_display or explicit_auto or explicit_confirm, + "stop_after_auto": explicit_behavior == "auto_continue_then_wait_confirmation", + "reason": "优先使用服务端返回的执行策略。", + "execution_reason": "优先使用服务端返回的执行策略。", + } + preferred_command = str(summary.get("preferred_command") or "").strip() + fallback_command = str(summary.get("fallback_command") or "").strip() + preferred_requires_confirmation = bool(summary.get("preferred_requires_confirmation")) + fallback_requires_confirmation = bool(summary.get("fallback_requires_confirmation")) + can_auto_run_preferred = bool(summary.get("can_auto_run_preferred")) + + if preferred_command and can_auto_run_preferred and not preferred_requires_confirmation: + if fallback_command and fallback_requires_confirmation: + return { + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": preferred_command, + "confirm_command": fallback_command, + "display_command": preferred_command, + "stop_after_auto": True, + "reason": "首选命令是安全读步骤,可自动继续;后续备选命令涉及确认。", + "execution_reason": "首选命令是安全读步骤,可自动继续;后续备选命令涉及确认。", + } + return { + "recommended_agent_behavior": "auto_continue", + "auto_run_command": preferred_command, + "confirm_command": "", + "display_command": preferred_command, + "stop_after_auto": False, + "reason": "首选命令是安全读步骤,可直接继续。", + "execution_reason": "首选命令是安全读步骤,可直接继续。", + } + + if preferred_command and preferred_requires_confirmation: + return { + "recommended_agent_behavior": "wait_user_confirmation", + "auto_run_command": "", + "confirm_command": preferred_command, + "display_command": preferred_command, + "stop_after_auto": False, + "reason": "首选命令本身需要用户确认,不能自动执行。", + "execution_reason": "首选命令本身需要用户确认,不能自动执行。", + } + + if preferred_command: + return { + "recommended_agent_behavior": "show_only", + "auto_run_command": "", + "confirm_command": "", + "display_command": preferred_command, + "stop_after_auto": False, + "reason": "已有首选命令,但当前不建议自动执行。", + "execution_reason": "已有首选命令,但当前不建议自动执行。", + } + + if fallback_command and fallback_requires_confirmation: + return { + "recommended_agent_behavior": "wait_user_confirmation", + "auto_run_command": "", + "confirm_command": fallback_command, + "display_command": fallback_command, + "stop_after_auto": False, + "reason": "仅存在需要确认的备选命令。", + "execution_reason": "仅存在需要确认的备选命令。", + } + + if fallback_command: + return { + "recommended_agent_behavior": "show_only", + "auto_run_command": "", + "confirm_command": "", + "display_command": fallback_command, + "stop_after_auto": False, + "reason": "仅存在备选命令,建议先展示。", + "execution_reason": "仅存在备选命令,建议先展示。", + } + + return { + "recommended_agent_behavior": "stop", + "auto_run_command": "", + "confirm_command": "", + "display_command": "", + "stop_after_auto": False, + "reason": "当前没有可继续执行的短命令。", + "execution_reason": "当前没有可继续执行的短命令。", + } + + +def helper_summary_execution_policy(summary): + summary = summary if isinstance(summary, dict) else {} + if summary.get("preferred_command") or summary.get("fallback_command"): + return command_execution_policy(summary) + + inspect_command = str(summary.get("inspect_helper_command") or "").strip() + execute_command = str(summary.get("execute_helper_command") or "").strip() + if "first_requires_confirmation" in summary: + first_requires_confirmation = bool(summary.get("first_requires_confirmation")) + else: + first_requires_confirmation = bool(summary.get("requires_confirmation")) + requires_confirmation = bool(summary.get("requires_confirmation")) + + if execute_command and not first_requires_confirmation: + if requires_confirmation and inspect_command: + return { + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": execute_command, + "confirm_command": inspect_command, + "display_command": execute_command, + "stop_after_auto": True, + "reason": "当前步骤可直接执行,但后续链路存在确认动作。", + "execution_reason": "当前步骤可直接执行,但后续链路存在确认动作。", + } + return { + "recommended_agent_behavior": "auto_continue", + "auto_run_command": execute_command, + "confirm_command": "", + "display_command": execute_command, + "stop_after_auto": False, + "reason": "当前步骤可直接执行。", + "execution_reason": "当前步骤可直接执行。", + } + + if inspect_command: + return { + "recommended_agent_behavior": "wait_user_confirmation" if requires_confirmation else "show_only", + "auto_run_command": "", + "confirm_command": inspect_command if requires_confirmation else "", + "display_command": inspect_command, + "stop_after_auto": False, + "reason": "当前应先展示检查或确认命令。", + "execution_reason": "当前应先展示检查或确认命令。", + } + + return { + "recommended_agent_behavior": "stop", + "auto_run_command": "", + "confirm_command": "", + "display_command": "", + "stop_after_auto": False, + "reason": "当前没有可继续执行的 helper 命令。", + "execution_reason": "当前没有可继续执行的 helper 命令。", + } + + +def print_summary(summary, command_only=False, confirmed=False): + if isinstance(summary, dict): + policy = helper_summary_execution_policy(summary) + if "reason" in summary and policy.get("reason"): + policy = { + **policy, + "execution_reason": policy.get("execution_reason") or policy.get("reason") or "", + } + policy.pop("reason", None) + summary = {**summary, **policy} + if command_only: + print(summary_command(summary, confirmed=confirmed)) + return + print_json(summary) + + +def shell_quote(value): + text = str(value or "") + return "'" + text.replace("'", "'\"'\"'") + "'" + + +def normalize_calibration_text(text): + return ( + str(text or "") + .strip() + .lower() + .replace(" ", "") + .replace("\u3000", "") + .replace("-", "") + .replace("_", "") + ) + + +def is_calibration_text(text): + normalized = normalize_calibration_text(text) + return normalized in CALIBRATION_COMMANDS + + +def calibration_payload(): + return { + "success": True, + "action": "calibrate_media_skill", + "helper_version": HELPER_VERSION, + "message": "影视技能已校准。", + "read_files": [ + "skills/agent-resource-officer/SKILL.md", + "skills/agent-resource-officer/EXTERNAL_AGENTS.md", + ], + "recommended_command": "python3 scripts/aro_request.py calibrate", + "route_alias": "python3 scripts/aro_request.py route '校准影视技能'", + "agent_instruction": "重新加载 agent-resource-officer skill 的资源流语义;日常执行不必每次完整读文件,但会话压缩、行为漂移、命令边界不确定时必须重新校准。", + "hard_rules": [ + "资源流必须走 agent-resource-officer skill/helper,不要自己改写成 MCP、curl、TMDB 或底层网盘 API。", + "下载 <片名> = MoviePilot 原生 MP/PT;片名不明确先选影片,片名明确后直接生成最多 3 个最佳 PT 候选的待确认下载方案,不展示完整 PT 列表、不走云盘、不自动提交真实下载。", + "下载候选影片列表出来后,必须保持同一个 helper session,把用户回复的候选编号原样 route 回去;不要改写成 下载 <候选标题 年份>。", + "下载链路如果选定影片后没有 PT 资源,只能报告无 PT 可下载;不能自动补查盘搜、影巢、夸克或 115。", + "只有在当前会话刚生成待确认下载计划后,用户回复 执行计划 或同一个资源编号,才可以确认执行该计划;没有待确认计划时,裸编号不能当成下载许可。", + "转存 <片名> = 115转存;只有明确说 夸克转存 <片名> 才走夸克。", + "云盘搜索 <片名> = 盘搜 + 影巢;盘搜搜索、影巢搜索、MP搜索、PT搜索 必须保留原命令语义。", + "MP搜索/PT搜索 后面带 给我最新集、最新集、最新一集 时,仍然原样 route;插件会只展示最高集数候选,不要把上一批旧集数混回摘要。", + "MP搜索/PT搜索/下载 后面带 第4集、第四集、E04、S01E04 时,仍然原样 route;插件会只展示包含该目标集的候选并安全重编号。", + "MP搜索/PT搜索 最新集 如果先返回影片候选,后续编号必须继续使用同一个 helper session;不要新开会话裸跑 route \"1\",否则会丢失最新集过滤上下文。", + "下载/MP搜索/PT搜索 返回 PT 资源列表时,必须原样展示插件 message 里的编号资源,不要压缩成“PT资源已列出”。", + "MP/PT 结果列表不能重新编号;插件返回 2、4、21、29 就原样显示并用这些编号继续选择/下载,不能改成 1、2、3、4,也不要追加自己的选择提示尾巴。", + "编号详情、15详情、十六详情、选择 15 详情 = 只读详情,不能执行转存或下载。", + "n / 下一页 = 继续当前会话翻页;不要重新搜索导致编号变化。", + "不确定时先问或先看详情,不要凭记忆改路由。", + ], + "response_contract": "校准完成后,对用户只需回复:影视技能已校准。", + } + + +def format_calibration_text(payload): + lines = [ + payload.get("message") or "影视技能已校准。", + "", + "请立即按 agent-resource-officer skill 的固定语义执行资源流:", + ] + for rule in payload.get("hard_rules") or []: + lines.append(f"- {rule}") + lines.extend([ + "", + "需要重新读取时优先看:", + "1. skills/agent-resource-officer/SKILL.md", + "2. skills/agent-resource-officer/EXTERNAL_AGENTS.md", + "", + payload.get("response_contract") or "校准完成后,对用户只需回复:影视技能已校准。", + ]) + return "\n".join(lines) + + +def helper_route_command(command, session="", session_id=""): + command_text = str(command or "").strip() + if not command_text: + return "" + session_part = f" --session {shell_quote(session)}" if str(session or "").strip() else "" + session_id_part = f" --session-id {shell_quote(session_id)}" if str(session_id or "").strip() else "" + return f"python3 scripts/aro_request.py route {shell_quote(command_text)}{session_part}{session_id_part}" + + +def recovery_helper_commands(recovery): + recovery = recovery if isinstance(recovery, dict) else {} + template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else {} + body = template.get("body") if isinstance(template.get("body"), dict) else {} + action_body = template.get("action_body") if isinstance(template.get("action_body"), dict) else {} + name = str(template.get("name") or action_body.get("name") or "").strip() + session = str(body.get("session") or action_body.get("session") or "").strip() + session_id = str(body.get("session_id") or action_body.get("session_id") or "").strip() + plan_id = str(body.get("plan_id") or action_body.get("plan_id") or "").strip() + target_path = str(body.get("path") or action_body.get("path") or "").strip() + + session_part = f" --session {shell_quote(session)}" if session else "" + session_id_part = f" --session-id {shell_quote(session_id)}" if session_id else "" + plan_id_part = f" --plan-id {shell_quote(plan_id)}" if plan_id else "" + path_part = f" --path {shell_quote(target_path)}" if target_path else "" + + inspect = None + if session or session_id: + inspect = ( + "python3 scripts/aro_request.py session" + f"{session_part}{session_id_part}" + ) + + execute = None + if name == "pick_hdhive_candidate": + execute = ( + "python3 scripts/aro_request.py pick" + f"{session_part}{session_id_part}{path_part} --choice <编号>" + ) + elif name == "pick_hdhive_resource": + execute = ( + "python3 scripts/aro_request.py pick" + f"{session_part}{session_id_part}{path_part} --choice <资源编号>" + ) + elif name == "pick_pansou_result": + execute = ( + "python3 scripts/aro_request.py pick" + f"{session_part}{session_id_part}{path_part} --choice <编号>" + ) + elif name == "resume_pending_115": + execute = ( + "python3 scripts/aro_request.py recover" + f"{session_part}{session_id_part} --execute" + ) + elif name in {"execute_plan", "execute_latest_plan", "execute_session_latest_plan"}: + execute = ( + "python3 scripts/aro_request.py plan-execute" + f"{session_part}{session_id_part}{plan_id_part}" + ) + elif name == "inspect_session_state": + execute = inspect + + return { + "inspect_helper_command": inspect or "", + "execute_helper_command": execute or "", + } + + +def recovery_can_resume(recovery, helper_commands=None): + recovery = recovery if isinstance(recovery, dict) else {} + helper_commands = helper_commands if isinstance(helper_commands, dict) else recovery_helper_commands(recovery) + mode = str(recovery.get("mode") or "").strip() + if mode == "start_new": + return False + return bool(recovery.get("can_resume")) and bool(helper_commands.get("execute_helper_command")) + + +def request_templates_summary(data): + payload = data_payload(data) + detail = payload.get("recommended_recipe_detail") if isinstance(payload, dict) and isinstance(payload.get("recommended_recipe_detail"), dict) else {} + first_call = detail.get("first_call") if isinstance(detail, dict) and isinstance(detail.get("first_call"), dict) else {} + return { + "selected_recipe": payload.get("selected_recipe") or payload.get("recommended_recipe") or "", + "recommended_recipe": payload.get("recommended_recipe") or "", + "first_template": detail.get("first_template") or "", + "first_endpoint": first_call.get("endpoint") or "", + "first_method": first_call.get("method") or "", + "first_requires_confirmation": bool(first_call.get("requires_confirmation")), + "requires_confirmation": bool(detail.get("confirmation_required_templates")), + "confirmation_message": detail.get("confirmation_message") or "", + "orchestration_contract": payload.get("orchestration_contract") or detail.get("orchestration_contract") or {}, + "entry_patterns": payload.get("entry_patterns") or detail.get("entry_patterns") or {}, + "entry_playbooks": payload.get("entry_playbooks") or detail.get("entry_playbooks") or {}, + } + + +def recipe_helper_commands(recipe_summary, recipe_request): + recipe_summary = recipe_summary if isinstance(recipe_summary, dict) else {} + recipe_request = str(recipe_request or "").strip() + first_template = str(recipe_summary.get("first_template") or "").strip() + first_method = str(recipe_summary.get("first_method") or "").strip().upper() + first_endpoint = str(recipe_summary.get("first_endpoint") or "").strip() + + inspect = "" + if recipe_request: + inspect = ( + "python3 scripts/aro_request.py templates" + f" --recipe {shell_quote(recipe_request)} --policy-only" + ) + + execute = "" + if first_template == "startup_probe": + execute = "python3 scripts/aro_request.py startup" + elif first_template == "selfcheck_probe": + execute = "python3 scripts/aro_request.py selfcheck" + elif first_template == "maintain_preview": + execute = "python3 scripts/aro_request.py maintain" + elif first_template == "maintain_execute": + execute = "python3 scripts/aro_request.py maintain --execute" + elif first_template == "saved_plan_execute": + execute = "python3 scripts/aro_request.py plan-execute" + elif first_template == "execution_followup": + execute = "python3 scripts/aro_request.py followup" + elif first_template == "pick_continue": + execute = "python3 scripts/aro_request.py recover --execute" + elif first_template == "preferences_get": + execute = "python3 scripts/aro_request.py preferences" + elif first_template == "scoring_policy": + execute = "python3 scripts/aro_request.py scoring-policy" + elif first_template == "workflow_dry_run": + execute = "python3 scripts/aro_request.py workflow --workflow --keyword " + elif first_template == "smart_search": + execute = "python3 scripts/aro_request.py workflow --workflow smart_resource_search --keyword " + elif first_template == "smart_decision": + execute = "python3 scripts/aro_request.py workflow --workflow smart_resource_decision --keyword " + elif first_template == "smart_search_plan": + execute = "python3 scripts/aro_request.py workflow --workflow smart_resource_plan --keyword " + elif first_template == "smart_search_execute": + execute = "python3 scripts/aro_request.py workflow --workflow smart_resource_execute --keyword " + elif first_template == "mp_media_detail": + execute = "python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword " + elif first_template == "mp_search": + execute = "python3 scripts/aro_request.py workflow --workflow mp_search --keyword " + elif first_template == "mp_search_detail": + execute = "python3 scripts/aro_request.py workflow --workflow mp_search_detail --keyword --choice <编号>" + elif first_template == "mp_search_best": + execute = "python3 scripts/aro_request.py workflow --workflow mp_search_best --keyword " + elif first_template == "mp_search_download_plan": + execute = "python3 scripts/aro_request.py workflow --workflow mp_search_download --keyword --choice <编号>" + elif first_template == "mp_recommend": + execute = "python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20" + elif first_template == "mp_recommend_search": + execute = "python3 scripts/aro_request.py workflow --workflow mp_recommend_search --source tmdb_trending --media-type all --choice <编号> --mode mp --limit 20" + elif first_template == "mp_ingest_status": + execute = "python3 scripts/aro_request.py workflow --workflow mp_ingest_status --keyword " + elif first_template == "mp_local_diagnose": + execute = "python3 scripts/aro_request.py workflow --workflow mp_local_diagnose --keyword " + elif first_endpoint: + execute = f"# {first_method or 'CALL'} {first_endpoint}" + + return { + "inspect_helper_command": inspect, + "execute_helper_command": execute, + } + + +def selftest_result(): + checks = [] + + def check(name, condition): + checks.append({"name": name, "ok": bool(condition)}) + + start_new_recovery = { + "mode": "start_new", + "can_resume": True, + "action_template": { + "name": "start_pansou_search", + "body": {"session": "empty", "session_id": "assistant::empty"}, + }, + } + check("start_new_is_not_resumable", not recovery_can_resume(start_new_recovery)) + + p115_recovery = { + "mode": "resume_pending_115", + "can_resume": True, + "action_template": { + "name": "resume_pending_115", + "body": {"session": "s1", "session_id": "assistant::s1"}, + }, + } + p115_commands = recovery_helper_commands(p115_recovery) + check("resume_pending_115_is_resumable", recovery_can_resume(p115_recovery, p115_commands)) + check("resume_pending_115_execute_command", p115_commands.get("execute_helper_command") == "python3 scripts/aro_request.py recover --session 's1' --session-id 'assistant::s1' --execute") + + plan_recovery = { + "mode": "execute_plan", + "can_resume": True, + "action_template": { + "name": "execute_plan", + "body": {"session": "s1", "plan_id": "plan-123"}, + }, + } + plan_commands = recovery_helper_commands(plan_recovery) + check("execute_plan_includes_plan_id", plan_commands.get("execute_helper_command") == "python3 scripts/aro_request.py plan-execute --session 's1' --plan-id 'plan-123'") + + bootstrap_commands = recipe_helper_commands({"first_template": "startup_probe"}, "bootstrap") + check("bootstrap_execute_command", bootstrap_commands.get("execute_helper_command") == "python3 scripts/aro_request.py startup") + check("bootstrap_inspect_command", bootstrap_commands.get("inspect_helper_command") == "python3 scripts/aro_request.py templates --recipe 'bootstrap' --policy-only") + + workflow_commands = recipe_helper_commands({"first_template": "workflow_dry_run"}, "plan") + check("workflow_dry_run_command", workflow_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow --keyword ") + smart_search_commands = recipe_helper_commands({"first_template": "smart_search"}, "smart_search") + check("smart_search_recipe_execute_command", smart_search_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow smart_resource_search --keyword ") + smart_decision_commands = recipe_helper_commands({"first_template": "smart_decision"}, "smart_decision") + check("smart_decision_recipe_execute_command", smart_decision_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow smart_resource_decision --keyword ") + smart_search_plan_commands = recipe_helper_commands({"first_template": "smart_search_plan"}, "smart_search_plan") + check("smart_search_plan_recipe_execute_command", smart_search_plan_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow smart_resource_plan --keyword ") + smart_search_execute_commands = recipe_helper_commands({"first_template": "smart_search_execute"}, "smart_search_execute") + check("smart_search_execute_recipe_execute_command", smart_search_execute_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow smart_resource_execute --keyword ") + mp_pt_commands = recipe_helper_commands({"first_template": "mp_media_detail"}, "mp_pt") + check("mp_pt_recipe_execute_command", mp_pt_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow mp_media_detail --keyword ") + mp_recommend_commands = recipe_helper_commands({"first_template": "mp_recommend"}, "recommend") + check("mp_recommend_recipe_execute_command", mp_recommend_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow mp_recommend --source tmdb_trending --media-type all --limit 20") + preferences_commands = recipe_helper_commands({"first_template": "preferences_get"}, "preferences") + check("preferences_recipe_execute_command", preferences_commands.get("execute_helper_command") == "python3 scripts/aro_request.py preferences") + local_ingest_commands = recipe_helper_commands({"first_template": "mp_ingest_status"}, "local_ingest") + check("local_ingest_recipe_execute_command", local_ingest_commands.get("execute_helper_command") == "python3 scripts/aro_request.py workflow --workflow mp_ingest_status --keyword ") + maintain_commands = recipe_helper_commands({"first_template": "maintain_preview"}, "maintain") + check("maintain_preview_command", maintain_commands.get("execute_helper_command") == "python3 scripts/aro_request.py maintain") + maintain_execute_commands = recipe_helper_commands({"first_template": "maintain_execute"}, "maintain") + check("maintain_execute_command", maintain_execute_commands.get("execute_helper_command") == "python3 scripts/aro_request.py maintain --execute") + + template_summary = request_templates_summary({ + "data": { + "recommended_recipe": "bootstrap", + "orchestration_contract": { + "recommended_first_call": "startup", + "recommended_route_call": "route --summary-only", + }, + "entry_patterns": { + "mp_builtin_agent": {"route_with": "agent_resource_officer_smart_entry"}, + }, + "recommended_recipe_detail": { + "first_template": "startup_probe", + "first_call": {"endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "method": "GET"}, + "confirmation_required_templates": ["saved_plan_execute"], + "confirmation_message": "需要确认", + }, + }, + }) + check("templates_summary_recipe", template_summary.get("recommended_recipe") == "bootstrap") + check("templates_summary_first_call", template_summary.get("first_template") == "startup_probe" and template_summary.get("first_method") == "GET") + check("templates_summary_confirmation", template_summary.get("requires_confirmation") is True and template_summary.get("confirmation_message") == "需要确认") + check("templates_summary_first_confirmation", template_summary.get("first_requires_confirmation") is False) + check("templates_summary_orchestration_contract", (template_summary.get("orchestration_contract") or {}).get("recommended_first_call") == "startup") + check("templates_summary_entry_patterns", bool(((template_summary.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with"))) + template_summary_with_playbooks = request_templates_summary({ + "data": { + "recommended_recipe": "external_agent_quickstart", + "entry_playbooks": { + "external_agent": { + "steps": [{"step": "startup"}, {"step": "decide"}, {"step": "route"}, {"step": "followup"}], + }, + "mp_builtin_agent": { + "steps": [{"step": "request_templates", "tool": "agent_resource_officer_request_templates"}], + }, + }, + }, + }) + check("templates_summary_entry_playbooks", len(((template_summary_with_playbooks.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or []) == 4) + check("templates_summary_entry_playbooks_mp_tool", bool(((((template_summary_with_playbooks.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool"))) + + confirm_summary = { + "requires_confirmation": True, + "inspect_helper_command": "inspect", + "execute_helper_command": "execute", + } + check("command_only_requires_confirmation", summary_command(confirm_summary) == "inspect") + later_confirm_summary = { + "first_requires_confirmation": False, + "requires_confirmation": True, + "inspect_helper_command": "inspect", + "execute_helper_command": "execute", + } + check("command_only_later_confirmation_executes_first_step", summary_command(later_confirm_summary) == "execute") + check("command_only_confirmed_executes", summary_command(confirm_summary, confirmed=True) == "execute") + no_confirm_summary = { + "requires_confirmation": False, + "inspect_helper_command": "inspect", + "execute_helper_command": "execute", + } + check("command_only_without_confirmation_executes", summary_command(no_confirm_summary) == "execute") + top_level_preferred_summary = { + "requires_confirmation": False, + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "preferred_command": "选择 1", + "fallback_command": "下载1", + } + check("command_only_prefers_top_level_command", summary_command(top_level_preferred_summary) == "选择 1") + top_level_confirm_summary = { + "requires_confirmation": True, + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "preferred_command": "选择 1", + "fallback_command": "下载1", + } + check("command_only_confirmed_uses_top_level_fallback", summary_command(top_level_confirm_summary, confirmed=True) == "下载1") + top_level_policy_summary = { + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": True, + "preferred_command": "选择 1", + "fallback_command": "下载1", + } + top_level_policy = command_execution_policy(top_level_policy_summary) + check("command_execution_policy_auto_continue_then_wait", top_level_policy.get("recommended_agent_behavior") == "auto_continue_then_wait_confirmation") + explicit_summary_command = { + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "先看详情", + "confirm_command": "执行最佳", + "display_command": "先看详情", + "auto_run_short_command": "详情", + "confirm_short_command": "确认", + "display_short_command": "详情", + } + check("command_only_prefers_explicit_auto_run_command", summary_command(explicit_summary_command) == "详情") + check("command_only_confirmed_prefers_explicit_confirm_command", summary_command(explicit_summary_command, confirmed=True) == "确认") + explicit_top_level_policy = command_execution_policy({ + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "先看详情", + "confirm_command": "执行最佳", + "display_command": "先看详情", + }) + check("command_execution_policy_prefers_explicit_server_policy", explicit_top_level_policy.get("auto_run_command") == "先看详情" and explicit_top_level_policy.get("confirm_command") == "执行最佳") + confirm_only_policy = command_execution_policy({ + "preferred_requires_confirmation": True, + "preferred_command": "下载1", + }) + check("command_execution_policy_wait_confirmation", confirm_only_policy.get("recommended_agent_behavior") == "wait_user_confirmation" and confirm_only_policy.get("confirm_command") == "下载1") + stop_policy = command_execution_policy({}) + check("command_execution_policy_stop_without_commands", stop_policy.get("recommended_agent_behavior") == "stop") + helper_auto_policy = helper_summary_execution_policy({ + "first_requires_confirmation": False, + "requires_confirmation": True, + "inspect_helper_command": "inspect", + "execute_helper_command": "execute", + }) + check("helper_execution_policy_auto_then_confirm", helper_auto_policy.get("recommended_agent_behavior") == "auto_continue_then_wait_confirmation" and helper_auto_policy.get("auto_run_command") == "execute") + helper_confirm_policy = helper_summary_execution_policy({ + "requires_confirmation": True, + "inspect_helper_command": "inspect", + "execute_helper_command": "", + }) + check("helper_execution_policy_wait_confirmation", helper_confirm_policy.get("recommended_agent_behavior") == "wait_user_confirmation" and helper_confirm_policy.get("confirm_command") == "inspect") + helper_stop_policy = helper_summary_execution_policy({}) + check("helper_execution_policy_stop_without_commands", helper_stop_policy.get("recommended_agent_behavior") == "stop") + + quote_value = shell_quote("a'b") + check("shell_quote_single_quote", quote_value == "'a'\"'\"'b'") + check("helper_route_command_with_session", helper_route_command("选择 1", session="agent:demo") == "python3 scripts/aro_request.py route '选择 1' --session 'agent:demo'") + + route_args = normalize_command_args(argparse.Namespace(command="route", extra=["盘搜搜索", "大君夫人"], text=None)) + check("normalize_route_positional_text", route_args.text == "盘搜搜索 大君夫人") + + pick_choice_args = normalize_command_args( + argparse.Namespace(command="pick", extra=["11"], choice=None, action=None) + ) + check("normalize_pick_positional_choice", pick_choice_args.choice == 11 and not pick_choice_args.action) + + pick_action_args = normalize_command_args( + argparse.Namespace(command="pick", extra=["详情"], choice=None, action=None) + ) + check("normalize_pick_positional_action", pick_action_args.action == "详情" and pick_action_args.choice is None) + + pick_choice_action_args = normalize_command_args( + argparse.Namespace(command="pick", extra=["11", "详情"], choice=None, action=None) + ) + check("normalize_pick_positional_choice_action", pick_choice_action_args.choice == 11 and pick_choice_action_args.action == "详情") + + plan_args = normalize_command_args( + argparse.Namespace(command="plan-execute", extra=["plan-123"], plan_id=None) + ) + check("normalize_plan_execute_positional_plan", plan_args.plan_id == "plan-123") + followup_args = normalize_command_args( + argparse.Namespace(command="followup", extra=["plan-123"], plan_id=None) + ) + check("normalize_followup_positional_plan", followup_args.plan_id == "plan-123") + + workflow_args = normalize_command_args( + argparse.Namespace(command="workflow", extra=["mp_media_detail", "蜘蛛侠"], workflow="hdhive_candidates", keyword=None) + ) + check("normalize_workflow_positional_workflow", workflow_args.workflow == "mp_media_detail") + check("normalize_workflow_positional_keyword", workflow_args.keyword == "蜘蛛侠") + + session_args = normalize_command_args( + argparse.Namespace(command="session", extra=["agent:demo"], session=None) + ) + check("normalize_session_positional_session", session_args.session == "agent:demo") + + history_args = normalize_command_args( + argparse.Namespace(command="history", extra=["agent:demo"], session=None) + ) + check("normalize_history_positional_session", history_args.session == "agent:demo") + + plans_args = normalize_command_args( + argparse.Namespace(command="plans", extra=["plan-123"], plan_id=None) + ) + check("normalize_plans_positional_plan", plans_args.plan_id == "plan-123") + + compact_workflow = compact({ + "success": True, + "data": { + "action": "workflow_plan", + "plan_id": "plan-123", + "workflow": "hdhive_candidates", + "execute_plan_body": {"plan_id": "plan-123"}, + }, + }) + check("compact_preserves_plan_id", compact_workflow.get("plan_id") == "plan-123") + check("compact_preserves_execute_plan_body", (compact_workflow.get("execute_plan_body") or {}).get("plan_id") == "plan-123") + compact_execute = compact({ + "success": True, + "data": { + "action": "execute_plan", + "recommended_action": "query_mp_download_history", + "follow_up_hint": "先查下载历史。", + }, + }) + check("compact_preserves_follow_up_hint", compact_execute.get("follow_up_hint") == "先查下载历史。") + compact_top_level_commands = compact({ + "success": True, + "data": { + "action": "mp_media_search", + "command_source": "score_summary", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": False, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": True, + "preferred_command": "选择 1", + "fallback_command": "下载1", + "compact_commands": ["选择 1", "下载1"], + }, + }) + top_level_summary = compact_command_summary(compact_top_level_commands) + check("compact_preserves_top_level_preferred_command", compact_top_level_commands.get("preferred_command") == "选择 1") + check("compact_command_summary_prefers_top_level", top_level_summary.get("preferred_command") == "选择 1" and top_level_summary.get("command_source") == "score_summary") + check("compact_command_summary_preserves_confirmation_flags", top_level_summary.get("fallback_requires_confirmation") is True and top_level_summary.get("can_auto_run_preferred") is True) + check("compact_command_summary_builds_helper_command", top_level_summary.get("preferred_helper_command") == "python3 scripts/aro_request.py route '选择 1'") + check("compact_command_summary_includes_execution_policy", top_level_summary.get("recommended_agent_behavior") == "auto_continue_then_wait_confirmation" and top_level_summary.get("auto_run_command") == "选择 1") + compact_explicit_policy_commands = compact({ + "success": True, + "data": { + "action": "smart_resource_decision", + "command_source": "decision_summary", + "command_policy": "read_then_confirm_write", + "preferred_requires_confirmation": True, + "fallback_requires_confirmation": True, + "can_auto_run_preferred": False, + "preferred_command": "执行最佳", + "fallback_command": "计划最佳", + "compact_commands": ["执行最佳", "计划最佳"], + "recommended_agent_behavior": "auto_continue_then_wait_confirmation", + "auto_run_command": "先看详情", + "confirm_command": "执行最佳", + "display_command": "先看详情", + "detail_short_command": "详情", + "plan_short_command": "计划", + "confirm_short_command": "确认", + }, + }) + explicit_summary = compact_command_summary(compact_explicit_policy_commands) + check("compact_command_summary_preserves_explicit_auto_run_command", explicit_summary.get("auto_run_command") == "先看详情" and explicit_summary.get("confirm_command") == "执行最佳") + check("compact_command_summary_preserves_smart_short_commands", explicit_summary.get("detail_short_command") == "详情" and explicit_summary.get("plan_short_command") == "计划" and explicit_summary.get("confirm_short_command") == "确认") + check("compact_command_summary_builds_preferred_short_commands", explicit_summary.get("auto_run_short_command") == "详情" and explicit_summary.get("display_short_command") == "详情") + compact_clear = compact({ + "success": True, + "data": { + "action": "plans_clear", + "removed": 1, + "remaining": 0, + }, + }) + check("compact_preserves_plan_clear_counts", compact_clear.get("removed") == 1 and compact_clear.get("remaining") == 0) + compact_feishu = compact({ + "success": True, + "message": "feishu", + "data": { + "plugin_version": "0.1.110", + "enabled": False, + "running": False, + "sdk_available": True, + "legacy_bridge_running": True, + "conflict_warning": False, + }, + }) + check("compact_preserves_feishu_health", compact_feishu.get("plugin_version") == "0.1.110" and compact_feishu.get("legacy_bridge_running") is True) + + external_agent = external_agent_payload() + check("external_agent_payload_has_prompt", bool(external_agent.get("prompt"))) + check("external_agent_payload_has_guide", external_agent.get("guide_file_exists") is True) + check("external_agent_payload_has_tools", len(external_agent.get("tools") or []) == 4) + check("external_agent_payload_has_followup", bool(external_agent.get("followup_command"))) + check("external_agent_payload_has_preferences_recipe", bool(external_agent.get("preferences_recipe_command"))) + check("external_agent_payload_has_smart_search_recipe", bool(external_agent.get("smart_search_recipe_command"))) + check("external_agent_payload_has_smart_decision_route", bool(external_agent.get("smart_decision_route_command"))) + check("external_agent_payload_has_smart_decision_recipe", bool(external_agent.get("smart_decision_recipe_command"))) + check("external_agent_payload_has_smart_search_plan_recipe", bool(external_agent.get("smart_search_plan_recipe_command"))) + check("external_agent_payload_has_smart_search_execute_recipe", bool(external_agent.get("smart_search_execute_recipe_command"))) + check("external_agent_payload_has_mp_pt_recipe", bool(external_agent.get("mp_pt_recipe_command"))) + check("external_agent_payload_has_mp_recommend_recipe", bool(external_agent.get("mp_recommend_recipe_command"))) + check("external_agent_payload_has_post_execute_recipe", bool(external_agent.get("post_execute_recipe_command"))) + check("external_agent_payload_has_local_ingest_recipe", bool(external_agent.get("local_ingest_recipe_command"))) + check("external_agent_payload_has_next_command_rule", bool(external_agent.get("next_command_rule"))) + check("external_agent_payload_has_execution_policy_contract", bool((external_agent.get("execution_policy_contract") or {}).get("auto_continue"))) + check("external_agent_payload_has_execution_loop_contract", len(external_agent.get("execution_loop_contract") or []) >= 5) + check("external_agent_payload_has_orchestration_contract_present", bool((external_agent.get("orchestration_contract") or {}).get("recommended_route_call"))) + check("external_agent_payload_has_feishu_entry_pattern", bool(((external_agent.get("entry_patterns") or {}).get("feishu_channel") or {}).get("route_with"))) + check("external_agent_payload_has_orchestration_contract_route", (external_agent.get("orchestration_contract") or {}).get("recommended_route_call") == "route --summary-only") + check("external_agent_payload_has_entry_patterns", bool(((external_agent.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with"))) + check("external_agent_payload_has_entry_playbooks", len((((external_agent.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or [])) >= 4) + check("external_agent_payload_has_mp_playbook_tool", bool(((((external_agent.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool"))) + check("external_agent_payload_has_deprecated_aliases", "workbuddy" in (external_agent.get("deprecated_aliases") or [])) + calibration = calibration_payload() + check("calibration_payload_success", calibration.get("success") is True) + check("calibration_mentions_download_rule", any("下载 <片名>" in str(rule) and "MP/PT" in str(rule) for rule in calibration.get("hard_rules") or [])) + check("calibration_text_alias", is_calibration_text("校准影视技能")) + + catalog = commands_catalog() + catalog_commands = catalog.get("commands") or [] + catalog_names = {item.get("name") for item in catalog_commands} + check("helper_version_present", catalog.get("helper_version") == HELPER_VERSION) + check("commands_schema_version", catalog.get("schema_version") == "commands.v1") + check("commands_catalog_includes_version", "version" in catalog_names) + check("commands_catalog_includes_external_agent", "external-agent" in catalog_names) + check("commands_catalog_includes_calibrate", "calibrate" in catalog_names) + check("commands_catalog_includes_workbuddy_alias", "workbuddy" in catalog_names) + workbuddy_entry = next((item for item in catalog_commands if item.get("name") == "workbuddy"), {}) + check("commands_catalog_marks_workbuddy_deprecated", workbuddy_entry.get("deprecated") is True) + check("commands_catalog_complete", catalog_names == set(HELPER_COMMANDS)) + check("commands_writes_are_boolean", all(isinstance(item.get("writes"), bool) for item in catalog_commands)) + check("commands_have_write_condition", all("write_condition" in item for item in catalog_commands)) + workflow_entry = next((item for item in catalog_commands if item.get("name") == "workflow"), {}) + check("workflow_catalog_marks_plan_write", workflow_entry.get("writes") is True and "plan" in workflow_entry.get("write_condition", "")) + check("commands_recommended_start", catalog.get("recommended_start") == "python3 scripts/aro_request.py decide --summary-only") + + passed = sum(1 for item in checks if item.get("ok")) + failed = [item for item in checks if not item.get("ok")] + result = { + "success": not failed, + "passed": passed, + "failed": len(failed), + "checks": checks, + } + return result + + +def run_selftest(): + result = selftest_result() + print_json(result) + return 0 if result.get("success") else 1 + + +def commands_catalog(): + return { + "success": True, + "schema_version": "commands.v1", + "helper_version": HELPER_VERSION, + "recommended_start": "python3 scripts/aro_request.py decide --summary-only", + "commands": [ + {"name": "version", "network": False, "writes": False, "write_condition": "", "purpose": "print local helper version"}, + {"name": "calibrate", "network": False, "writes": False, "write_condition": "", "purpose": "print a compact media-skill calibration card for long-lived external-agent sessions"}, + {"name": "external-agent", "network": False, "writes": False, "write_condition": "", "purpose": "print external agent connection prompt and minimal tool contract"}, + {"name": "workbuddy", "network": False, "writes": False, "write_condition": "", "purpose": "compatibility alias for external-agent", "deprecated": True}, + {"name": "commands", "network": False, "writes": False, "write_condition": "", "purpose": "print local helper command catalog"}, + {"name": "config-check", "network": False, "writes": False, "write_condition": "", "purpose": "check local connection settings without printing secrets"}, + {"name": "selftest", "network": False, "writes": False, "write_condition": "", "purpose": "test local helper decision and command generation logic"}, + {"name": "readiness", "network": True, "writes": False, "write_condition": "", "purpose": "run config-check, selftest, and live plugin selfcheck"}, + {"name": "startup", "network": True, "writes": False, "write_condition": "", "purpose": "inspect assistant startup state and recommended recipe"}, + {"name": "selfcheck", "network": True, "writes": False, "write_condition": "", "purpose": "run live AgentResourceOfficer protocol health check"}, + {"name": "scoring-policy", "network": True, "writes": False, "write_condition": "", "purpose": "read plugin-owned cloud/PT scoring rules and hard gates"}, + {"name": "templates", "network": True, "writes": False, "write_condition": "", "purpose": "fetch low-token assistant request templates by recipe or name"}, + {"name": "decide", "network": True, "writes": False, "write_condition": "", "purpose": "choose continue_session or start_recipe and return next helper command"}, + {"name": "doctor", "network": True, "writes": False, "write_condition": "", "purpose": "return startup, selfcheck, sessions, and recovery snapshot"}, + {"name": "feishu-health", "network": True, "writes": False, "write_condition": "", "purpose": "inspect AgentResourceOfficer built-in Feishu Channel status"}, + {"name": "auto", "network": True, "writes": False, "write_condition": "", "purpose": "follow startup recommended recipe and return request template summary"}, + {"name": "recover", "network": True, "writes": True, "write_condition": "only with --execute", "purpose": "inspect or execute the recommended recovery action"}, + {"name": "route", "network": True, "writes": True, "write_condition": "depends on text and routed action", "purpose": "route natural-language resource requests"}, + {"name": "pick", "network": True, "writes": True, "write_condition": "depends on current session and selected action", "purpose": "continue numbered choices or actions"}, + {"name": "preferences", "network": True, "writes": True, "write_condition": "only with --preferences-json or --reset", "purpose": "read/save/reset source preferences used by cloud and PT scoring"}, + {"name": "workflow", "network": True, "writes": True, "write_condition": "read workflows execute directly; write workflows save a dry-run plan by default", "purpose": "run or plan preset assistant workflows"}, + {"name": "plan-execute", "network": True, "writes": True, "write_condition": "always executes a saved plan; use --plan-id for exact execution", "purpose": "execute a saved plan by plan_id or latest unexecuted session plan"}, + {"name": "followup", "network": True, "writes": False, "write_condition": "", "purpose": "run the unified post-execution follow-up action for the latest executed or specified plan"}, + {"name": "maintain", "network": True, "writes": True, "write_condition": "only with --execute", "purpose": "preview or execute low-risk maintenance"}, + {"name": "session", "network": True, "writes": False, "write_condition": "", "purpose": "inspect one assistant session"}, + {"name": "session-clear", "network": True, "writes": True, "write_condition": "clears exactly one assistant session by --session or --session-id", "purpose": "clear one assistant session, including abandoned pending 115 state"}, + {"name": "sessions", "network": True, "writes": False, "write_condition": "", "purpose": "list recent assistant sessions"}, + {"name": "sessions-clear", "network": True, "writes": True, "write_condition": "clears assistant sessions matching --session, --session-id, --kind, --has-pending-p115, --stale-only, or --all-sessions", "purpose": "bulk clear assistant sessions"}, + {"name": "history", "network": True, "writes": False, "write_condition": "", "purpose": "list recent assistant execution history"}, + {"name": "plans", "network": True, "writes": False, "write_condition": "", "purpose": "list saved workflow plans"}, + {"name": "plans-clear", "network": True, "writes": True, "write_condition": "clears saved plans matching --plan-id, session filters, --executed, or --all-plans", "purpose": "clear saved workflow plans"}, + {"name": "raw", "network": True, "writes": True, "write_condition": "depends on method, path, and JSON body", "purpose": "call a raw assistant endpoint for debugging"}, + {"name": "hdhive-cookie-refresh", "network": False, "writes": True, "write_condition": "reads local browser cookies and writes them back to MoviePilot config", "purpose": "refresh HDHive webpage cookie from the host browser export tool"}, + {"name": "hdhive-checkin-repair", "network": True, "writes": True, "write_condition": "refreshes HDHive webpage cookie, restarts moviepilot-v2, then retries one HDHive check-in", "purpose": "repair HDHive check-in by refreshing browser cookie and immediately retrying sign-in"}, + {"name": "quark-cookie-refresh", "network": False, "writes": True, "write_condition": "reads local browser cookies and writes them back to MoviePilot Quark config", "purpose": "refresh Quark webpage cookie from the host browser export tool"}, + {"name": "quark-transfer-repair", "network": True, "writes": True, "write_condition": "refreshes Quark webpage cookie, restarts moviepilot-v2, then optionally retries one failed Quark transfer command", "purpose": "repair Quark transfer by refreshing browser cookie and rechecking/retrying transfer"}, + ], + } + + +def main(): + parser = argparse.ArgumentParser(description="AgentResourceOfficer request helper") + parser.add_argument( + "command", + choices=HELPER_COMMANDS, + ) + parser.add_argument("extra", nargs="*") + parser.add_argument("--base-url") + parser.add_argument("--api-key") + parser.add_argument("--recipe") + parser.add_argument("--names") + parser.add_argument("--include-templates", action="store_true") + parser.add_argument("--policy-only", action="store_true") + parser.add_argument("--text") + parser.add_argument("--session") + parser.add_argument("--session-id") + parser.add_argument("--plan-id") + parser.add_argument("--kind") + parser.add_argument("--has-pending-p115", action="store_true") + parser.add_argument("--choice", type=int) + parser.add_argument("--action") + parser.add_argument("--path", dest="target_path") + parser.add_argument("--workflow", default="hdhive_candidates") + parser.add_argument("--keyword") + parser.add_argument("--media-type", default="auto") + parser.add_argument("--mode", default="") + parser.add_argument("--source", default="") + parser.add_argument("--status", default="") + parser.add_argument("--hash", dest="hash_value", default="") + parser.add_argument("--target", default="") + parser.add_argument("--control", default="") + parser.add_argument("--downloader", default="") + parser.add_argument("--delete-files", action="store_true") + parser.add_argument("--preferences-json") + parser.add_argument("--reset", action="store_true") + parser.add_argument("--execute", action="store_true") + parser.add_argument("--limit", type=int, default=100) + parser.add_argument("--executed", action="store_true") + parser.add_argument("--unexecuted", action="store_true") + parser.add_argument("--all-plans", action="store_true") + parser.add_argument("--stale-only", action="store_true") + parser.add_argument("--all-sessions", action="store_true") + parser.add_argument("--include-actions", action="store_true") + parser.add_argument("--prefer-unexecuted", action="store_true") + parser.add_argument("--include-raw-results", action="store_true") + parser.add_argument("--method", default="GET") + parser.add_argument("--api-path") + parser.add_argument("--json", dest="json_body") + parser.add_argument("--retry-text") + parser.add_argument( + "--compact", + action="store_true", + help="Compatibility no-op; compact output is the default unless --full is used.", + ) + parser.add_argument( + "--json-output", + action="store_true", + help="For route/pick, print compact JSON instead of the chat-friendly plain message.", + ) + parser.add_argument("--full", action="store_true") + parser.add_argument("--summary-only", action="store_true") + parser.add_argument("--command-only", action="store_true") + parser.add_argument("--confirmed", action="store_true") + args = normalize_command_args(parser.parse_args()) + + if args.executed and args.unexecuted: + print("--executed and --unexecuted cannot be used together", file=sys.stderr) + return 2 + + if args.command == "commands": + print_json(commands_catalog()) + return 0 + if args.command == "version": + print_json({"success": True, "helper_version": HELPER_VERSION}) + return 0 + if args.command == "calibrate": + payload = calibration_payload() + if args.json_output or args.full: + print_json(payload) + else: + print(format_calibration_text(payload)) + return 0 + if args.command == "route" and is_calibration_text(args.text or " ".join(args.extra)): + payload = calibration_payload() + if args.json_output or args.full: + print_json(payload) + else: + print(format_calibration_text(payload)) + return 0 + if args.command in {"external-agent", "workbuddy"}: + if args.full and os.path.exists(EXTERNAL_AGENT_GUIDE_PATH): + with open(EXTERNAL_AGENT_GUIDE_PATH, "r", encoding="utf-8") as file_obj: + print(file_obj.read()) + else: + print_json(external_agent_payload()) + return 0 + + if args.command == "selftest": + return run_selftest() + + config = read_config() + if args.command == "hdhive-cookie-refresh": + result = run_hdhive_cookie_refresh(config) + result["helper_version"] = HELPER_VERSION + result["action"] = "hdhive_cookie_refresh" + print_json(result) + return 0 if result.get("success") else 2 + if args.command == "quark-cookie-refresh": + result = run_quark_cookie_refresh(config) + result["helper_version"] = HELPER_VERSION + result["action"] = "quark_cookie_refresh" + print_json(result) + return 0 if result.get("success") else 2 + base_url = args.base_url or config_value(config, "ARO_BASE_URL", "MP_BASE_URL", "MOVIEPILOT_URL") + api_key = args.api_key or config_value(config, "ARO_API_KEY", "MP_API_TOKEN") + if args.command == "hdhive-checkin-repair": + if not base_url: + print("ARO_BASE_URL / MP_BASE_URL / MOVIEPILOT_URL is not set", file=sys.stderr) + return 2 + if not api_key: + print("ARO_API_KEY / MP_API_TOKEN is not set", file=sys.stderr) + return 2 + result, status = run_hdhive_checkin_repair( + base_url, + api_key, + config, + session=args.session or "", + session_id=args.session_id or "", + ) + print_json(result) + return status + if args.command == "quark-transfer-repair": + if not base_url: + print("ARO_BASE_URL / MP_BASE_URL / MOVIEPILOT_URL is not set", file=sys.stderr) + return 2 + if not api_key: + print("ARO_API_KEY / MP_API_TOKEN is not set", file=sys.stderr) + return 2 + result, status = run_quark_transfer_repair( + base_url, + api_key, + config, + retry_text=args.retry_text or "", + session=args.session or "", + session_id=args.session_id or "", + ) + print_json(result) + return status + if args.command == "config-check": + result = { + "success": bool(base_url and api_key), + "config_path": CONFIG_PATH_DISPLAY, + "config_file_exists": os.path.exists(CONFIG_PATH), + "base_url_set": bool(base_url), + "base_url_source": "arg:--base-url" if args.base_url else config_source(config, "ARO_BASE_URL", "MP_BASE_URL", "MOVIEPILOT_URL"), + "api_key_set": bool(api_key), + "api_key_source": "arg:--api-key" if args.api_key else config_source(config, "ARO_API_KEY", "MP_API_TOKEN"), + } + print_json(result) + return 0 if result["success"] else 2 + if args.command == "readiness": + config_result = { + "success": bool(base_url and api_key), + "config_path": CONFIG_PATH_DISPLAY, + "config_file_exists": os.path.exists(CONFIG_PATH), + "base_url_set": bool(base_url), + "base_url_source": "arg:--base-url" if args.base_url else config_source(config, "ARO_BASE_URL", "MP_BASE_URL", "MOVIEPILOT_URL"), + "api_key_set": bool(api_key), + "api_key_source": "arg:--api-key" if args.api_key else config_source(config, "ARO_API_KEY", "MP_API_TOKEN"), + } + local_result = selftest_result() + live_result = {"success": False, "skipped": True, "reason": "missing config"} + if config_result["success"]: + try: + live_response = request(base_url, api_key, "GET", assistant_path("selfcheck")) + live_compact = compact(live_response) + live_result = { + "success": bool((live_compact or {}).get("ok") or (live_compact or {}).get("success")), + "skipped": False, + "version": (live_compact or {}).get("version") or "", + "message": (live_compact or {}).get("message") or "", + } + except Exception as exc: + live_result = {"success": False, "skipped": False, "reason": str(exc)} + result = { + "success": bool(config_result["success"] and local_result["success"] and live_result["success"]), + "helper_version": HELPER_VERSION, + "config": config_result, + "local_selftest": { + "success": local_result["success"], + "passed": local_result["passed"], + "failed": local_result["failed"], + }, + "live_selfcheck": live_result, + } + print_json(result) + return 0 if result["success"] else 2 + if not base_url: + print("ARO_BASE_URL / MP_BASE_URL / MOVIEPILOT_URL is not set", file=sys.stderr) + return 2 + if not api_key: + print("ARO_API_KEY / MP_API_TOKEN is not set", file=sys.stderr) + return 2 + + method = "GET" + path = assistant_path("startup") + body = None + query = {} + + if args.command == "auto": + startup = request(base_url, api_key, "GET", assistant_path("startup")) + startup_data = data_payload(startup) + recommended = startup_data.get("recommended_request_templates") if isinstance(startup_data, dict) else {} + tool_args = recommended.get("tool_args") if isinstance(recommended, dict) else {} + recipe = (tool_args or {}).get("recipe") or args.recipe or "bootstrap" + templates = request( + base_url, + api_key, + "POST", + assistant_path("request_templates"), + body={ + "recipe": recipe, + "include_templates": bool(args.include_templates and not args.policy_only), + }, + ) + output = { + "startup": compact(startup), + "request_templates": compact(templates), + } + if (args.summary_only or args.command_only) and not args.full: + summary = { + "startup_ok": bool((output.get("startup") or {}).get("success")), + "recommended_recipe_request": (recommended or {}).get("recipe") or recipe, + "recommended_recipe_reason": (recommended or {}).get("reason") or "", + **request_templates_summary(templates), + **recipe_helper_commands(request_templates_summary(templates), (recommended or {}).get("recipe") or recipe), + } + print_summary(summary, command_only=args.command_only, confirmed=args.confirmed) + return 0 + print_json(output if not args.full else {"startup": startup, "request_templates": templates}) + return 0 + + if args.command == "doctor": + startup = request(base_url, api_key, "GET", assistant_path("startup")) + selfcheck = request(base_url, api_key, "GET", assistant_path("selfcheck")) + sessions = request( + base_url, + api_key, + "GET", + assistant_path("sessions"), + query={ + "compact": "true", + "limit": str(args.limit), + **({"kind": args.kind} if args.kind else {}), + **({"has_pending_p115": "true"} if args.has_pending_p115 else {}), + }, + ) + recover_query = { + "compact": "true", + "limit": str(args.limit), + } + if args.session: + recover_query["session"] = args.session + if args.session_id: + recover_query["session_id"] = args.session_id + recover = request( + base_url, + api_key, + "GET", + assistant_path("recover"), + query=recover_query, + ) + output = { + "startup": compact(startup), + "selfcheck": compact(selfcheck), + "sessions": compact(sessions), + "recover": compact(recover), + } + helper_commands = recovery_helper_commands(((output.get("recover") or {}).get("recovery") or {})) + output["helper_commands"] = helper_commands + summary = { + "startup_ok": bool((output.get("startup") or {}).get("success")), + "selfcheck_ok": bool((output.get("selfcheck") or {}).get("ok")), + "recovery_can_resume": recovery_can_resume(((output.get("recover") or {}).get("recovery") or {}), helper_commands), + "requires_confirmation": recovery_can_resume(((output.get("recover") or {}).get("recovery") or {}), helper_commands), + "recommended_action": ((output.get("recover") or {}).get("recovery") or {}).get("recommended_action") or "", + "recommended_tool": ((output.get("recover") or {}).get("recovery") or {}).get("recommended_tool") or "", + **helper_commands, + } + output["summary"] = summary + if (args.summary_only or args.command_only) and not args.full: + print_summary(summary, command_only=args.command_only, confirmed=args.confirmed) + return 0 + if not args.full: + output["summary"] = summary + print_json(output if not args.full else { + "startup": startup, + "selfcheck": selfcheck, + "sessions": sessions, + "recover": recover, + }) + return 0 + + if args.command == "decide": + startup = request(base_url, api_key, "GET", assistant_path("startup")) + recover = request( + base_url, + api_key, + "GET", + assistant_path("recover"), + query={ + "compact": "true", + "limit": str(args.limit), + **({"session": args.session} if args.session else {}), + **({"session_id": args.session_id} if args.session_id else {}), + }, + ) + startup_compact = compact(startup) + recover_compact = compact(recover) + recover_data = ((recover_compact or {}).get("recovery") or {}) if isinstance(recover_compact, dict) else {} + helper_commands = recovery_helper_commands(recover_data) + if recovery_can_resume(recover_data, helper_commands): + summary = { + "decision": "continue_session", + "startup_ok": bool((startup_compact or {}).get("success")), + "can_resume": True, + "mode": recover_data.get("mode") or "", + "reason": recover_data.get("reason") or "", + "recommended_action": recover_data.get("recommended_action") or "", + "recommended_tool": recover_data.get("recommended_tool") or "", + "requires_confirmation": True, + **helper_commands, + } + if (args.summary_only or args.command_only) and not args.full: + print_summary(summary, command_only=args.command_only, confirmed=args.confirmed) + return 0 + print_json({ + "summary": summary, + "startup": startup_compact, + "recover": recover_compact, + } if not args.full else { + "summary": summary, + "startup": startup, + "recover": recover, + }) + return 0 + + startup_data = data_payload(startup) + recommended = startup_data.get("recommended_request_templates") if isinstance(startup_data, dict) else {} + tool_args = recommended.get("tool_args") if isinstance(recommended, dict) else {} + scoped_session = bool(args.session or args.session_id) + recipe = args.recipe or ("bootstrap" if scoped_session else ((tool_args or {}).get("recipe") or "bootstrap")) + templates = request( + base_url, + api_key, + "POST", + assistant_path("request_templates"), + body={ + "recipe": recipe, + "include_templates": bool(args.include_templates and not args.policy_only), + }, + ) + template_summary = request_templates_summary(templates) + helper_commands = recipe_helper_commands(template_summary, recipe) + summary = { + "decision": "start_recipe", + "startup_ok": bool((startup_compact or {}).get("success")), + "can_resume": False, + "recommended_recipe_request": recipe, + "recommended_recipe_reason": ( + f"使用指定 recipe:{args.recipe}。" + if args.recipe + else "指定会话没有可恢复状态,使用 bootstrap。" + if scoped_session + else ((recommended or {}).get("reason") or "") + ), + **template_summary, + **helper_commands, + } + if (args.summary_only or args.command_only) and not args.full: + print_summary(summary, command_only=args.command_only, confirmed=args.confirmed) + return 0 + print_json({ + "summary": summary, + "startup": startup_compact, + "recover": recover_compact, + "request_templates": compact(templates), + } if not args.full else { + "summary": summary, + "startup": startup, + "recover": recover, + "request_templates": templates, + }) + return 0 + + if args.command == "startup": + path = assistant_path("startup") + elif args.command == "selfcheck": + path = assistant_path("selfcheck") + elif args.command == "scoring-policy": + path = assistant_path("capabilities") + query = {"compact": "true"} + elif args.command == "feishu-health": + path = "/api/v1/plugin/AgentResourceOfficer/feishu/health" + elif args.command == "templates": + method = "POST" + path = assistant_path("request_templates") + body = { + "include_templates": bool(args.include_templates and not args.policy_only), + } + if args.recipe: + body["recipe"] = args.recipe + if args.names: + body["names"] = args.names + elif args.command == "route": + method = "POST" + path = assistant_path("route") + route_text = args.text or "" + body = { + "text": route_text, + "compact": True, + } + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + if args.target_path: + body["path"] = args.target_path + elif args.command == "pick": + method = "POST" + path = assistant_path("pick") + body = { + "compact": True, + } + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + if args.choice is not None: + body["choice"] = args.choice + if args.action: + body["action"] = args.action + if args.mode: + body["mode"] = args.mode + if args.target_path: + body["path"] = args.target_path + elif args.command == "preferences": + method = "DELETE" if args.reset else "POST" if args.preferences_json else "GET" + path = assistant_path("preferences") + if method == "GET": + query = {"compact": "true"} + if args.session: + query["session"] = args.session + if args.session_id: + query["session_id"] = args.session_id + else: + body = {"compact": True} + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + if args.preferences_json: + body["preferences"] = load_json_arg(args.preferences_json) + elif args.command == "workflow": + method = "POST" + path = assistant_path("workflow") + body = { + "workflow": args.workflow, + "name": args.workflow, + "keyword": args.keyword or "", + "media_type": args.media_type, + "mode": args.mode or "", + "choice": args.choice, + "path": args.target_path or "", + "source": args.source or "", + "status": args.status or "", + "hash": args.hash_value or "", + "target": args.target or "", + "control": args.control or "", + "downloader": args.downloader or "", + "delete_files": bool(args.delete_files), + "limit": args.limit, + "dry_run": args.workflow in WRITE_WORKFLOWS, + "compact": True, + } + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + elif args.command == "plan-execute": + method = "POST" + path = assistant_path("plan/execute") + body = { + "prefer_unexecuted": True, + "compact": True, + } + if args.plan_id: + body["plan_id"] = args.plan_id + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + elif args.command == "followup": + method = "POST" + path = assistant_path("action") + body = { + "name": "query_execution_followup", + "compact": True, + } + if args.plan_id: + body["plan_id"] = args.plan_id + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + elif args.command == "maintain": + method = "POST" if args.execute else "GET" + path = assistant_path("maintain") + if args.execute: + body = { + "execute": True, + "limit": args.limit, + } + else: + query = { + "limit": str(args.limit), + } + elif args.command == "recover": + method = "POST" if args.execute else "GET" + path = assistant_path("recover") + if args.execute: + body = { + "compact": True, + } + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + if args.prefer_unexecuted: + body["prefer_unexecuted"] = True + if args.include_raw_results: + body["include_raw_results"] = True + else: + query = { + "compact": "true", + } + if args.session: + query["session"] = args.session + if args.session_id: + query["session_id"] = args.session_id + if args.limit: + query["limit"] = str(args.limit) + elif args.command == "session": + path = assistant_path("session") + query = { + "compact": "true", + } + if args.session: + query["session"] = args.session + if args.session_id: + query["session_id"] = args.session_id + elif args.command == "session-clear": + method = "POST" + path = assistant_path("session/clear") + body = { + "compact": True, + } + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + elif args.command == "sessions": + path = assistant_path("sessions") + query = { + "compact": "true", + "limit": str(args.limit), + } + if args.kind: + query["kind"] = args.kind + if args.has_pending_p115: + query["has_pending_p115"] = "true" + elif args.command == "sessions-clear": + method = "POST" + path = assistant_path("sessions/clear") + body = { + "limit": args.limit, + } + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + if args.kind: + body["kind"] = args.kind + if args.has_pending_p115: + body["has_pending_p115"] = True + if args.stale_only: + body["stale_only"] = True + if args.all_sessions: + body["all_sessions"] = True + elif args.command == "history": + path = assistant_path("history") + query = { + "compact": "true", + "limit": str(args.limit), + } + if args.session: + query["session"] = args.session + if args.session_id: + query["session_id"] = args.session_id + elif args.command == "plans": + path = assistant_path("plans") + query = { + "compact": "true", + "limit": str(args.limit), + } + if args.plan_id: + query["plan_id"] = args.plan_id + if args.session: + query["session"] = args.session + if args.session_id: + query["session_id"] = args.session_id + if args.executed: + query["executed"] = "true" + if args.unexecuted: + query["executed"] = "false" + if args.include_actions: + query["include_actions"] = "true" + elif args.command == "plans-clear": + method = "POST" + path = assistant_path("plans/clear") + body = { + "limit": args.limit, + } + if args.plan_id: + body["plan_id"] = args.plan_id + if args.session: + body["session"] = args.session + if args.session_id: + body["session_id"] = args.session_id + if args.executed: + body["executed"] = True + if args.unexecuted: + body["executed"] = False + if args.all_plans: + body["all_plans"] = True + elif args.command == "raw": + method = args.method + path = args.api_path or assistant_path("startup") + body = load_json_arg(args.json_body) if args.json_body else None + + result = request(base_url, api_key, method, path, body=body, query=query) + if args.command == "recover" and (args.summary_only or args.command_only) and not args.full: + output = compact(result) + recovery = ((output or {}).get("recovery") or {}) if isinstance(output, dict) else {} + helper_commands = recovery_helper_commands(recovery) + summary = { + "success": bool((output or {}).get("success")), + "can_resume": recovery_can_resume(recovery, helper_commands), + "mode": recovery.get("mode") or "", + "reason": recovery.get("reason") or "", + "recommended_action": recovery.get("recommended_action") or "", + "recommended_tool": recovery.get("recommended_tool") or "", + "requires_confirmation": recovery_can_resume(recovery, helper_commands), + **helper_commands, + } + print_summary(summary, command_only=args.command_only, confirmed=args.confirmed) + return 0 + if args.command in {"route", "pick", "workflow", "plan-execute", "followup"} and (args.summary_only or args.command_only) and not args.full: + output = compact(result) + summary = compact_command_summary(output) + print_summary(summary, command_only=args.command_only, confirmed=args.confirmed) + return 0 + output = result if args.full else compact(result) + if args.command in {"route", "pick"} and not args.full and not args.json_output: + message = str((output or {}).get("message") or "").strip() if isinstance(output, dict) else "" + if message: + print(message) + return 0 if (output or {}).get("success", True) else 2 + print_json(output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/hdhive-search-unlock-to-115/CHANGELOG.md b/skills/hdhive-search-unlock-to-115/CHANGELOG.md new file mode 100644 index 0000000..2d996e1 --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/CHANGELOG.md @@ -0,0 +1,12 @@ +# hdhive-search-unlock-to-115 changelog + +## 0.1.1 + +- Added `install.sh` with dry-run and custom target support for installing the skill into configurable skill paths. +- Added installer target guards to prevent accidental overwrites of unsafe or non-skill directories. + +## 0.1.0 + +- Added `version` command for helper version discovery. +- Added `helper_version` to local `selftest` JSON output. +- Added release-gate version sync coverage for README and CHANGELOG. diff --git a/skills/hdhive-search-unlock-to-115/PROMPTS.md b/skills/hdhive-search-unlock-to-115/PROMPTS.md new file mode 100644 index 0000000..663d258 --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/PROMPTS.md @@ -0,0 +1,34 @@ +# Recommended Prompts + +## Search Only + +```text +使用 hdhive-search-unlock-to-115 skill,搜索《黑客帝国》,列出前10个资源让我选。默认优先 115、免费资源,不要直接解锁。 +``` + +## Search Then Unlock by Choice + +```text +使用 hdhive-search-unlock-to-115 skill,搜索《黑客帝国》,列出前10个资源让我选。我选中后按编号解锁;如果是 115 资源,就放到 /待整理。收费资源必须先征求我确认。 +``` + +## TV Search + +```text +使用 hdhive-search-unlock-to-115 skill,搜索《绝命毒师》,自动判断电影或剧集,列出前10个资源让我选。 +``` + +## Force Year + +```text +使用 hdhive-search-unlock-to-115 skill,搜索《超级马里奥兄弟大电影》,年份 2023,列出前10个资源让我选。 +``` + +## Agent Notes + +- 优先使用单入口脚本 `scripts/hdhive_agent_tool.py` +- 默认使用文本输出,不必回传整段 JSON +- 只有在后续步骤需要结构化数据时才加 `--output json` +- 默认不要付费解锁,除非用户明确确认 +- `115.com/s/...` 也是有效的 115 分享链接,不要先入为主判成“非 115” +- 是否转存成功,以插件实际返回结果为准 diff --git a/skills/hdhive-search-unlock-to-115/README.md b/skills/hdhive-search-unlock-to-115/README.md new file mode 100644 index 0000000..a730764 --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/README.md @@ -0,0 +1,40 @@ +# hdhive-search-unlock-to-115 + +这是放在仓库里的公开版 Skill 模板,目标是让别人可以快速复制到支持 Skill 的智能体环境中使用。 + +当前 helper 版本:`0.1.1` + +## 使用方式 + +1. 把整个目录复制到自己的 Skill 搜索路径,例如 `/hdhive-search-unlock-to-115` + +也可以直接运行安装脚本: + +```bash +bash install.sh --dry-run +bash install.sh +bash install.sh --target /path/to/skills/hdhive-search-unlock-to-115 +``` + +2. 根据自己的环境设置: + - `MP_APP_ENV` + - `MP_BASE_URL` + - `TMDB_API_KEY` +3. 再让智能体使用这个 Skill + +## 本地自测 + +`selftest` 不连接 MoviePilot,只验证 helper 的搜索/解锁文本格式是否仍符合智能体读取习惯: + +```bash +python3 scripts/hdhive_agent_tool.py version +python3 scripts/hdhive_agent_tool.py selftest +python3 scripts/hdhive_agent_tool.py selftest --output json +``` + +## 备注 + +- 这是面向公开仓库的通用模板 +- 推荐搭配支持技能和工作流调度的智能体工作台使用,例如腾讯 WorkBuddy,或其它兼容 Skill 工作流的客户端 +- 如果用户环境路径不同,优先通过环境变量或命令行参数覆盖 +- 版本记录见 [CHANGELOG.md](./CHANGELOG.md) diff --git a/skills/hdhive-search-unlock-to-115/SKILL.md b/skills/hdhive-search-unlock-to-115/SKILL.md new file mode 100644 index 0000000..fb6d672 --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/SKILL.md @@ -0,0 +1,161 @@ +--- +name: hdhive-search-unlock-to-115 +description: HDHive agent skill template. Use when an agent should search HDHive resources by movie or TV title, show the top 10 candidate results, let the user choose one, then unlock the selected resource and try to transfer 115 links into MoviePilot's configured `/待整理` directory. +--- + +# HDHive Search, Choose, Unlock, and Drop to 115 + +This is the public repository copy of the skill. + +After copying this skill into your own external-agent skill environment, adapt runtime paths with: + +- `MP_APP_ENV` +- `MP_BASE_URL` +- `TMDB_API_KEY` +- command-line flags like `--app-env` and `--mp-base-url` + +## Use This Skill When + +- “影巢搜索某部电影” +- “列出前 10 个资源让我选” +- “选中后帮我解锁并放到 115” + +## Preconditions + +- MoviePilot is reachable locally. +- `HdhiveOpenApi` and `P115StrmHelper` are already installed. +- Search is done through the MoviePilot plugin API, not by calling HDHive raw APIs directly. +- Prefer the plugin keyword search endpoint. Do not assume HDHive OpenAPI itself supports keyword search. + +## Local Discovery + +Before acting: + +1. Read MoviePilot API token from `app.env`. +2. Prefer `MP_APP_ENV` when available. +3. Otherwise use `--app-env` or one of your local default paths. +4. Never print API keys, cookies, or full secrets back to the user. +5. In this workflow, prefer `apikey=...` on local MoviePilot API requests over `login/access-token`. + +## Preferred Tooling + +Prefer the bundled helper scripts instead of ad hoc `curl` or temporary Python: + +- `scripts/hdhive_agent_tool.py` +- `scripts/search_hdhive.py` +- `scripts/unlock_hdhive.py` +- `PROMPTS.md` + +Preferred single-entry commands: + +```bash +python3 scripts/hdhive_agent_tool.py search "黑客帝国" +python3 scripts/hdhive_agent_tool.py show +python3 scripts/hdhive_agent_tool.py unlock --index 1 +``` + +- `search` writes a normalized cache file. +- `show` re-displays the latest cached result. +- `unlock --index N` unlocks by cached index and tries 115 transfer by default. +- Use `--output json` only when structured output is necessary. +- When candidate titles are ambiguous, the search script can enrich them with 1-2 actor names. +- `115.com/s/...` is also a valid 115 share form. Do not pre-judge it as “non-115”; trust the plugin's actual transfer result. + +## Workflow + +### 1. Search Through MoviePilot + +Preferred path: + +```bash +python3 scripts/hdhive_agent_tool.py search "片名" +``` + +- The tool reads the local MoviePilot API token, calls the plugin search endpoint, picks the better media type, and writes a normalized cache. +- Use `--output json` only if the next step really needs structured data. +- Fall back to lower-level scripts only when the single-entry tool is unavailable or clearly broken. + +### 2. Handle Ambiguous Titles + +- If keyword search returns multiple TMDB candidates, use `candidates`. +- Prefer the built-in actor enrichment instead of web search or ad hoc TMDB probing. +- Ask a short follow-up only when the results clearly mix different works. +- If one work is already obvious, show the resource list directly. + +### 3. Rank and Present Results + +When you need to re-rank: + +1. `pan_type=115` +2. free items first +3. valid or unknown links before invalid ones +4. `4K` before `1080P` +5. `蓝光原盘/REMUX` before `WEB-DL/WEBRip` + +Show each choice with: + +- index +- title +- matched title if different +- pan type +- size +- resolution +- source +- unlock points + +Example: + +```text +1. 黑客帝国 (1999) | 115 | 64.91GB | 4K | 蓝光原盘/REMUX | 免费 +``` + +### 4. Let the User Choose + +- Stop after the top 10. +- Ask the user to choose by number. +- Do not unlock before the user chooses. +- If the chosen item costs points, ask for confirmation first. + +### 5. Unlock and Transfer + +Preferred path: + +```bash +python3 scripts/hdhive_agent_tool.py unlock --index 1 +``` + +- This reads the cached search result. +- It tries 115 transfer by default. +- It refuses paid unlocks unless `--allow-paid` is provided. +- Use `--path /待整理` to override the target directory if needed. +- Do not assume `115.com/s/...` cannot be transferred. Let the plugin decide. + +### 6. Non-115 Items + +- If the chosen item is clearly not `115`, warn the user that it cannot be auto-dropped into 115. +- Offer `unlock only` instead of pretending it can be auto-landed. + +### 7. Report the Result + +After unlock: + +- say whether unlock succeeded +- say whether transfer succeeded +- report the final target path +- if transfer failed, include the short failure reason + +## Guardrails + +- Never expose secrets from `app.env`, database, or plugin configs. +- Never spend points without explicit user confirmation when `unlock_points > 0`. +- Never claim a non-115 link was dropped into 115. +- Never pre-judge `115.com/s/...` as non-115. +- Prefer cache index based unlocks over hand-copied slug text. +- Do not use ad hoc browser search just to fetch actors; use built-in enrichment first. + +## Output Style + +- Be concise. +- Show the top 10. +- Ask the user to choose one number. +- After selection, report the landing result in plain language. diff --git a/skills/hdhive-search-unlock-to-115/install.sh b/skills/hdhive-search-unlock-to-115/install.sh new file mode 100755 index 0000000..2b7bffd --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/install.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CODEX_HOME_DIR="${CODEX_HOME:-"${HOME}/.codex"}" +TARGET_DIR="${CODEX_HOME_DIR}/skills/hdhive-search-unlock-to-115" +DRY_RUN=0 + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + shift + ;; + --target) + if [[ "$#" -lt 2 ]]; then + echo "--target requires a directory" >&2 + exit 2 + fi + TARGET_DIR="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +echo "Source: ${SCRIPT_DIR}" +echo "Target: ${TARGET_DIR}" + +TARGET_DIR="${TARGET_DIR%/}" +if [[ -z "${TARGET_DIR}" || "${TARGET_DIR}" == "/" || "${TARGET_DIR}" == "." || "${TARGET_DIR}" == "${HOME}" || "${TARGET_DIR}" == "${CODEX_HOME_DIR}" ]]; then + echo "Refusing unsafe target: ${TARGET_DIR}" >&2 + exit 2 +fi + +if [[ -e "${TARGET_DIR}" && ! -d "${TARGET_DIR}" ]]; then + echo "Refusing non-directory target: ${TARGET_DIR}" >&2 + exit 2 +fi + +if [[ "$DRY_RUN" == "1" ]]; then + echo "Dry run: no files changed." + exit 0 +fi + +mkdir -p "$(dirname "${TARGET_DIR}")" +if [[ -d "${TARGET_DIR}" && ! -f "${TARGET_DIR}/SKILL.md" ]]; then + if [[ -n "$(find "${TARGET_DIR}" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + echo "Refusing to overwrite non-skill directory: ${TARGET_DIR}" >&2 + exit 2 + fi +fi + +rm -rf "${TARGET_DIR}" +mkdir -p "${TARGET_DIR}" + +if command -v rsync >/dev/null 2>&1; then + rsync -a \ + --exclude '.DS_Store' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + "${SCRIPT_DIR}/" "${TARGET_DIR}/" +else + cp -R "${SCRIPT_DIR}/." "${TARGET_DIR}/" + find "${TARGET_DIR}" -name '.DS_Store' -delete + find "${TARGET_DIR}" -name '__pycache__' -type d -prune -exec rm -rf {} + + find "${TARGET_DIR}" -name '*.pyc' -delete +fi + +echo "Installed hdhive-search-unlock-to-115 skill." diff --git a/skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py b/skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py new file mode 100755 index 0000000..c191a3e --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/scripts/hdhive_agent_tool.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Single-entry helper for stable, low-noise HDHive agent workflows.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict + +import search_hdhive +import unlock_hdhive + + +HELPER_VERSION = "0.1.1" + + +def format_search_for_agent(payload: Dict[str, Any]) -> str: + return search_hdhive.format_text(payload, compact=True) + + +def format_unlock_for_agent(payload: Dict[str, Any]) -> str: + return unlock_hdhive.format_text(payload) + + +def emit(payload: Dict[str, Any], output: str, *, text: str) -> None: + if output == "json": + final_payload = dict(payload) + final_payload["text"] = text + print(json.dumps(final_payload, ensure_ascii=False, indent=2)) + return + print(text) + + +def command_search(args: argparse.Namespace) -> int: + try: + payload = search_hdhive.execute_search( + keyword=args.keyword, + media_type=args.type, + tmdb_id=args.tmdb_id, + year=args.year, + limit=args.limit, + candidate_limit=args.candidate_limit, + mp_base_url=args.mp_base_url, + app_env=Path(args.app_env), + cache_path=Path(args.cache_path), + use_cache=not args.no_cache, + tmdb_api_key=args.tmdb_api_key, + ) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 2 + text = format_search_for_agent(payload) + emit(payload, args.output, text=text) + return 0 if payload.get("success") else 1 + + +def command_show(args: argparse.Namespace) -> int: + cache_path = Path(args.cache_path).expanduser() + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except Exception as exc: + print(f"读取缓存失败: {exc}", file=sys.stderr) + return 2 + if not isinstance(payload, dict): + print("缓存格式无效", file=sys.stderr) + return 2 + text = format_search_for_agent(payload) + emit(payload, args.output, text=text) + return 0 + + +def command_unlock(args: argparse.Namespace) -> int: + try: + payload = unlock_hdhive.execute_unlock( + index=args.index, + slug=args.slug, + cache_path=Path(args.cache_path), + allow_paid=args.allow_paid, + transfer_115=not args.no_transfer_115, + path=args.path, + mp_base_url=args.mp_base_url, + app_env=Path(args.app_env), + ) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + text = format_unlock_for_agent(payload) + emit(payload, args.output, text=text) + return 0 if payload.get("success") else 1 + + +def command_version(args: argparse.Namespace) -> int: + payload = {"success": True, "helper_version": HELPER_VERSION} + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +def command_selftest(args: argparse.Namespace) -> int: + checks = [] + + def check(name: str, ok: bool) -> None: + checks.append({"name": name, "ok": bool(ok)}) + + search_payload = { + "success": True, + "query": {"keyword": "测试电影", "selected_type": "movie", "year": ""}, + "candidates": [ + {"tmdb_id": 1001, "title": "测试电影", "year": "2026", "media_type": "movie", "actors": ["演员甲", "演员乙"]}, + {"tmdb_id": 1002, "title": "测试电影2", "year": "2027", "media_type": "movie", "actors": []}, + ], + "results": [ + { + "index": 1, + "slug": "slug-115", + "title": "测试电影 4K", + "matched_title": "测试电影", + "matched_year": "2026", + "pan_type": "115", + "share_size": "50GB", + "video_resolution": ["4K"], + "source": ["REMUX"], + "unlock_points": 0, + }, + { + "index": 2, + "slug": "slug-quark", + "title": "测试电影 1080P", + "matched_title": "测试电影", + "matched_year": "2026", + "pan_type": "quark", + "share_size": "12GB", + "video_resolution": ["1080P"], + "source": ["WEB-DL"], + "unlock_points": 4, + }, + ], + } + search_text = format_search_for_agent(search_payload) + check("search_text_has_candidates", "候选影片" in search_text) + check("search_text_has_actor_names", "演员甲 / 演员乙" in search_text) + check("search_text_has_free_marker", "免费" in search_text) + check("search_text_has_points_marker", "4分" in search_text) + check("search_text_has_slug", "slug=slug-115" in search_text) + + unlock_payload = { + "success": True, + "message": "已返回资源链接", + "selected": {"title": "测试电影 4K", "slug": "slug-115"}, + "unlock": {"full_url": "https://115cdn.com/s/example?password=abcd", "access_code": "abcd"}, + "transfer_115": {"requested": True, "path": "/待整理", "ok": True, "message": "success"}, + } + unlock_text = format_unlock_for_agent(unlock_payload) + check("unlock_text_has_url", "https://115cdn.com/s/example" in unlock_text) + check("unlock_text_has_access_code", "提取码: abcd" in unlock_text) + check("unlock_text_has_transfer_ok", "115转存: 成功" in unlock_text) + check("helper_version_present", bool(HELPER_VERSION)) + + failed = [item for item in checks if not item["ok"]] + payload = { + "success": not failed, + "helper_version": HELPER_VERSION, + "passed": len(checks) - len(failed), + "failed": len(failed), + "checks": checks, + } + if args.output == "json": + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(f"selftest {'ok' if payload['success'] else 'failed'}: passed={payload['passed']} failed={payload['failed']}") + for item in failed: + print(f"- {item['name']}") + return 0 if payload["success"] else 1 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Single-entry HDHive agent helper.") + subparsers = parser.add_subparsers(dest="command", required=True) + + search_parser = subparsers.add_parser("search", help="Search and cache HDHive results") + search_parser.add_argument("keyword", help="Movie or TV keyword") + search_parser.add_argument("--type", choices=["auto", "movie", "tv"], default="auto") + search_parser.add_argument("--tmdb-id", default="") + search_parser.add_argument("--year", default="") + search_parser.add_argument("--limit", type=int, default=10) + search_parser.add_argument("--candidate-limit", type=int, default=5) + search_parser.add_argument("--cache-path", default=str(search_hdhive.DEFAULT_CACHE_PATH)) + search_parser.add_argument("--no-cache", action="store_true") + search_parser.add_argument("--app-env", default=str(search_hdhive.DEFAULT_APP_ENV)) + search_parser.add_argument("--mp-base-url", default=search_hdhive.DEFAULT_MP_BASE_URL) + search_parser.add_argument("--tmdb-api-key", default="", help="Optional TMDB API key override for actor enrichment") + search_parser.add_argument("--output", choices=["text", "json"], default="text") + search_parser.set_defaults(func=command_search) + + show_parser = subparsers.add_parser("show", help="Show cached search results") + show_parser.add_argument("--cache-path", default=str(search_hdhive.DEFAULT_CACHE_PATH)) + show_parser.add_argument("--output", choices=["text", "json"], default="text") + show_parser.set_defaults(func=command_show) + + unlock_parser = subparsers.add_parser("unlock", help="Unlock cached result by index or slug") + unlock_parser.add_argument("--index", type=int, default=0) + unlock_parser.add_argument("--slug", default="") + unlock_parser.add_argument("--allow-paid", action="store_true") + unlock_parser.add_argument("--no-transfer-115", action="store_true") + unlock_parser.add_argument("--path", default="/待整理") + unlock_parser.add_argument("--cache-path", default=str(search_hdhive.DEFAULT_CACHE_PATH)) + unlock_parser.add_argument("--app-env", default=str(search_hdhive.DEFAULT_APP_ENV)) + unlock_parser.add_argument("--mp-base-url", default=search_hdhive.DEFAULT_MP_BASE_URL) + unlock_parser.add_argument("--output", choices=["text", "json"], default="text") + unlock_parser.set_defaults(func=command_unlock) + + version_parser = subparsers.add_parser("version", help="Print helper version") + version_parser.set_defaults(func=command_version) + + selftest_parser = subparsers.add_parser("selftest", help="Run local helper formatting tests") + selftest_parser.add_argument("--output", choices=["text", "json"], default="text") + selftest_parser.set_defaults(func=command_selftest) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/hdhive-search-unlock-to-115/scripts/search_hdhive.py b/skills/hdhive-search-unlock-to-115/scripts/search_hdhive.py new file mode 100755 index 0000000..7822e60 --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/scripts/search_hdhive.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Search HDHive resources through the local MoviePilot plugin.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +DEFAULT_MP_BASE_URL = os.environ.get("MP_BASE_URL", "http://127.0.0.1:3000").strip() or "http://127.0.0.1:3000" +DEFAULT_APP_ENV = Path(os.environ.get("MP_APP_ENV", "/config/app.env")).expanduser() +DEFAULT_CACHE_PATH = Path( + os.environ.get("HDHIVE_SEARCH_CACHE", "~/.cache/hdhive-search-unlock-to-115/cache.json") +).expanduser() +TMDB_API_BASE = "https://api.themoviedb.org/3" +COMMON_APP_ENV_PATHS = [ + Path("/config/app.env"), + Path("./config/app.env"), + Path("./app.env"), + Path("~/moviepilot/config/app.env").expanduser(), +] + + +def read_api_token(app_env_path: Path) -> str: + candidates: List[Path] = [] + if str(app_env_path).strip() and str(app_env_path) != ".": + candidates.append(app_env_path.expanduser()) + env_override = os.environ.get("MP_APP_ENV", "").strip() + if env_override: + candidates.append(Path(env_override).expanduser()) + candidates.extend(COMMON_APP_ENV_PATHS) + + checked: List[Path] = [] + for candidate in candidates: + if candidate in checked: + continue + checked.append(candidate) + if not candidate.exists(): + continue + for line in candidate.read_text(encoding="utf-8", errors="ignore").splitlines(): + if line.startswith("API_TOKEN="): + return line.split("=", 1)[1].strip().strip("'\"") + raise RuntimeError(f"API_TOKEN not found in app.env: {candidate}") + raise FileNotFoundError("MoviePilot app.env not found. Set MP_APP_ENV or pass --app-env.") + + +def load_json(url: str) -> Dict[str, Any]: + request = urllib.request.Request(url=url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read().decode("utf-8", errors="ignore") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"HTTP {exc.code}: {body[:300]}") from exc + except Exception as exc: + raise RuntimeError(f"request failed: {exc}") from exc + try: + return json.loads(raw) + except Exception as exc: + raise RuntimeError(f"invalid JSON response: {raw[:300]}") from exc + + +def fetch_json(url: str, headers: Optional[Dict[str, str]] = None, timeout: int = 60) -> Dict[str, Any]: + request = urllib.request.Request(url=url, headers={"Accept": "application/json", **(headers or {})}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + raw = response.read().decode("utf-8", errors="ignore") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"HTTP {exc.code}: {body[:300]}") from exc + except Exception as exc: + raise RuntimeError(f"request failed: {exc}") from exc + try: + data = json.loads(raw) + except Exception as exc: + raise RuntimeError(f"invalid JSON response: {raw[:300]}") from exc + if not isinstance(data, dict): + raise RuntimeError("response must be a JSON object") + return data + + +def read_tmdb_api_key(explicit_key: str = "") -> str: + if explicit_key.strip(): + return explicit_key.strip() + env_key = os.environ.get("TMDB_API_KEY", "").strip() + if env_key: + return env_key + return "" + + +def build_search_url( + *, + mp_base_url: str, + api_token: str, + media_type: str, + keyword: str, + tmdb_id: str, + year: str, + candidate_limit: int, + result_limit: int, +) -> str: + params: Dict[str, Any] = { + "type": media_type, + "apikey": api_token, + "candidate_limit": candidate_limit, + "limit": result_limit, + } + if tmdb_id: + params["tmdb_id"] = tmdb_id + else: + params["keyword"] = keyword + if year: + params["year"] = year + query = urllib.parse.urlencode(params) + return f"{mp_base_url.rstrip('/')}/api/v1/plugin/HdhiveOpenApi/resources/search?{query}" + + +def normalize_title(text: str) -> str: + return re.sub(r"[\W_]+", "", (text or "").strip().lower()) + + +def choose_best_result(keyword: str, results: List[Tuple[str, Dict[str, Any]]]) -> Tuple[str, Dict[str, Any]]: + normalized_keyword = normalize_title(keyword) + + def score(item: Tuple[str, Dict[str, Any]]) -> Tuple[int, int, int, int, int]: + _, payload = item + data = payload.get("data") if isinstance(payload, dict) else {} + items = data.get("data") if isinstance(data, dict) else [] + candidates = data.get("candidates") if isinstance(data, dict) else [] + candidate_titles = [ + normalize_title(str(entry.get("title") or "")) + for entry in (candidates or []) + if isinstance(entry, dict) + ] + matched_titles = [ + normalize_title(str(entry.get("matched_title") or entry.get("title") or "")) + for entry in (items or [])[:5] + if isinstance(entry, dict) + ] + exact_match = 1 if normalized_keyword and any(title == normalized_keyword for title in candidate_titles + matched_titles) else 0 + contains_match = 1 if normalized_keyword and any(normalized_keyword in title for title in candidate_titles + matched_titles if title) else 0 + return ( + exact_match, + contains_match, + len(items or []), + len(candidates or []), + 1 if payload.get("success") else 0, + ) + + ranked = sorted(results, key=score, reverse=True) + return ranked[0] + + +def normalize_items(items: List[Dict[str, Any]], limit: int) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + for index, item in enumerate(items[:limit], start=1): + normalized.append( + { + "index": index, + "slug": item.get("slug", ""), + "title": item.get("title", ""), + "matched_title": item.get("matched_title", ""), + "matched_year": item.get("matched_year", ""), + "pan_type": item.get("pan_type", ""), + "share_size": item.get("share_size", ""), + "source": item.get("source") or [], + "video_resolution": item.get("video_resolution") or [], + "unlock_points": item.get("unlock_points"), + "is_unlocked": bool(item.get("is_unlocked")), + "is_official": bool(item.get("is_official")), + "is_valid": item.get("is_valid"), + } + ) + return normalized + + +def normalize_candidates(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + for item in items[:10]: + normalized.append( + { + "tmdb_id": item.get("tmdb_id"), + "title": item.get("title"), + "year": item.get("year"), + "media_type": item.get("media_type") or item.get("type"), + } + ) + return normalized + + +def fetch_candidate_actors(tmdb_id: Any, media_type: str, tmdb_api_key: str) -> List[str]: + clean_tmdb_id = str(tmdb_id or "").strip() + clean_media_type = str(media_type or "").strip().lower() + if not clean_tmdb_id or clean_media_type not in {"movie", "tv"} or not tmdb_api_key: + return [] + endpoint = "movie" if clean_media_type == "movie" else "tv" + query = urllib.parse.urlencode( + { + "api_key": tmdb_api_key, + "language": "zh-CN", + "append_to_response": "credits", + } + ) + url = f"{TMDB_API_BASE}/{endpoint}/{clean_tmdb_id}?{query}" + try: + payload = fetch_json(url, timeout=20) + except Exception: + return [] + cast = ((payload.get("credits") or {}).get("cast") or []) if isinstance(payload, dict) else [] + actors: List[str] = [] + for member in cast[:10]: + name = str((member or {}).get("name") or "").strip() + department = str((member or {}).get("known_for_department") or "").strip() + if not name: + continue + if department and department != "Acting": + continue + if name not in actors: + actors.append(name) + if len(actors) >= 2: + break + return actors + + +def enrich_candidates_with_actors(candidates: List[Dict[str, Any]], tmdb_api_key: str) -> List[Dict[str, Any]]: + enriched: List[Dict[str, Any]] = [] + for item in candidates: + candidate = dict(item) + candidate["actors"] = fetch_candidate_actors( + tmdb_id=candidate.get("tmdb_id"), + media_type=str(candidate.get("media_type") or candidate.get("type") or "").lower(), + tmdb_api_key=tmdb_api_key, + ) + enriched.append(candidate) + return enriched + + +def has_ambiguous_candidates(payload: Dict[str, Any]) -> bool: + candidates = payload.get("candidates") or [] + if not isinstance(candidates, list): + return False + return len(candidates) > 1 + + +def format_text(payload: Dict[str, Any], *, compact: bool = False) -> str: + query = payload.get("query") or {} + items = payload.get("results") or [] + candidates = payload.get("candidates") or [] + lines: List[str] = [] + if not compact: + lines.append( + f"影巢搜索: type={query.get('selected_type', query.get('type', '—'))} " + f"keyword={query.get('keyword', '—')} year={query.get('year', '—')}" + ) + if candidates and (not compact or has_ambiguous_candidates(payload)): + lines.append("候选影片:") + for item in candidates[:5]: + actors = item.get("actors") or [] + actor_text = f" | 主演:{' / '.join(actors[:2])}" if actors else "" + lines.append( + f"- TMDB:{item.get('tmdb_id', '—')} | {item.get('title', '—')} ({item.get('year', '—')}) | {item.get('media_type', '—')}{actor_text}" + ) + if not items: + lines.append("没有找到影巢资源。") + return "\n".join(lines) + + lines.append("前10条资源:") + for item in items: + matched = item.get("matched_title") or item.get("title") or "—" + matched_year = item.get("matched_year") + if matched_year: + matched = f"{matched} ({matched_year})" + resolution = "/".join(item.get("video_resolution") or []) or "—" + source = "/".join(item.get("source") or []) or "—" + points = item.get("unlock_points") + point_text = "免费" if points in (None, "", 0, "0") else f"{points}分" + lines.append( + f"{item['index']}. {item.get('title', '—')} | 匹配:{matched} | " + f"{item.get('pan_type', '—')} | {item.get('share_size', '—')} | " + f"{resolution} | {source} | {point_text} | slug={item.get('slug', '')}" + ) + if payload.get("cache_path") and not compact: + lines.append(f"缓存: {payload['cache_path']}") + return "\n".join(lines) + + +def write_cache(cache_path: Path, payload: Dict[str, Any]) -> None: + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def execute_search( + *, + keyword: str, + media_type: str = "auto", + tmdb_id: str = "", + year: str = "", + limit: int = 10, + candidate_limit: int = 5, + mp_base_url: str = DEFAULT_MP_BASE_URL, + app_env: Path = DEFAULT_APP_ENV, + cache_path: Path = DEFAULT_CACHE_PATH, + use_cache: bool = True, + tmdb_api_key: str = "", +) -> Dict[str, Any]: + try: + token = read_api_token(Path(app_env)) + except Exception as exc: + raise RuntimeError(str(exc)) from exc + + requested_type = media_type + types = [requested_type] if requested_type != "auto" else ["movie", "tv"] + attempts: List[Tuple[str, Dict[str, Any]]] = [] + for candidate_type in types: + url = build_search_url( + mp_base_url=mp_base_url, + api_token=token, + media_type=candidate_type, + keyword=keyword.strip(), + tmdb_id=tmdb_id.strip(), + year=year.strip(), + candidate_limit=max(1, min(10, candidate_limit)), + result_limit=max(1, min(20, limit)), + ) + try: + payload = load_json(url) + except Exception as exc: + payload = {"success": False, "message": str(exc), "data": {}} + attempts.append((candidate_type, payload)) + + selected_type, selected_payload = choose_best_result(keyword.strip(), attempts) + raw_data = selected_payload.get("data") if isinstance(selected_payload, dict) else {} + raw_items = raw_data.get("data") if isinstance(raw_data, dict) else [] + raw_candidates = raw_data.get("candidates") if isinstance(raw_data, dict) else [] + + normalized_candidates = normalize_candidates(raw_candidates or []) + if len(normalized_candidates) > 1: + normalized_candidates = enrich_candidates_with_actors( + normalized_candidates, + read_tmdb_api_key(tmdb_api_key), + ) + + result = { + "success": bool(selected_payload.get("success")), + "message": selected_payload.get("message", ""), + "query": { + "keyword": keyword.strip(), + "tmdb_id": tmdb_id.strip(), + "type": requested_type, + "selected_type": selected_type, + "year": year.strip(), + }, + "summary": { + "resource_count": len(raw_items or []), + "candidate_count": len(normalized_candidates or []), + "attempts": [ + { + "type": media_type, + "success": bool(payload.get("success")), + "message": payload.get("message", ""), + "resource_count": len(((payload.get("data") or {}).get("data") or []) if isinstance(payload.get("data"), dict) else []), + "candidate_count": len(((payload.get("data") or {}).get("candidates") or []) if isinstance(payload.get("data"), dict) else []), + } + for media_type, payload in attempts + ], + }, + "candidates": normalized_candidates, + "results": normalize_items(raw_items or [], max(1, min(20, limit))), + "cache_path": "", + } + + if use_cache: + cache_path = Path(os.path.expanduser(str(cache_path))) + write_cache(cache_path, result) + result["cache_path"] = str(cache_path) + write_cache(cache_path, result) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Search HDHive resources through local MoviePilot.") + parser.add_argument("keyword", nargs="?", default="", help="Movie or TV title keyword") + parser.add_argument("--type", choices=["auto", "movie", "tv"], default="auto", help="Search type") + parser.add_argument("--tmdb-id", default="", help="Direct TMDB ID search") + parser.add_argument("--year", default="", help="Optional year filter") + parser.add_argument("--limit", type=int, default=10, help="Resource result limit") + parser.add_argument("--candidate-limit", type=int, default=5, help="TMDB candidate limit") + parser.add_argument("--format", choices=["json", "text"], default="json", help="Output format") + parser.add_argument("--mp-base-url", default=DEFAULT_MP_BASE_URL, help="Local MoviePilot base URL") + parser.add_argument("--app-env", default=str(DEFAULT_APP_ENV), help="Path to MoviePilot app.env") + parser.add_argument("--cache-path", default=str(DEFAULT_CACHE_PATH), help="Where to save the normalized search cache") + parser.add_argument("--no-cache", action="store_true", help="Do not write a search cache file") + parser.add_argument("--compact", action="store_true", help="Print shorter text output") + parser.add_argument("--tmdb-api-key", default="", help="Optional TMDB API key override for actor enrichment") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if not args.tmdb_id and not args.keyword.strip(): + parser.error("keyword or --tmdb-id is required") + + try: + result = execute_search( + keyword=args.keyword, + media_type=args.type, + tmdb_id=args.tmdb_id, + year=args.year, + limit=args.limit, + candidate_limit=args.candidate_limit, + mp_base_url=args.mp_base_url, + app_env=Path(args.app_env), + cache_path=Path(args.cache_path), + use_cache=not args.no_cache, + tmdb_api_key=args.tmdb_api_key, + ) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 2 + + if args.format == "text": + print(format_text(result, compact=args.compact)) + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/hdhive-search-unlock-to-115/scripts/unlock_hdhive.py b/skills/hdhive-search-unlock-to-115/scripts/unlock_hdhive.py new file mode 100755 index 0000000..210a246 --- /dev/null +++ b/skills/hdhive-search-unlock-to-115/scripts/unlock_hdhive.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Unlock HDHive resources through the local MoviePilot plugin. + +Supports selecting by cached search result index or direct slug. Defaults to a +safe mode that refuses paid unlocks unless explicitly allowed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from search_hdhive import DEFAULT_APP_ENV, DEFAULT_CACHE_PATH, DEFAULT_MP_BASE_URL, read_api_token + + +def read_cache(cache_path: Path) -> Dict[str, Any]: + if not cache_path.exists(): + raise FileNotFoundError(f"search cache not found: {cache_path}") + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except Exception as exc: + raise RuntimeError(f"invalid search cache JSON: {exc}") from exc + if not isinstance(payload, dict): + raise RuntimeError("search cache must be a JSON object") + return payload + + +def select_item(cache_payload: Dict[str, Any], index: int) -> Dict[str, Any]: + items = cache_payload.get("results") or [] + if not isinstance(items, list) or not items: + raise RuntimeError("search cache has no results") + for item in items: + if int(item.get("index", 0)) == index: + return item + raise RuntimeError(f"result index {index} not found in cache") + + +def should_treat_as_paid(points: Any) -> bool: + return points not in (None, "", 0, "0") + + +def build_unlock_request( + *, + mp_base_url: str, + api_token: str, + slug: str, + transfer_115: bool, + path: str, +) -> Tuple[str, bytes]: + query = urllib.parse.urlencode({"apikey": api_token}) + url = f"{mp_base_url.rstrip('/')}/api/v1/plugin/HdhiveOpenApi/resources/unlock?{query}" + body = { + "slug": slug, + "transfer_115": transfer_115, + } + if path: + body["path"] = path + return url, json.dumps(body).encode("utf-8") + + +def post_json(url: str, body: bytes) -> Dict[str, Any]: + request = urllib.request.Request( + url=url, + data=body, + method="POST", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + raw = response.read().decode("utf-8", errors="ignore") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"HTTP {exc.code}: {body[:500]}") from exc + except Exception as exc: + raise RuntimeError(f"request failed: {exc}") from exc + try: + payload = json.loads(raw) + except Exception as exc: + raise RuntimeError(f"invalid JSON response: {raw[:300]}") from exc + if not isinstance(payload, dict): + raise RuntimeError("unlock response must be a JSON object") + return payload + + +def normalize_output( + *, + selected_item: Dict[str, Any], + response_payload: Dict[str, Any], + transfer_115: bool, + transfer_path: str, + warning: str, +) -> Dict[str, Any]: + data = response_payload.get("data") or {} + unlock_data = data.get("data") if isinstance(data, dict) else {} + transfer_data = data.get("transfer_115") if isinstance(data, dict) else {} + return { + "success": bool(response_payload.get("success")), + "message": response_payload.get("message", ""), + "selected": { + "index": selected_item.get("index"), + "slug": selected_item.get("slug"), + "title": selected_item.get("title"), + "pan_type": selected_item.get("pan_type"), + "unlock_points": selected_item.get("unlock_points"), + }, + "unlock": { + "slug": data.get("slug") if isinstance(data, dict) else selected_item.get("slug"), + "message": data.get("message", response_payload.get("message", "")) if isinstance(data, dict) else response_payload.get("message", ""), + "url": unlock_data.get("url") if isinstance(unlock_data, dict) else "", + "full_url": unlock_data.get("full_url") if isinstance(unlock_data, dict) else "", + "access_code": unlock_data.get("access_code") if isinstance(unlock_data, dict) else "", + }, + "transfer_115": { + "requested": transfer_115, + "path": transfer_path, + "ok": bool((transfer_data or {}).get("ok")) if isinstance(transfer_data, dict) else False, + "message": (transfer_data or {}).get("message", "") if isinstance(transfer_data, dict) else "", + "save_parent": ((transfer_data or {}).get("data") or {}).get("save_parent", "") if isinstance(transfer_data, dict) else "", + }, + "warning": warning, + } + + +def format_text(payload: Dict[str, Any]) -> str: + selected = payload.get("selected") or {} + unlock = payload.get("unlock") or {} + transfer = payload.get("transfer_115") or {} + lines: List[str] = [] + lines.append( + f"解锁结果: {selected.get('title', '—')} | slug={selected.get('slug', '—')} | {payload.get('message', '—')}" + ) + if payload.get("warning"): + lines.append(f"提示: {payload['warning']}") + if unlock.get("full_url") or unlock.get("url"): + lines.append(f"链接: {unlock.get('full_url') or unlock.get('url')}") + if unlock.get("access_code"): + lines.append(f"提取码: {unlock.get('access_code')}") + if transfer.get("requested"): + lines.append( + f"115转存: {'成功' if transfer.get('ok') else '未完成'} | 目录={transfer.get('path', '—')} | {transfer.get('message', '')}" + ) + return "\n".join(lines) + + +def execute_unlock( + *, + index: int = 0, + slug: str = "", + cache_path: Path = DEFAULT_CACHE_PATH, + allow_paid: bool = False, + transfer_115: bool = True, + path: str = "/待整理", + mp_base_url: str = DEFAULT_MP_BASE_URL, + app_env: Path = DEFAULT_APP_ENV, +) -> Dict[str, Any]: + selected_item: Dict[str, Any] = {} + if index: + cache_payload = read_cache(Path(os.path.expanduser(str(cache_path)))) + selected_item = select_item(cache_payload, index) + else: + selected_item = { + "index": None, + "slug": slug.strip(), + "title": "", + "pan_type": "", + "unlock_points": None, + } + + final_slug = str(selected_item.get("slug") or slug).strip() + if not final_slug: + raise RuntimeError("missing slug") + + if should_treat_as_paid(selected_item.get("unlock_points")) and not allow_paid: + raise RuntimeError( + f"refusing paid unlock without --allow-paid: " + f"{selected_item.get('title', slug)} needs {selected_item.get('unlock_points')} points" + ) + + warning = "" + pan_type = str(selected_item.get("pan_type") or "").strip().lower() + if pan_type and pan_type != "115" and transfer_115: + transfer_115 = False + warning = f"selected resource pan_type={selected_item.get('pan_type')},已自动关闭 115 转存" + + token = read_api_token(Path(app_env)) + + url, body = build_unlock_request( + mp_base_url=mp_base_url, + api_token=token, + slug=final_slug, + transfer_115=transfer_115, + path=path.strip(), + ) + response_payload = post_json(url, body) + + output = normalize_output( + selected_item=selected_item, + response_payload=response_payload, + transfer_115=transfer_115, + transfer_path=path.strip(), + warning=warning, + ) + return output + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Unlock HDHive resources through local MoviePilot.") + parser.add_argument("--index", type=int, default=0, help="Select result by cached search index") + parser.add_argument("--slug", default="", help="Direct HDHive resource slug") + parser.add_argument("--cache-path", default=str(DEFAULT_CACHE_PATH), help="Search cache path") + parser.add_argument("--allow-paid", action="store_true", help="Allow unlocks that cost points") + parser.add_argument("--transfer-115", dest="transfer_115", action="store_true", help="Try 115 transfer after unlock") + parser.add_argument("--no-transfer-115", dest="transfer_115", action="store_false", help="Disable 115 transfer") + parser.set_defaults(transfer_115=True) + parser.add_argument("--path", default="/待整理", help="115 transfer target path") + parser.add_argument("--format", choices=["json", "text"], default="json", help="Output format") + parser.add_argument("--mp-base-url", default=DEFAULT_MP_BASE_URL, help="Local MoviePilot base URL") + parser.add_argument("--app-env", default=str(DEFAULT_APP_ENV), help="Path to MoviePilot app.env") + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if not args.index and not args.slug.strip(): + parser.error("--index or --slug is required") + + try: + output = execute_unlock( + index=args.index, + slug=args.slug, + cache_path=Path(args.cache_path), + allow_paid=args.allow_paid, + transfer_115=args.transfer_115, + path=args.path, + mp_base_url=args.mp_base_url, + app_env=Path(args.app_env), + ) + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + if args.format == "text": + print(format_text(output)) + else: + print(json.dumps(output, ensure_ascii=False, indent=2)) + return 0 if output.get("success") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..e34dad4 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,25 @@ +# Cookie 导出工具 + +这个目录收录了两个可分发的本机导出工具: + +- [影巢 Cookie 导出](./hdhive-cookie-export/README.md) +- [夸克 Cookie 导出](./quark-cookie-export/README.md) + +它们的定位是: + +- 从**当前电脑浏览器**读取登录态 Cookie +- 写回 `MoviePilot` / `Agent影视助手` 的插件配置 +- 作为 `刷新影巢Cookie`、`修复影巢签到`、`刷新夸克Cookie`、`修复夸克转存` 这类命令的宿主机执行层 + +注意: + +- 这些工具运行在**外部智能体所在电脑**,不是运行在 NAS 容器里 +- 如果 `MoviePilot` 在 NAS,而智能体在 Win / Mac,Cookie 会先从当前电脑浏览器导出,再写回 NAS 上的 `MoviePilot` +- 默认更适合 macOS;如果是别的环境,请先确认 `python3`、浏览器 Cookie 读取和容器重启命令是否可用 + +安装依赖示例: + +```bash +cd tools/hdhive-cookie-export && python3 -m pip install -r requirements.txt +cd tools/quark-cookie-export && python3 -m pip install -r requirements.txt +``` diff --git a/tools/hdhive-cookie-export/README.md b/tools/hdhive-cookie-export/README.md new file mode 100644 index 0000000..276fe26 --- /dev/null +++ b/tools/hdhive-cookie-export/README.md @@ -0,0 +1,115 @@ +# 影巢 Cookie 快速导出 + +这个目录里提供了一个小脚本,可以直接从本机浏览器里读取指定站点的登录 Cookie, +并自动拼成插件配置需要的完整 `Cookie` 字符串。 + +另外还附带了一个可双击运行的 macOS 启动器: + +`影巢Cookie导出.command` + +现在它还支持把最新 Cookie 直接写回 MoviePilot 的 +`plugin.HdhiveSign` 配置,同时同步写入 +`/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json`, +并同步写入 `plugin.AgentResourceOfficer.hdhive_checkin_cookie`, +并自动重启 `moviepilot-v2` 容器。 + +## 安装 + +```bash +pip3 install -r requirements.txt +``` + +如果你是从 `MoviePilot-Plugins` 仓库里使用,推荐直接保留这个目录结构不动,并把: + +- `ARO_HDHIVE_COOKIE_EXPORT_DIR` + +指向本目录。 + +## 用法 + +Chrome: + +```bash +python3 export_yc_cookie.py yc.example.com +``` + +Edge: + +```bash +python3 export_yc_cookie.py yc.example.com --browser edge +``` + +如果你拿到的是完整网址,也可以直接传: + +```bash +python3 export_yc_cookie.py https://yc.example.com +``` + +脚本会: + +1. 从浏览器读取该域名下的 Cookie。 +2. 自动拼成 `name=value; name2=value2` 格式。 +3. 自动复制到 macOS 剪贴板。 +4. 检查是否包含 `token` 和 `csrf_access_token`。 +5. 如果只有 `token` 没有 `csrf_access_token`,会提示这更可能是站点或插件兼容性问题。 + +## 直接写回 MoviePilot + +如果你希望把最新登录态直接同步到 MoviePilot: + +```bash +python3 export_yc_cookie.py https://hdhive.com --browser edge --write-mp --restart-container moviepilot-v2 +``` + +默认写入位置: + +- 数据库:`/Applications/Dockge/moviepilotv2/config/user.db` +- 配置键:`plugin.HdhiveSign` +- 资源官键:`plugin.AgentResourceOfficer.hdhive_checkin_cookie` +- 旧签到 JSON:`/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json` + +写回时会优先生成插件真正需要的最小 Cookie: + +- `token` +- `csrf_access_token`(如果浏览器里存在) +- `refresh_token`(当前影巢网页签到兜底也依赖它) + +## 双击使用 + +如果你不想每次手动敲命令,可以直接双击: + +`影巢Cookie导出.command` + +它会提示你输入: + +1. 影巢域名或完整网址 +2. 浏览器类型 +3. 操作模式 + - 只导出到剪贴板 + - 导出并写回 MoviePilot(推荐) + +推荐模式下,它会: + +1. 从浏览器读取最新 Cookie +2. 写回 MoviePilot 的 `plugin.HdhiveSign` +3. 同步写入 `plugin.AgentResourceOfficer.hdhive_checkin_cookie` +4. 同步写入 `hdhivedailysign.json` +5. 自动重启 `moviepilot-v2` +6. 让新 Cookie 立即生效 + +## 推荐流程 + +1. 先正常登录影巢站点。 +2. 打开一次站点首页或任意已登录页面。 +3. 双击 `影巢Cookie导出.command` +4. 选择“导出并写回 MoviePilot(推荐)” + +如果你只想手动复制,也可以选“只导出到剪贴板”。 + +## 说明 + +- 如果站点改了域名,换成新域名重新执行一次。 +- 如果脚本提示缺少 `token` 或 `csrf_access_token`,通常说明登录态已经过期,或者当前域名不对。 +- 如果脚本明确提示“只有 token,没有 csrf_access_token”,说明脚本已经成功读到浏览器 Cookie,但站点当前登录流没有给出 `csrf_access_token`。这种情况更像插件规则过时,而不是你不会抓 Cookie。 +- 这个方案的目标不是“让 Cookie 永不过期”,而是“过期后 10 秒内重新拿到最新 Cookie”。 +- 写回 MoviePilot 后,如果容器重启成功,新 Cookie 会立即生效。 diff --git a/tools/hdhive-cookie-export/export_yc_cookie.py b/tools/hdhive-cookie-export/export_yc_cookie.py new file mode 100644 index 0000000..2cc2091 --- /dev/null +++ b/tools/hdhive-cookie-export/export_yc_cookie.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlparse + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Export browser cookies for a site into a single Cookie header string." + ) + parser.add_argument( + "site", + help="Site domain or full URL, for example yc.example.com or https://yc.example.com", + ) + parser.add_argument( + "--browser", + choices=["chrome", "edge", "brave", "chromium", "firefox", "opera", "vivaldi"], + default="chrome", + help="Browser to read cookies from. Default: chrome", + ) + parser.add_argument( + "--no-copy", + action="store_true", + help="Print only and do not copy the result to clipboard.", + ) + parser.add_argument( + "--out", + type=Path, + help="Optional file path to write the cookie string to.", + ) + parser.add_argument( + "--write-mp", + action="store_true", + help="Write the exported cookie back into MoviePilot plugin config.", + ) + parser.add_argument( + "--mp-db", + type=Path, + default=Path("/Applications/Dockge/moviepilotv2/config/user.db"), + help="MoviePilot sqlite config DB path. Default: /Applications/Dockge/moviepilotv2/config/user.db", + ) + parser.add_argument( + "--mp-plugin-key", + default="plugin.HdhiveSign", + help="MoviePilot systemconfig key for the HDHive plugin. Default: plugin.HdhiveSign", + ) + parser.add_argument( + "--aro-plugin-key", + default="plugin.AgentResourceOfficer", + help="MoviePilot systemconfig key for AgentResourceOfficer. Default: plugin.AgentResourceOfficer", + ) + parser.add_argument( + "--restart-container", + help="Optional Docker container name to restart after writing MoviePilot config, for example moviepilot-v2", + ) + parser.add_argument( + "--hdhive-json", + type=Path, + default=Path("/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json"), + help="Optional HDHiveDailySign JSON config path to update alongside MoviePilot config.", + ) + return parser.parse_args() + + +def normalize_domain(site: str) -> str: + if "://" not in site: + site = f"https://{site}" + parsed = urlparse(site) + domain = parsed.hostname + if not domain: + raise ValueError(f"Could not parse domain from input: {site}") + return domain + + +def load_cookiejar(browser: str, domain: str): + try: + import browser_cookie3 + except ImportError as exc: + raise RuntimeError( + "Missing dependency 'browser_cookie3'. Install it with: pip3 install browser-cookie3" + ) from exc + + loader = getattr(browser_cookie3, browser, None) + if loader is None: + raise RuntimeError(f"Browser '{browser}' is not supported by browser_cookie3") + + try: + return loader(domain_name=domain) + except Exception as exc: # pragma: no cover - depends on local browser setup + raise RuntimeError( + f"Failed to read cookies from {browser}. Make sure the browser is installed, " + "you are logged in to the site, and the site has been opened at least once." + ) from exc + + +def build_cookie_header(cookiejar, domain: str) -> str: + items: list[str] = [] + seen: set[str] = set() + + for cookie in cookiejar: + cookie_domain = cookie.domain.lstrip(".") + if not (cookie_domain == domain or domain.endswith(f".{cookie_domain}") or cookie_domain.endswith(f".{domain}")): + continue + if cookie.name in seen: + continue + seen.add(cookie.name) + items.append(f"{cookie.name}={cookie.value}") + + return "; ".join(items) + + +def build_cookie_map(cookiejar, domain: str) -> dict[str, str]: + items: dict[str, str] = {} + for cookie in cookiejar: + cookie_domain = cookie.domain.lstrip(".") + if not ( + cookie_domain == domain + or domain.endswith(f".{cookie_domain}") + or cookie_domain.endswith(f".{domain}") + ): + continue + items.setdefault(cookie.name, cookie.value) + return items + + +def extract_cookie_names(cookiejar, domain: str) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + + for cookie in cookiejar: + cookie_domain = cookie.domain.lstrip(".") + if not ( + cookie_domain == domain + or domain.endswith(f".{cookie_domain}") + or cookie_domain.endswith(f".{domain}") + ): + continue + if cookie.name in seen: + continue + seen.add(cookie.name) + names.append(cookie.name) + + return names + + +def copy_to_clipboard(text: str) -> None: + subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True) + + +def build_mp_cookie_header(cookie_map: dict[str, str]) -> str: + names = ["token", "csrf_access_token", "refresh_token"] + parts = [f"{name}={cookie_map[name]}" for name in names if cookie_map.get(name)] + if parts: + return "; ".join(parts) + return "; ".join(f"{name}={value}" for name, value in cookie_map.items()) + + +def update_moviepilot_config(db_path: Path, plugin_key: str, cookie_header: str) -> tuple[dict, bool]: + if not db_path.exists(): + raise RuntimeError(f"MoviePilot DB not found: {db_path}") + + conn = sqlite3.connect(str(db_path)) + try: + cur = conn.cursor() + row = cur.execute( + "SELECT value FROM systemconfig WHERE key = ?", + (plugin_key,), + ).fetchone() + + created = False + if row and row[0]: + try: + config = json.loads(row[0]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Existing config for {plugin_key} is not valid JSON") from exc + else: + config = {"enabled": True} + created = True + + config["cookie"] = cookie_header + + payload = json.dumps(config, ensure_ascii=False) + if row: + cur.execute( + "UPDATE systemconfig SET value = ? WHERE key = ?", + (payload, plugin_key), + ) + else: + cur.execute( + "INSERT INTO systemconfig(key, value) VALUES(?, ?)", + (plugin_key, payload), + ) + created = True + + conn.commit() + return config, created + finally: + conn.close() + + +def update_agent_resource_officer_config( + db_path: Path, + plugin_key: str, + cookie_header: str, +) -> tuple[dict, bool]: + if not db_path.exists(): + raise RuntimeError(f"MoviePilot DB not found: {db_path}") + + conn = sqlite3.connect(str(db_path)) + try: + cur = conn.cursor() + row = cur.execute( + "SELECT value FROM systemconfig WHERE key = ?", + (plugin_key,), + ).fetchone() + + created = False + if row and row[0]: + try: + config = json.loads(row[0]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Existing config for {plugin_key} is not valid JSON") from exc + else: + config = {"enabled": True} + created = True + + config["hdhive_checkin_cookie"] = cookie_header + + payload = json.dumps(config, ensure_ascii=False) + if row: + cur.execute( + "UPDATE systemconfig SET value = ? WHERE key = ?", + (payload, plugin_key), + ) + else: + cur.execute( + "INSERT INTO systemconfig(key, value) VALUES(?, ?)", + (plugin_key, payload), + ) + created = True + + conn.commit() + return config, created + finally: + conn.close() + + +def update_hdhive_daily_sign_json(file_path: Path, cookie_header: str) -> tuple[dict, bool]: + created = False + if file_path.exists(): + try: + config = json.loads(file_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Existing HDHiveDailySign config is not valid JSON: {file_path}" + ) from exc + else: + config = {} + created = True + + if not isinstance(config, dict): + raise RuntimeError(f"HDHiveDailySign config is not an object: {file_path}") + + config["cookie"] = cookie_header + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") + return config, created + + +def restart_container(container_name: str) -> None: + subprocess.run(["docker", "restart", container_name], check=True) + + +def main() -> int: + args = parse_args() + + try: + domain = normalize_domain(args.site) + cookiejar = load_cookiejar(args.browser, domain) + cookie_map = build_cookie_map(cookiejar, domain) + cookie_names = extract_cookie_names(cookiejar, domain) + cookie_header = build_cookie_header(cookiejar, domain) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + if not cookie_header: + print( + f"Error: no cookies found for {domain}. Open the site in {args.browser} and log in first.", + file=sys.stderr, + ) + return 1 + + missing = [name for name in ("token", "csrf_access_token") if f"{name}=" not in cookie_header] + + mp_cookie_header = build_mp_cookie_header(cookie_map) + + if args.out: + args.out.write_text(cookie_header, encoding="utf-8") + + if not args.no_copy: + try: + copy_to_clipboard(cookie_header) + except Exception as exc: + print(f"Warning: failed to copy to clipboard: {exc}", file=sys.stderr) + + print(cookie_header) + print(file=sys.stderr) + print(f"Domain: {domain}", file=sys.stderr) + print(f"Length: {len(cookie_header)}", file=sys.stderr) + print( + "Found cookie names: " + (", ".join(cookie_names) if cookie_names else "(none)"), + file=sys.stderr, + ) + print( + "MoviePilot cookie payload: " + (mp_cookie_header or "(empty)"), + file=sys.stderr, + ) + if missing: + if "token" not in missing and "csrf_access_token" in missing: + print( + "Warning: browser contains token, but not csrf_access_token.", + file=sys.stderr, + ) + print( + "This usually means the site did not issue a csrf cookie for the current login flow.", + file=sys.stderr, + ) + print( + "For current HDHive + MoviePilot plugin, token-only mode is supported, so export/update can continue.", + file=sys.stderr, + ) + else: + print( + "Warning: missing required fields: " + + ", ".join(missing) + + ". You may need to re-login or confirm the correct site domain.", + file=sys.stderr, + ) + return 2 + + if args.write_mp: + try: + _, created = update_moviepilot_config(args.mp_db, args.mp_plugin_key, mp_cookie_header) + print( + f"MoviePilot config updated: {args.mp_plugin_key} -> {args.mp_db}", + file=sys.stderr, + ) + if created: + print("MoviePilot config row did not exist and was created.", file=sys.stderr) + _, aro_created = update_agent_resource_officer_config( + args.mp_db, + args.aro_plugin_key, + mp_cookie_header, + ) + print( + f"AgentResourceOfficer config updated: {args.aro_plugin_key} -> {args.mp_db}", + file=sys.stderr, + ) + if aro_created: + print("AgentResourceOfficer config row did not exist and was created.", file=sys.stderr) + if args.hdhive_json: + _, json_created = update_hdhive_daily_sign_json(args.hdhive_json, mp_cookie_header) + print(f"HDHiveDailySign cookie updated: {args.hdhive_json}", file=sys.stderr) + if json_created: + print("HDHiveDailySign JSON did not exist and was created.", file=sys.stderr) + if args.restart_container: + restart_container(args.restart_container) + print(f"Docker container restarted: {args.restart_container}", file=sys.stderr) + except Exception as exc: + print(f"Error: failed to update MoviePilot config: {exc}", file=sys.stderr) + return 3 + + if args.write_mp: + if args.no_copy: + print("Cookie exported successfully and written back to MoviePilot/HDHiveDailySign.", file=sys.stderr) + else: + print( + "Cookie exported successfully, copied to clipboard, and written back to MoviePilot/HDHiveDailySign.", + file=sys.stderr, + ) + else: + if args.no_copy: + print("Cookie exported successfully.", file=sys.stderr) + else: + print("Cookie exported successfully and copied to clipboard.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hdhive-cookie-export/requirements.txt b/tools/hdhive-cookie-export/requirements.txt new file mode 100644 index 0000000..b2cade6 --- /dev/null +++ b/tools/hdhive-cookie-export/requirements.txt @@ -0,0 +1 @@ +browser-cookie3>=0.20,<1 diff --git a/tools/hdhive-cookie-export/影巢Cookie导出.command b/tools/hdhive-cookie-export/影巢Cookie导出.command new file mode 100755 index 0000000..fab9e44 --- /dev/null +++ b/tools/hdhive-cookie-export/影巢Cookie导出.command @@ -0,0 +1,69 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PYTHON_BIN="${SCRIPT_DIR}/.venv/bin/python" +SCRIPT_PATH="${SCRIPT_DIR}/export_yc_cookie.py" + +echo "==============================" +echo "影巢 Cookie 快速导出" +echo "==============================" +echo +echo "先确保你已经在 Edge 里登录影巢,并打开过 https://hdhive.com 。" +echo + +if [[ ! -x "${PYTHON_BIN}" ]]; then + if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python3)" + else + echo "未找到可用的 python3,请先安装 Python。" + echo + read "DUMMY?按回车关闭..." + exit 1 + fi +fi + +if [[ ! -f "${SCRIPT_PATH}" ]]; then + echo "未找到导出脚本:${SCRIPT_PATH}" + echo + read "DUMMY?按回车关闭..." + exit 1 +fi + +SITE="https://hdhive.com" +BROWSER="edge" +RUN_ARGS=( + "${SITE}" + --browser "${BROWSER}" + --write-mp + --mp-db /Applications/Dockge/moviepilotv2/config/user.db + --mp-plugin-key plugin.HdhiveSign + --restart-container moviepilot-v2 + --hdhive-json /Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json +) +SUCCESS_HINT="导出完成,Cookie 已写回 MoviePilot / HDHiveDailySign,并已重启 moviepilot-v2。" +SUCCESS_HINT_2="后面你不用再手动复制或粘贴 Cookie。" + +echo +echo "将使用固定配置自动执行:" +echo "- 站点:${SITE}" +echo "- 浏览器:${BROWSER}" +echo "- 模式:写回 MoviePilot + 同步 HDHiveDailySign + 重启容器" +echo +echo "正在执行..." +echo + +if ! "${PYTHON_BIN}" "${SCRIPT_PATH}" "${RUN_ARGS[@]}"; then + echo + echo "执行失败。" + echo "请确认 Edge 里已经登录影巢,并且已经打开过 ${SITE} 。" + echo + read "DUMMY?按回车关闭..." + exit 1 +fi + +echo +echo "${SUCCESS_HINT}" +echo "${SUCCESS_HINT_2}" +echo +read "DUMMY?按回车关闭..." diff --git a/tools/quark-cookie-export/README.md b/tools/quark-cookie-export/README.md new file mode 100644 index 0000000..1aef541 --- /dev/null +++ b/tools/quark-cookie-export/README.md @@ -0,0 +1,69 @@ +# 夸克 Cookie 导出 + +这个目录提供了一个轻量的夸克 Cookie 导出工具,用来从本机浏览器读取当前登录态,并自动写回 MoviePilot 插件配置。 + +适合场景: + +1. 没有部署 CookieCloud +2. 夸克提示登录态失效、`require login [guest]` +3. 想快速恢复 `AgentResourceOfficer` / `QuarkShareSaver` 的夸克转存能力 + +## 能做什么 + +- 从 Edge / Chrome / Brave / Firefox 读取 `pan.quark.cn` 的当前 Cookie +- 可选复制到剪贴板 +- 自动写回: + - `plugin.AgentResourceOfficer.quark_cookie` + - `plugin.QuarkShareSaver.cookie` +- 可选自动重启 `moviepilot-v2` + +## 命令行用法 + +安装依赖: + +```bash +pip3 install -r requirements.txt +``` + +直接从 Edge 读取并写回 MoviePilot: + +```bash +python3 export_quark_cookie.py https://pan.quark.cn \ + --browser edge \ + --write-mp \ + --restart-container moviepilot-v2 +``` + +只导出并复制到剪贴板: + +```bash +python3 export_quark_cookie.py https://pan.quark.cn --browser edge +``` + +默认写入数据库: + +- `/Applications/Dockge/moviepilotv2/config/user.db` + +如果你是从 `MoviePilot-Plugins` 仓库里使用,推荐直接保留这个目录结构不动,并把: + +- `ARO_QUARK_COOKIE_EXPORT_DIR` + +指向本目录。 + +## 双击使用 + +直接双击: + +`夸克Cookie导出.command` + +它会固定执行: + +1. 从 Edge 读取 `https://pan.quark.cn` +2. 自动写回 MoviePilot +3. 自动重启 `moviepilot-v2` + +## 推荐流程 + +1. 先在 Edge 打开并登录 [https://pan.quark.cn](https://pan.quark.cn) +2. 双击 `夸克Cookie导出.command` +3. 回到智能体或 MoviePilot 重试夸克转存 diff --git a/tools/quark-cookie-export/export_quark_cookie.py b/tools/quark-cookie-export/export_quark_cookie.py new file mode 100644 index 0000000..01e8dc9 --- /dev/null +++ b/tools/quark-cookie-export/export_quark_cookie.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlparse + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Export Quark browser cookies and optionally write them back into MoviePilot plugin config." + ) + parser.add_argument( + "site", + nargs="?", + default="https://pan.quark.cn", + help="Quark site URL. Default: https://pan.quark.cn", + ) + parser.add_argument( + "--browser", + choices=["chrome", "edge", "brave", "chromium", "firefox", "opera", "vivaldi"], + default="edge", + help="Browser to read cookies from. Default: edge", + ) + parser.add_argument( + "--no-copy", + action="store_true", + help="Do not copy the cookie header to clipboard.", + ) + parser.add_argument( + "--show-cookie", + action="store_true", + help="Print the raw cookie header to stdout. Disabled by default for safety.", + ) + parser.add_argument( + "--write-mp", + action="store_true", + help="Write the exported cookie back into MoviePilot plugin config.", + ) + parser.add_argument( + "--mp-db", + type=Path, + default=Path("/Applications/Dockge/moviepilotv2/config/user.db"), + help="MoviePilot sqlite config DB path.", + ) + parser.add_argument( + "--aro-plugin-key", + default="plugin.AgentResourceOfficer", + help="MoviePilot systemconfig key for AgentResourceOfficer. Default: plugin.AgentResourceOfficer", + ) + parser.add_argument( + "--qss-plugin-key", + default="plugin.QuarkShareSaver", + help="MoviePilot systemconfig key for QuarkShareSaver. Default: plugin.QuarkShareSaver", + ) + parser.add_argument( + "--restart-container", + help="Optional Docker container name to restart after writing MoviePilot config.", + ) + return parser.parse_args() + + +def normalize_domain(site: str) -> str: + if "://" not in site: + site = f"https://{site}" + parsed = urlparse(site) + domain = parsed.hostname + if not domain: + raise ValueError(f"Could not parse domain from input: {site}") + return domain + + +def load_cookiejar(browser: str, domain: str): + try: + import browser_cookie3 + except ImportError as exc: + raise RuntimeError( + "Missing dependency 'browser_cookie3'. Install it with: pip3 install browser-cookie3" + ) from exc + + loader = getattr(browser_cookie3, browser, None) + if loader is None: + raise RuntimeError(f"Browser '{browser}' is not supported by browser_cookie3") + + try: + return loader(domain_name=domain) + except Exception as exc: + raise RuntimeError( + f"Failed to read cookies from {browser}. Make sure the browser is installed, " + "you are logged in to Quark, and pan.quark.cn has been opened at least once." + ) from exc + + +def candidate_cookie_domains(domain: str) -> list[str]: + parts = [part for part in domain.split(".") if part] + domains: list[str] = [] + if len(parts) >= 2: + domains.append(".".join(parts[-2:])) + domains.append(domain) + return list(dict.fromkeys(domains)) + + +def domain_matches(cookie_domain: str, domain: str) -> bool: + cookie_domain = cookie_domain.lstrip(".") + return ( + cookie_domain == domain + or domain.endswith(f".{cookie_domain}") + or cookie_domain.endswith(f".{domain}") + ) + + +def build_cookie_list(cookiejars, domain: str) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + seen: set[str] = set() + for cookiejar in cookiejars: + for cookie in cookiejar: + if not domain_matches(cookie.domain, domain): + continue + if cookie.name in seen: + continue + seen.add(cookie.name) + items.append( + { + "domain": cookie.domain.lstrip("."), + "name": cookie.name, + "value": cookie.value, + } + ) + return items + + +def cookie_list_to_header(items: list[dict[str, str]]) -> str: + return "; ".join(f"{item['name']}={item['value']}" for item in items if item.get("name")) + + +def missing_auth_cookie_names(items: list[dict[str, str]]) -> list[str]: + names = {item.get("name") for item in items} + required_any = {"__puus", "__pus", "puus", "logintoken"} + if names & required_any: + return [] + return sorted(required_any) + + +def copy_to_clipboard(text: str) -> None: + subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True) + + +def update_plugin_cookie(db_path: Path, plugin_key: str, field_name: str, cookie_header: str) -> tuple[dict, bool]: + if not db_path.exists(): + raise RuntimeError(f"MoviePilot DB not found: {db_path}") + conn = sqlite3.connect(str(db_path)) + try: + cur = conn.cursor() + row = cur.execute("SELECT value FROM systemconfig WHERE key = ?", (plugin_key,)).fetchone() + created = False + if row and row[0]: + try: + config = json.loads(row[0]) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Existing config for {plugin_key} is not valid JSON") from exc + else: + config = {"enabled": True} + created = True + config[field_name] = cookie_header + payload = json.dumps(config, ensure_ascii=False) + if row: + cur.execute("UPDATE systemconfig SET value = ? WHERE key = ?", (payload, plugin_key)) + else: + cur.execute("INSERT INTO systemconfig(key, value) VALUES(?, ?)", (plugin_key, payload)) + created = True + conn.commit() + return config, created + finally: + conn.close() + + +def restart_container(container_name: str) -> None: + subprocess.run(["docker", "restart", container_name], check=True) + + +def main() -> int: + args = parse_args() + try: + domain = normalize_domain(args.site) + cookiejars = [load_cookiejar(args.browser, item) for item in candidate_cookie_domains(domain)] + items = build_cookie_list(cookiejars, domain) + cookie_header = cookie_list_to_header(items) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + if not cookie_header: + print( + f"Error: no cookies found for {domain}. Open pan.quark.cn in {args.browser} and log in first.", + file=sys.stderr, + ) + return 1 + + missing_auth = missing_auth_cookie_names(items) + if missing_auth: + print( + "Error: exported cookies do not include Quark auth cookies " + f"({', '.join(missing_auth)}). Open https://pan.quark.cn in the selected browser, " + "confirm it is logged in, then retry.", + file=sys.stderr, + ) + print(f"Found cookie names: {', '.join(item['name'] for item in items)}", file=sys.stderr) + return 2 + + if not args.no_copy: + try: + copy_to_clipboard(cookie_header) + except Exception as exc: + print(f"Warning: failed to copy to clipboard: {exc}", file=sys.stderr) + + if args.show_cookie: + print(cookie_header) + + print(f"Domain: {domain}", file=sys.stderr) + print(f"Length: {len(cookie_header)}", file=sys.stderr) + print("Found cookie names: " + ", ".join(item["name"] for item in items), file=sys.stderr) + + if args.write_mp: + try: + _, aro_created = update_plugin_cookie(args.mp_db, args.aro_plugin_key, "quark_cookie", cookie_header) + print( + f"AgentResourceOfficer Quark cookie updated: {args.aro_plugin_key} -> {args.mp_db}", + file=sys.stderr, + ) + if aro_created: + print("AgentResourceOfficer config row did not exist and was created.", file=sys.stderr) + + _, qss_created = update_plugin_cookie(args.mp_db, args.qss_plugin_key, "cookie", cookie_header) + print( + f"QuarkShareSaver cookie updated: {args.qss_plugin_key} -> {args.mp_db}", + file=sys.stderr, + ) + if qss_created: + print("QuarkShareSaver config row did not exist and was created.", file=sys.stderr) + + if args.restart_container: + restart_container(args.restart_container) + print(f"Docker container restarted: {args.restart_container}", file=sys.stderr) + except Exception as exc: + print(f"Error: failed to update MoviePilot config: {exc}", file=sys.stderr) + return 3 + + if args.write_mp: + if args.no_copy: + print("Quark cookie exported successfully and written back to MoviePilot.", file=sys.stderr) + else: + print( + "Quark cookie exported successfully, copied to clipboard, and written back to MoviePilot.", + file=sys.stderr, + ) + else: + if args.no_copy: + print("Quark cookie exported successfully.", file=sys.stderr) + else: + print("Quark cookie exported successfully and copied to clipboard.", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/quark-cookie-export/requirements.txt b/tools/quark-cookie-export/requirements.txt new file mode 100644 index 0000000..b2cade6 --- /dev/null +++ b/tools/quark-cookie-export/requirements.txt @@ -0,0 +1 @@ +browser-cookie3>=0.20,<1 diff --git a/tools/quark-cookie-export/夸克Cookie导出.command b/tools/quark-cookie-export/夸克Cookie导出.command new file mode 100755 index 0000000..69a5899 --- /dev/null +++ b/tools/quark-cookie-export/夸克Cookie导出.command @@ -0,0 +1,23 @@ +#!/bin/zsh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PYTHON_BIN="$SCRIPT_DIR/.venv/bin/python" + +if [ ! -x "$PYTHON_BIN" ]; then + if command -v python3 >/dev/null 2>&1; then + PYTHON_BIN="$(command -v python3)" + else + echo "未找到可用的 python3,请先安装 Python。" + read -r -p "按回车键退出..." + exit 1 + fi +fi + +cd "$SCRIPT_DIR" +"$PYTHON_BIN" export_quark_cookie.py https://pan.quark.cn --browser edge --write-mp --restart-container moviepilot-v2 + +echo +echo "导出完成,夸克 Cookie 已写回 MoviePilot,并已重启 moviepilot-v2。" +echo "后面你不用再手动复制或粘贴 Cookie。" +read -r -p "按回车键退出..."