From ad7cce72f48cc078e8edd5662adbf5980fb35403 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Wed, 20 May 2026 20:10:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20feedback-issue=20Agent=20s?= =?UTF-8?q?kill=EF=BC=9A=E6=8A=8A=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88?= =?UTF-8?q?=E6=95=B4=E7=90=86=E4=B8=BA=E4=B8=8A=E6=B8=B8=20Issue=20(#5799)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/agent/tools/factory.py | 2 + app/agent/tools/impl/submit_feedback_issue.py | 682 ++++++++++++++++++ skills/feedback-issue/SKILL.md | 444 ++++++++++++ .../test_agent_submit_feedback_issue_tool.py | 477 ++++++++++++ 4 files changed, 1605 insertions(+) create mode 100644 app/agent/tools/impl/submit_feedback_issue.py create mode 100644 skills/feedback-issue/SKILL.md create mode 100644 tests/test_agent_submit_feedback_issue_tool.py diff --git a/app/agent/tools/factory.py b/app/agent/tools/factory.py index e44810ef..a87a6a30 100644 --- a/app/agent/tools/factory.py +++ b/app/agent/tools/factory.py @@ -77,6 +77,7 @@ from app.agent.tools.impl.query_custom_identifiers import QueryCustomIdentifiers from app.agent.tools.impl.update_custom_identifiers import UpdateCustomIdentifiersTool from app.agent.tools.impl.query_system_settings import QuerySystemSettingsTool from app.agent.tools.impl.update_system_settings import UpdateSystemSettingsTool +from app.agent.tools.impl.submit_feedback_issue import SubmitFeedbackIssueTool from app.agent.llm.capability import AgentCapabilityManager from app.core.plugin import PluginManager from app.log import logger @@ -223,6 +224,7 @@ class MoviePilotToolFactory: UpdateCustomIdentifiersTool, QuerySystemSettingsTool, UpdateSystemSettingsTool, + SubmitFeedbackIssueTool, ] if MoviePilotToolFactory._should_enable_choice_tool(channel): tool_definitions.append(AskUserChoiceTool) diff --git a/app/agent/tools/impl/submit_feedback_issue.py b/app/agent/tools/impl/submit_feedback_issue.py new file mode 100644 index 00000000..cfd6fd0b --- /dev/null +++ b/app/agent/tools/impl/submit_feedback_issue.py @@ -0,0 +1,682 @@ +"""向 jxxghp/MoviePilot 上游仓库提交问题反馈 Issue 的工具。 + +设计要点: +- 不接受任意仓库参数,目标仓库恒定为 ``jxxghp/MoviePilot`` 后端上游,避免被 + 滥用为通用 GitHub 写入通道。 +- 调用前根据 ``settings.GITHUB_TOKEN`` 是否存在以及权限是否足够,分三种结局: + 1) 成功:通过 GitHub REST API ``POST /repos/jxxghp/MoviePilot/issues`` + 创建 Issue,返回 ``html_url``。 + 2) 无 token:返回 ``no_token`` 结局以及一个 GitHub Issue Forms 预填 URL, + 由 Agent 在 TG / 飞书机器人等渠道里给用户一个可点击链接兜底,并提示 + 管理员配置 ``GITHUB_TOKEN``。 + 3) Token 无写权限或被拒:返回 ``no_permission`` 结局 + 预填 URL,并提示 + 重新配置一个带 ``public_repo``(或 ``repo``)scope 的 Token。 +- 仅 admin 用户可触发,防止任意 TG 群成员通过 Bot 给上游刷 Issue。 +""" + +from __future__ import annotations + +import hashlib +import json +import re +import time +from typing import ClassVar, Optional, Type +from urllib.parse import quote + +from pydantic import BaseModel, Field + +from app.agent.tools.base import MoviePilotTool +from app.core.config import settings +from app.log import logger +from app.utils.http import AsyncRequestUtils + + +# 目标仓库恒定,不接受外部覆盖;如未来要支持前端/插件仓库反馈,新增独立 tool +# 而非把这个常量做成可配置项,避免被 prompt 注入指向任意仓库。 +FEEDBACK_REPO_OWNER = "jxxghp" +FEEDBACK_REPO_NAME = "MoviePilot" +FEEDBACK_REPO = f"{FEEDBACK_REPO_OWNER}/{FEEDBACK_REPO_NAME}" +FEEDBACK_ISSUE_API = f"https://api.github.com/repos/{FEEDBACK_REPO}/issues" +FEEDBACK_ISSUE_NEW_URL = f"https://github.com/{FEEDBACK_REPO}/issues/new" +FEEDBACK_ISSUE_TEMPLATE = "bug_report.yml" +FEEDBACK_REQUEST_TIMEOUT = 15 + +# 允许的运行环境与问题类型枚举值,与 ``.github/ISSUE_TEMPLATE/bug_report.yml`` +# 表单 ``options`` 字段严格一致;前置校验避免上游解析失败或被自动关闭。 +ALLOWED_ENVIRONMENTS = ("Docker", "Windows") +ALLOWED_ISSUE_TYPES = ("主程序运行问题", "插件问题", "其他问题") + +# 长度上限:参考 GitHub Issue 实际限制并留余量。 +# - title 256 字符(GitHub 截断到 256,超长会被静默裁剪) +# - body 60 KB(GitHub 上限 ~65535,留 5KB 余量) +# - logs 8 KB(SKILL.md 给 agent 的软上限是 3KB;这里以 8KB 兜底, +# 再加上 redaction 仍可能膨胀,留充足余量但不放任日志吞掉整段正文) +MAX_TITLE_CHARS = 256 +MAX_BODY_CHARS = 60 * 1024 +MAX_LOGS_CHARS = 8 * 1024 +# 预填 URL 走 GET,浏览器 / Chat 平台对 URL 长度通常限制在 4-8KB; +# logs 在 URL 路径下需要更严格的上限,给其它必填字段留余量。 +MAX_URL_LOGS_CHARS = 3 * 1024 + +# 防止 agent 重复触发提交:60 秒内同 title+body 哈希命中视为重复。 +DEDUP_TTL_SECONDS = 60 + +# 日志二次脱敏正则:作为 defense-in-depth,避免 agent 漏脱敏时把凭据直接 +# 写进公网 issue。SKILL.md 要求 agent 主动脱敏,这里只兜最常见的高危模式。 +_SENSITIVE_PATTERNS: tuple[tuple[re.Pattern, str], ...] = ( + (re.compile(r"(?i)(Cookie\s*:\s*)[^\r\n]+"), r"\1"), + (re.compile(r"(?i)(Set-Cookie\s*:\s*)[^\r\n]+"), r"\1"), + ( + re.compile(r"(?i)(Authorization\s*:\s*)(Bearer|Basic|Token)\s+\S+"), + r"\1\2 ", + ), + ( + # 捕获原始分隔符(``:`` 或 ``=``)并在替换中保留,避免把 ``key: val`` + # 强制改成 ``key=`` 破坏日志阅读体验 + re.compile( + r"(?i)\b(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|" + r"passkey|password|secret|token)(\s*[:=]\s*)['\"]?[^\s'\"&\r\n]+" + ), + r"\1\2", + ), +) + + +class SubmitFeedbackIssueInput(BaseModel): + """向 jxxghp/MoviePilot 提交问题反馈 Issue 的输入参数模型。 + + 所有字段均与上游 ``bug_report.yml`` 表单字段对齐;正文与日志由调用方 + (通常是 Agent 通过 feedback-issue skill 整理)预先组织好,本工具只 + 负责把这些字段稳定地拼成 GitHub Issue body / labels 并发起请求。 + """ + + explanation: str = Field( + ..., + description="Clear explanation of why this tool is being used in the current context", + ) + title: str = Field( + ..., + description=( + "Issue title. Must follow upstream format `[错误报告]: <短描述>`. " + "Do NOT keep the template placeholder text `请在此处简单描述你的问题`." + ), + ) + version: str = Field( + ..., + description=( + "Current MoviePilot version, e.g. v2.12.2. If user does not know, " + "fall back to the running backend version returned by system APIs." + ), + ) + environment: str = Field( + ..., + description=( + "Runtime environment. Must be exactly one of: Docker / Windows." + ), + ) + issue_type: str = Field( + ..., + description=( + "Issue category. Must be exactly one of: 主程序运行问题 / 插件问题 / 其他问题." + ), + ) + description: str = Field( + ..., + description=( + "Markdown-formatted bug description, including 现象 / 复现步骤 / " + "期望行为 / 已定位或推测 / 已尝试的处理 等结构化小节。" + ), + ) + logs: Optional[str] = Field( + default=None, + description=( + "Raw backend logs related to the bug. Leave empty if not captured; " + "do NOT fabricate." + ), + ) + + +class SubmitFeedbackIssueTool(MoviePilotTool): + """向上游 ``jxxghp/MoviePilot`` 仓库提交问题反馈 Issue。 + + require_admin=True:避免任意 TG/飞书用户通过 Bot 触发后给上游刷 Issue。 + Skill 层会在 dry-run 阶段做用户确认,本工具再做枚举校验与凭据降级。 + """ + + name: str = "submit_feedback_issue" + description: str = ( + "Submit a bug-report issue to the upstream MoviePilot backend repository " + f"({FEEDBACK_REPO}). Tries the GitHub REST API first when GITHUB_TOKEN is " + "configured with write permission; otherwise the tool itself pushes a " + "prefilled GitHub Issue Forms URL to the user via a separate notification " + "message (so the URL bytes are not corrupted by LLM verbatim copy). " + "Target repo is fixed; this tool does NOT accept arbitrary owner/repo " + "arguments. Admin only." + ) + args_schema: Type[BaseModel] = SubmitFeedbackIssueInput + require_admin: bool = True + # 工具会通过 send_tool_message 把 issue_url / prefill_url 作为独立通知推给用户, + # 因此声明 sends_message=True,让 factory 在受限渠道场景里仍可识别该副作用。 + sends_message: bool = True + + # 进程级去重缓存:{hash: timestamp}。Agent 在 SKILL.md 的指引下不应重复 + # 提交同一问题,但低能力模型仍可能误触;在工具层做 60 秒 hash 去重作为 + # 兜底,避免上游 issue 列表被重复条目污染。 + _recent_submissions: ClassVar[dict[str, float]] = {} + + def get_tool_message(self, **kwargs) -> Optional[str]: + """侧边消息:让用户知道 Agent 正在帮他向上游提交反馈。""" + title = kwargs.get("title") or "" + return f"提交问题反馈到 {FEEDBACK_REPO}:{title}".strip() + + # ------------------------------------------------------------------ + # 辅助方法 + # ------------------------------------------------------------------ + @staticmethod + def _validate_enum(value: str, allowed: tuple, field_name: str) -> Optional[str]: + """校验枚举字段,返回错误信息(None 表示通过)。 + + 枚举不合法时直接拒绝,避免发出后上游 bot/maintainer 还要手工处理。 + """ + if value not in allowed: + return ( + f"{field_name} 必须是以下之一:{', '.join(allowed)};" + f"当前传入:{value!r}" + ) + return None + + @staticmethod + def _redact_logs(raw: str) -> str: + """对 logs 字段做 defense-in-depth 二次脱敏。 + + SKILL.md 已经要求 agent 主动脱敏,这里只兜常见的高危模式(Cookie / + Authorization / api_key / password / token 等),避免 agent 漏脱敏 + 时凭据直接进入公网 issue。""" + out = raw + for pattern, replacement in _SENSITIVE_PATTERNS: + out = pattern.sub(replacement, out) + return out + + @staticmethod + def _truncate(text: str, limit: int, marker: str = "\n…(已截断)") -> str: + """长度截断辅助:超出 limit 时保留前 N 字符 + 截断说明。""" + if not text or len(text) <= limit: + return text + # 留出 marker 长度,避免最终输出再超 limit + return text[: max(0, limit - len(marker))] + marker + + @classmethod + def _sanitize_logs(cls, logs: Optional[str], limit: int) -> str: + """两条管道(API body / prefill URL)共用的日志清洗:先脱敏再截断。 + + 在两处都调用同一个入口,避免任何一条路径漏掉脱敏或长度兜底——这是 + 来自 review 的 high-priority 反馈:预填 URL 之前直接吃了原始 logs, + 会通过浏览器历史、消息渠道日志泄漏凭据。""" + if not logs or not logs.strip(): + return "" + return cls._truncate(cls._redact_logs(logs.strip()), limit) + + @classmethod + def _build_issue_body( + cls, + version: str, + environment: str, + issue_type: str, + description: str, + logs: Optional[str], + ) -> str: + """构造与 bug_report.yml 渲染结果保持一致的 Markdown 正文。 + + - 4 项 "确认" checkbox 默认勾选;通过 API 创建时模板表单不再展示, + 但保留勾选信息可让 maintainer 看到提交者已被告知规则。 + - 日志字段为空时显式标注,避免上游误以为是漏填。 + - 对 logs 做二次脱敏与长度截断,对整段 body 做最终长度兜底。 + """ + log_block = cls._sanitize_logs(logs, MAX_LOGS_CHARS) or "会话中未捕获到相关后端日志。" + body = ( + "### 确认\n\n" + "- [x] 我的版本是最新版本,我的版本号与 " + "[version](https://github.com/jxxghp/MoviePilot/releases/latest) 相同。\n" + "- [x] 我已经 [issue](https://github.com/jxxghp/MoviePilot/issues) " + "中搜索过,确认我的问题没有被提出过。\n" + "- [x] 我已经 [Telegram频道](https://t.me/moviepilot_channel) " + "中搜索过,确认我的问题没有被提出过。\n" + "- [x] 我已经修改标题,将标题中的 描述 替换为我遇到的问题。\n\n" + f"### 当前程序版本\n\n{version}\n\n" + f"### 运行环境\n\n{environment}\n\n" + f"### 问题类型\n\n{issue_type}\n\n" + f"### 问题描述\n\n{description.strip()}\n\n" + "### 发生问题时系统日志和配置文件\n\n" + f"```bash\n{log_block}\n```\n" + "\n---\n" + "_本 Issue 由 MoviePilot Agent 协助用户提交。_" + ) + return cls._truncate(body, MAX_BODY_CHARS) + + @classmethod + def _build_prefill_url( + cls, + title: str, + version: str, + environment: str, + issue_type: str, + description: str, + logs: Optional[str], + ) -> str: + """生成 GitHub Issue Forms 预填链接,作为 API 通道失败时的兜底。 + + 字段名与 bug_report.yml 的 ``id`` 一一对应;统一使用 ``quote`` 做严格 + URL-encode(空格 → %20、换行 → %0A),避免 ``+`` 被解释成空格。 + + Logs 字段在 URL 路径下走更严格的清洗:先做与 body 同源的脱敏,再截断到 + ``MAX_URL_LOGS_CHARS``(3KB)以防 URL 超长(浏览器 / Chat 平台对 GET + URL 通常限制在 4-8KB)。这是来自 review 的 high-priority 反馈。 + """ + params = { + "template": FEEDBACK_ISSUE_TEMPLATE, + "title": title, + "version": version, + "environment": environment, + "type": issue_type, + "what-happened": description, + "logs": cls._sanitize_logs(logs, MAX_URL_LOGS_CHARS), + } + encoded = "&".join( + f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params.items() + ) + return f"{FEEDBACK_ISSUE_NEW_URL}?{encoded}" + + @staticmethod + def _classify_failure( + status_code: Optional[int], + headers: Optional[dict] = None, + ) -> str: + """把 GitHub API 错误码映射到对 Agent 友好的失败原因。 + + 403 同时被 GitHub 用于「无权限」和「被限流」两种语义;当 + ``X-RateLimit-Remaining`` 为 0 时优先判定为 ``rate_limited``, + 避免提示用户重新配 token 实际只是限流。""" + headers = headers or {} + if status_code == 401: + return "no_permission" + if status_code == 403: + remaining = headers.get("X-RateLimit-Remaining") or headers.get( + "x-ratelimit-remaining" + ) + if remaining == "0": + return "rate_limited" + return "no_permission" + if status_code == 404: + # 404 一般是 token 完全无效或仓库被锁;对终端用户没必要细分 + return "no_permission" + if status_code == 422: + return "invalid_payload" + if status_code is not None and status_code >= 500: + return "github_unavailable" + return "api_error" + + @classmethod + def _check_recent_duplicate(cls, title: str, body: str) -> Optional[str]: + """检查 60 秒内是否提交过同 title+body 的 issue。 + + 返回命中的 hash 字符串(仅作日志用途);None 表示未命中。命中后 + run() 直接拒绝二次提交,避免上游 issue 列表被重复条目污染。""" + now = time.time() + # 同步清理过期条目,避免缓存无限增长 + expired = [ + h for h, ts in cls._recent_submissions.items() + if now - ts > DEDUP_TTL_SECONDS + ] + for h in expired: + cls._recent_submissions.pop(h, None) + key = hashlib.sha256( + f"{title}\x00{body}".encode("utf-8", errors="replace") + ).hexdigest() + if key in cls._recent_submissions: + return key + return None + + @classmethod + def _record_submission(cls, title: str, body: str) -> None: + """记录一次提交的指纹,配合 ``_check_recent_duplicate`` 实现去重。""" + key = hashlib.sha256( + f"{title}\x00{body}".encode("utf-8", errors="replace") + ).hexdigest() + cls._recent_submissions[key] = time.time() + + @staticmethod + def _safe_response_dict(response) -> dict: + """安全解析 HTTP 响应体为 dict。 + + GitHub 个别接口(如 422 批量校验)可能返回 array 而非 dict,对结果 + 直接 ``.get`` 会触发 AttributeError;这里统一返回 dict,调用方拿到的 + 是空 dict 也能继续走分支判断。""" + try: + data = response.json() + except Exception: # noqa: BLE001 — 响应体非合法 JSON,回退到空 dict + return {} + if isinstance(data, dict): + return data + return {} + + @staticmethod + def _result_payload(**fields) -> str: + """统一以 JSON 字符串返回,便于 Agent 通过 SKILL.md 中描述的字段分支。 + + 注意:``issue_url`` / ``prefill_url`` 等长 URL 默认**不会**写入这个返回值, + 而是通过 ``send_tool_message`` 单独推送到用户频道,避免 LLM 逐字转述时 + 因量化或 tokenizer 抖动引入字节级别的 URL 损坏(曾观察到 ``%89`` 被翻转 + 成 ``%79`` 导致 GitHub 400)。Agent 只需把工具返回的 ``message`` 字段 + 作为对话内的简短确认转述给用户即可。 + """ + return json.dumps(fields, ensure_ascii=False, indent=2) + + async def _push_url_to_user(self, url: str, title: str, hint: str) -> bool: + """把 issue_url / prefill_url 作为独立通知推给当前会话用户。 + + Why: TG/飞书等渠道下 LLM 转述 1KB+ 长 URL 极易出现字节翻转(低精度量化 + 模型尤其常见),导致 GitHub 拒绝预填链接。直接走 ToolChain 推送可以 + 让 URL 经由消息系统原文落地,跳过 LLM 转述链路。 + """ + try: + text = f"{hint}\n\n{url}" if hint else url + await self.send_tool_message(text, title=title) + return True + except Exception as e: # noqa: BLE001 — 推送失败不应该让整个工具崩溃 + logger.warning( + f"通过 send_tool_message 推送反馈链接失败,回退到把 URL 写入 " + f"工具返回值: {e}" + ) + return False + + # ------------------------------------------------------------------ + # 主流程 + # ------------------------------------------------------------------ + async def run( + self, + title: str, + version: str, + environment: str, + issue_type: str, + description: str, + logs: Optional[str] = None, + **kwargs, + ) -> str: + logger.info( + f"执行工具: {self.name}, 标题: {title!r}, 版本: {version!r}, " + f"环境: {environment!r}, 类型: {issue_type!r}" + ) + + # 1) 入参枚举校验:失败直接拒绝,不消耗 GitHub 调用次数 + for value, allowed, field_name in ( + (environment, ALLOWED_ENVIRONMENTS, "environment"), + (issue_type, ALLOWED_ISSUE_TYPES, "issue_type"), + ): + err = self._validate_enum(value, allowed, field_name) + if err: + return self._result_payload( + success=False, + reason="invalid_input", + message=err, + ) + + # 2) 兜底硬约束:title 长度截断,避免超出 GitHub 256 字符限制 + title = self._truncate(title, MAX_TITLE_CHARS, marker="…") + + # 3) 同会话内 60 秒去重,防止 agent 多次触发提交同一问题 + body_preview = self._build_issue_body( + version=version, + environment=environment, + issue_type=issue_type, + description=description, + logs=logs, + ) + if self._check_recent_duplicate(title, body_preview): + logger.info( + f"拒绝重复提交:{title!r} 在 {DEDUP_TTL_SECONDS}s 内已提交过" + ) + return self._result_payload( + success=False, + reason="duplicate", + message=( + f"该问题反馈在 {DEDUP_TTL_SECONDS} 秒内已经提交过一次," + "已避免重复提交。如确需重提,请稍后再次触发,或在原" + "Issue 页面追加评论。" + ), + ) + + # 4) 始终先生成兜底 URL,无论后面走哪条路径都能用上 + prefill_url = self._build_prefill_url( + title=title, + version=version, + environment=environment, + issue_type=issue_type, + description=description, + logs=logs, + ) + + # 5) 没有 token 时直接降级到 URL 兜底 + if not settings.GITHUB_TOKEN: + logger.warning( + "未配置 GITHUB_TOKEN,feedback issue 降级到预填 URL 通道" + ) + pushed = await self._push_url_to_user( + url=prefill_url, + title="问题反馈 - 请点击下方链接确认提交", + hint=( + "MoviePilot 未配置 GitHub 写入凭据,无法自动提交。" + "请在浏览器 / GitHub App 中打开下方链接,勾选 4 项 ✅ 后提交即可。" + ), + ) + return self._result_payload( + success=False, + reason="no_token", + url_delivered=pushed, + # 仅当 send_tool_message 失败时才把 URL 退回给 LLM 兜底 + prefill_url=None if pushed else prefill_url, + message=( + "MoviePilot 未配置可写入的 GitHub Token,无法自动提交 Issue;" + "已通过独立消息把预填链接发给用户,请在对话中简短告知" + "用户点击该链接完成提交,并提醒管理员后续可在系统设置中" + "配置一个具备 `public_repo` 权限的 GitHub Token,让以后" + "可以由 Agent 直接提交。" + if pushed + else + "MoviePilot 未配置可写入的 GitHub Token,无法自动提交 Issue。" + "独立消息推送失败,请把 prefill_url 原样转给用户。" + ), + ) + + # 6) 调 GitHub REST API。POST /issues 必须带 Bearer Token; + # GITHUB_HEADERS 已经填好 Authorization & UA,再补 Content-Type + # 与 Accept 以满足 GitHub 推荐头规范。复用 body_preview,避免 + # 重新构造一次(_build_issue_body 已经做了脱敏与长度兜底)。 + body = body_preview + request_headers = { + **settings.GITHUB_HEADERS, + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + } + payload = { + "title": title, + "body": body, + "labels": ["bug"], + } + + # 在真正发起 API 调用前先 record,确保后续任何结果(成功 / 失败 / + # 网络异常)都会被纳入 60 秒去重窗口,避免 agent 因 LLM loop 在短 + # 时间内反复触发提交。 + self._record_submission(title, body) + + try: + response = await AsyncRequestUtils( + proxies=settings.PROXY, + headers=request_headers, + timeout=FEEDBACK_REQUEST_TIMEOUT, + ).post_res(FEEDBACK_ISSUE_API, json=payload) + except Exception as e: # noqa: BLE001 — AsyncRequestUtils 已统一拦截,这里兜底未知异常 + logger.error(f"提交反馈 Issue 时发生异常: {e}", exc_info=True) + pushed = await self._push_url_to_user( + url=prefill_url, + title="问题反馈 - 网络异常,请点击链接手动提交", + hint=( + "调用 GitHub API 时出现网络异常,暂时无法自动提交。" + "请点击下方链接在浏览器中完成提交,或稍后让 Agent 重试。" + ), + ) + return self._result_payload( + success=False, + reason="network_error", + url_delivered=pushed, + prefill_url=None if pushed else prefill_url, + message=( + "调用 GitHub API 时网络异常;已通过独立消息把预填链接发给" + "用户,请在对话中告知用户稍后重试或点击链接手动提交。" + if pushed + else + "调用 GitHub API 时网络异常,且独立消息推送失败;" + "请把 prefill_url 原样转给用户。" + ), + error=str(e), + ) + + if response is None: + # AsyncRequestUtils 在 RequestError 时返回 None;此时无 status_code 可读 + pushed = await self._push_url_to_user( + url=prefill_url, + title="问题反馈 - 网络无响应,请点击链接手动提交", + hint=( + "调用 GitHub API 未收到响应。请点击下方链接在浏览器中" + "完成提交,或稍后让 Agent 重试。" + ), + ) + return self._result_payload( + success=False, + reason="network_error", + url_delivered=pushed, + prefill_url=None if pushed else prefill_url, + message=( + "调用 GitHub API 未返回响应;已通过独立消息把预填链接发给" + "用户,请在对话中告知用户稍后重试或点击链接手动提交。" + if pushed + else + "调用 GitHub API 未返回响应,且独立消息推送失败;" + "请把 prefill_url 原样转给用户。" + ), + ) + + if response.status_code == 201: + data = self._safe_response_dict(response) + html_url = data.get("html_url") + number = data.get("number") + logger.info(f"反馈 Issue 创建成功:#{number} {html_url}") + pushed = False + if html_url: + pushed = await self._push_url_to_user( + url=html_url, + title=f"问题反馈已提交 - {FEEDBACK_REPO} #{number}", + hint=( + "你的问题已提交到 MoviePilot 上游仓库," + "后续 maintainer 的回复会显示在下方 Issue 页面里。" + ), + ) + return self._result_payload( + success=True, + issue_number=number, + repo=FEEDBACK_REPO, + url_delivered=pushed, + # send 失败才把 URL 退给 LLM 转述兜底 + issue_url=None if pushed else html_url, + message=( + f"Issue 已成功提交到 {FEEDBACK_REPO}#{number},并通过独立" + "消息把链接推给用户,请在对话中简短告知用户提交成功并" + "请其等待 maintainer 回复。" + if pushed + else + f"Issue 已成功提交到 {FEEDBACK_REPO}#{number}。" + "独立消息推送失败,请把 issue_url 原样转给用户。" + ), + ) + + reason = self._classify_failure( + response.status_code, headers=dict(response.headers or {}) + ) + # 取 GitHub 返回的错误描述,便于排查;不暴露完整响应体避免泄漏 token 元信息 + api_data = self._safe_response_dict(response) + api_message = api_data.get("message") if api_data else None + if not api_message and getattr(response, "text", None): + api_message = response.text[:200] + + logger.warning( + f"提交反馈 Issue 失败:HTTP {response.status_code} reason={reason} " + f"msg={api_message!r}" + ) + if reason == "no_permission": + hint = ( + "MoviePilot 配置的 GitHub Token 缺少写入 Issue 的权限" + "(需要 `public_repo` 或 `repo` scope),暂时无法自动提交。" + "请点击下方链接在浏览器或 GitHub App 中完成提交。" + ) + llm_summary = ( + "GitHub Token 缺少写入 Issue 的权限;已通过独立消息把预填" + "链接发给用户,请在对话中简短告知用户点击链接完成提交," + "并提醒管理员重新生成带 `public_repo` / `repo` scope 的" + "Token 后续就可以由 Agent 直接提交。" + ) + elif reason == "rate_limited": + hint = ( + "GitHub API 已达到当前 Token 的请求限流上限,暂时无法自动" + "提交。请稍后重试,或点击下方链接在浏览器中手动提交。" + ) + llm_summary = ( + "GitHub API 限流(403 + X-RateLimit-Remaining=0);已通过" + "独立消息把预填链接发给用户,请在对话中告知用户稍后再让" + "Agent 重试,或直接点击链接手动提交。" + ) + elif reason == "invalid_payload": + hint = ( + "GitHub 拒绝了本次 Issue 内容(可能包含被限制的字符或字段" + "格式不正确)。请点击下方链接在浏览器中确认并提交。" + ) + llm_summary = ( + "GitHub 返回 HTTP 422 拒绝了 Issue 内容;已通过独立消息把" + "预填链接发给用户,请在对话中简短告知用户点击链接确认提交。" + ) + elif reason == "github_unavailable": + hint = ( + "GitHub 服务暂时不可用。请稍后重试,或点击下方链接在浏览器" + "中手动提交。" + ) + llm_summary = ( + "GitHub 服务暂时不可用;已通过独立消息把预填链接发给用户," + "请在对话中告知用户稍后重试或点击链接手动提交。" + ) + else: + hint = ( + "GitHub API 返回非预期错误,暂时无法自动提交。请点击下方" + "链接在浏览器中手动提交。" + ) + llm_summary = ( + "GitHub API 返回非预期错误;已通过独立消息把预填链接发给" + "用户,请在对话中告知用户点击链接手动提交。" + ) + + pushed = await self._push_url_to_user( + url=prefill_url, + title="问题反馈 - 请点击下方链接确认提交", + hint=hint, + ) + return self._result_payload( + success=False, + reason=reason, + url_delivered=pushed, + prefill_url=None if pushed else prefill_url, + message=( + llm_summary + if pushed + else + "独立消息推送失败,请把 prefill_url 原样转给用户。" + ), + github_message=api_message, + ) diff --git a/skills/feedback-issue/SKILL.md b/skills/feedback-issue/SKILL.md new file mode 100644 index 00000000..f595ad88 --- /dev/null +++ b/skills/feedback-issue/SKILL.md @@ -0,0 +1,444 @@ +--- +name: feedback-issue +version: 1 +description: >- + Use this skill when the user wants to file a bug report against the + MoviePilot upstream backend repository `jxxghp/MoviePilot`. Triggers + include Chinese phrases such as "反馈 issue"、"提 issue"、"报 bug"、 + "给 MP 提 issue"、"让上游修一下"、"我要反馈问题"、"提交错误报告", + as well as English phrasings such as "file an issue" / "report a bug" / + "open an upstream issue". The skill collects bug context from the + conversation, drafts an issue payload that matches the upstream + `bug_report.yml` form, asks the user to confirm, then calls the + `submit_feedback_issue` tool which either creates the issue directly + via GitHub REST API (when `GITHUB_TOKEN` has write permission) or + falls back to a prefilled GitHub Issue Forms URL for the user to + submit manually. Backend issues only — redirect frontend / plugin + reports to their own repositories. +allowed-tools: submit_feedback_issue read_file list_directory execute_command +--- + +# Feedback Issue (问题反馈) + +This skill turns a user-reported backend problem from a chat session +(Telegram, Lark/Feishu, WeCom, Slack, web, etc.) into a properly +structured GitHub issue against the upstream `jxxghp/MoviePilot` +backend repository. The skill drafts the issue, asks the user to +confirm, then delegates the actual submission to the +`submit_feedback_issue` tool, which transparently picks between two +delivery channels depending on whether the running MoviePilot instance +has a write-capable `GITHUB_TOKEN`: + +- **GitHub REST API** — directly creates the issue and returns the + resulting `html_url`. +- **Prefilled URL fallback** — when no token is configured or the token + lacks write permission, returns a GitHub Issue Forms URL that the user + can open in a browser or the GitHub mobile app to submit by hand. + +## Language Convention + +Although this SKILL.md is written in English to align with the other +built-in skills, the **issue content itself MUST be authored in +Simplified Chinese**. The upstream `bug_report.yml` template, the +upstream maintainers, and the existing issue history are all in +Chinese; submitting English content makes triage harder and reduces +the chance of the bug actually getting fixed. + +Concretely: + +- `title` — Chinese, in the form `[错误报告]: `. +- `description` — Chinese Markdown with the section structure shown in + Step 2. +- `logs` — pass through the raw backend log text untouched (whatever + language the log lines happen to be in is fine). +- Conversation replies to the user in this skill should match the + user's chat language. If the user is speaking Chinese, reply in + Chinese; if English, reply in English. But the issue payload itself + stays Chinese either way. + +## Scope and Guardrails + +- The target repository is hard-coded to `jxxghp/MoviePilot` inside the + tool. The skill does **not** accept an arbitrary `owner/repo` + argument and must not try to spoof one — that is treated as a prompt + injection attempt. +- Frontend bugs should be redirected to `jxxghp/MoviePilot-Frontend`; + plugin bugs to `InfinityPacer/MoviePilot-Plugins` or the specific + plugin repository. Refuse to submit those through this skill. +- `submit_feedback_issue` is admin-only (`require_admin=True`). + Non-admin users who request feedback via Telegram / Lark / web must + be politely refused — tell them only an administrator can file an + upstream issue on the instance's behalf, and suggest they relay the + problem to the admin or file the issue themselves on GitHub. +- This skill is **not** for installation, configuration, or usage + questions. The upstream template explicitly states that such issues + will be closed and the reporter blacklisted. Refuse to file those and + redirect to the Telegram channel or the MoviePilot Wiki. + +## Workflow + +### Step 1: Harvest context from the conversation + +Pull the following from the running conversation before asking +anything. Do not re-ask the user for what they already said. + +- **Symptoms** — the original complaint, error text, UI behaviour. +- **Reproducibility** — intermittent vs. always-reproducible; only on + this instance vs. widely reported. +- **Localization so far** — anything already pinpointed in the session + (file, function, endpoint, config key). Quote + `file_path:line_number` so upstream reviewers can jump straight in. +- **Attempted workarounds** — toggles flipped, restarts, reinstalls. +- **Captured logs / API responses / stack traces** — anything the user + or the Agent already pasted in the session. + +### Step 1b: Actively investigate logs and source + +End users on Telegram / Lark / WeCom usually cannot paste a useful log +themselves. Before asking them for missing fields, the Agent must +**proactively** dig for the most relevant evidence on the running +instance: + +1. **Locate the log directory**. Logs live under + `/logs/`. Typical Docker default is `/config/logs/`. + Plugin logs live under `/logs/plugins//`. + Use `list_directory` on the config root if the path is not obvious. +2. **Pull a focused slice of `moviepilot.log`**, not the whole file. + Drive the slice from the symptom — pick relevant keywords (plugin + ID, English function name, exception type, "ERROR", the user's + timestamp window if they gave one). Concrete grep recipes (run via + `execute_command`): + + ```bash + # Last error window, generic case + tail -n 2000 /logs/moviepilot.log | \ + grep -nE -B 5 -A 30 'ERROR|Traceback|Exception|' + + # Plugin-specific, both main log and plugin log + tail -n 1500 /logs/plugins//.log + ``` + +3. **Cap the captured log at ~3 KB** after redaction (Step 1c). If the + matched window is bigger, keep the single most relevant traceback / + ERROR block rather than truncating mid-line. +4. **Optionally grep source for localization**. When the log points at + a specific function name, module, or API path, the Agent **may** + grep `app/` to find the likely `file_path:line_number`: + + ```bash + grep -rn '' app/ --include='*.py' | head -20 + ``` + + Conclusions drawn from source-only inspection are **speculative** + and must go into the `仅为推测` bucket of `已定位 / 推测`. Do not + promote them to `已经验证` unless an actual run / test confirmed it + in this session. +5. **Skip this step entirely** when the user already pasted a usable + log block, or when the problem is obviously a UI / configuration + complaint with no error-shaped symptom — extra grepping just bloats + the issue. + +### Step 1c: Redact sensitive data in the captured log + +Auto-redact the log block before showing it in the dry-run or sending +it to the tool. Run a deterministic regex pass over the captured text. +Minimum patterns to redact (case-insensitive): + +| Pattern | Replacement | +| --- | --- | +| `Cookie:\s*[^\n]+` | `Cookie: ` | +| `Set-Cookie:\s*[^\n]+` | `Set-Cookie: ` | +| `Authorization:\s*(Bearer|Basic|Token)\s+\S+` | `Authorization: $1 ` | +| `(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|passkey|pwd|password|secret|token)\s*[:=]\s*['"]?[^\s'"&]+` | `$1=` | +| `passkey=[0-9a-f]{8,}` (URL query) | `passkey=` | +| `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}` (email) | `` | +| Public IPv4 (skip private 10/172.16/192.168/127) | `` | +| `/Users/[^/\s]+/` or `/home/[^/\s]+/` | `/Users//` / `/home//` | +| WeChat / Telegram / Lark webhook URLs containing tokens | host kept, token segment → `` | + +Additional rules: + +- If after redaction the log block is empty or trivially small (e.g. + just headers), omit `logs` entirely rather than submitting noise. +- If the captured log still contains a string that **looks** like a + long random base64 / hex value (≥ 24 chars of `[A-Za-z0-9+/=]` after + a `:`/`=`/`Bearer `), treat it as a possible secret and redact it + even if it didn't match any pattern above. +- The redaction is **mandatory** and is part of the dry-run preview — + the user sees the post-redaction logs and decides whether anything + still looks sensitive before confirming. + +### Step 1d: Ask the user for the remaining required fields + +Only after Step 1 / 1b / 1c, ask the user — in a single batched +question — for the fields you still cannot infer: + +| Field | Allowed values | Notes | +| --- | --- | --- | +| `version` | e.g. `v2.12.2` | Required. If the user does not know, point them at the "About" page in the WebUI. | +| `environment` | `Docker` / `Windows` | Required. Exactly one of the two strings. | +| `issue_type` | `主程序运行问题` / `插件问题` / `其他问题` | Required. Must match the upstream `bug_report.yml` dropdown values exactly. | + +If the problem is plugin-specific but the user explicitly wants it +filed against the backend, allow it, but make sure +`description` clearly states the plugin ID and plugin version so +maintainers can re-route the issue. + +### Step 2: Draft the issue (in Chinese) + +Compose the four payload fields below. Use Simplified Chinese for +`title` and `description`. Keep the section headings exactly as shown +so the rendered issue mirrors how `bug_report.yml` would normally +present a submission. + +- **`title`** — `[错误报告]: `. Always replace the template placeholder `请在此处简单描 + 述你的问题`; leaving the placeholder triggers auto-close upstream. +- **`description`** — Chinese Markdown using this skeleton (add or omit + sections as needed, but keep the verified-vs-speculation split): + + ```markdown + ## 现象 + - 用户观察到的具体行为、报错文字、UI 表现。 + + ## 复现步骤 + 1. 第一步…… + 2. 第二步…… + 3. 出现错误。 + + ## 期望行为 + - 正确情况下应该是什么样。 + + ## 已定位 / 推测 + - 已经验证:xxx(附 `file_path:line_number`)。 + - 仅为推测:xxx。 + + ## 已尝试的处理 + - workaround / 关闭/启用某选项 / 重启 / 重装 …… + ``` + +- **`logs`** — the redacted log block from Step 1b / 1c, capped at + ~3 KB. Only real log lines — never fabricate. If neither the + conversation nor the active log dig produced anything useful, omit + this field; the tool will fill in + "会话中未捕获到相关后端日志". + +- **Speculative localization** drawn from source grep in Step 1b goes + into the `仅为推测` bullet of `已定位 / 推测`, with the + `file_path:line_number` reference. Findings actually verified during + the session (logs that pinpoint the line, behaviour reproduced after + a hypothesis) may go under `已经验证`. + +Writing requirements: + +- Do not surface meta-information about Claude Code, the Agent runtime, + or "the current session" in `title` / `description`. The maintainer + should read the issue as if a regular user filed it. The tool already + appends a single discreet footer line crediting the Agent. +- Distinguish "verified" from "speculative" findings. Do not let a + guess from the chat become a stated cause. +- Do not invent GitHub usernames, emails, or version numbers. + +### Step 3: Mandatory dry-run preview + +Before calling the tool, print the six payload fields (`title`, +`version`, `environment`, `issue_type`, `description`, `logs`) back to +the user in full and ask, in the user's chat language: + +> Is this draft OK? Reply "confirm" / "确认" to submit, or "edit: ..." / +> "修改:..." to adjust. + +The dry-run **must include the post-redaction `logs` block verbatim** +so the user can spot any sensitive data the regex pass missed and +either tell the Agent to drop / re-edit it, or override the +redaction manually. If the user requests further redaction, apply it +and re-show the dry-run. + +Do **not** call `submit_feedback_issue` until the user explicitly +confirms. + +### Step 4: Call `submit_feedback_issue` + +> **MANDATORY: every tool call in this repository requires an +> `explanation` argument.** It is a hard pydantic-required field on +> every MoviePilot agent tool (see `query_subscribes`, `add_download`, +> `search_media`, etc.) — used for activity-log auditing and the +> tool-bubble shown in Telegram / Lark. Omitting it makes the framework +> reject the call **before** the tool runs, so the no-token / +> no-permission fallback inside `submit_feedback_issue` never fires. +> **Always pass a concrete `explanation` string**, e.g. +> `"User authorized submitting a TMDB-identification bug to jxxghp/MoviePilot"`. + +Once the user confirms, invoke the tool with the drafted fields: + +``` +submit_feedback_issue( + explanation="User authorized submitting a bug report to jxxghp/MoviePilot", + title=..., + version=..., + environment=..., + issue_type=..., + description=..., + logs=..., # omit if no real logs +) +``` + +The tool returns a JSON string. **Important architectural note:** to +avoid LLM verbatim-copy corruption of long URLs (e.g. a single +quantized byte flip mutating `%89` → `%79` and breaking the GitHub +prefill), the tool **delivers `issue_url` / `prefill_url` to the user +directly via a separate notification message** (`send_tool_message`), +not by returning the URL string for the LLM to re-emit. The JSON +returned to the LLM carries only `url_delivered: true|false` and a +short Chinese `message` field that summarizes what to say. + +Parse the JSON and branch on `success` + `reason`: + +| Result shape | Meaning | How to respond to the user | +| --- | --- | --- | +| `success=true`, `url_delivered=true` | API channel succeeded and the issue URL has already been pushed to the user channel as a separate notification. | Acknowledge briefly: "Issue 已提交到上游,等待 maintainer 跟进。" **Do NOT repeat or paraphrase the URL** — the user already received it as a clickable link. | +| `success=false`, `reason=no_token`, `url_delivered=true` | Instance has no `GITHUB_TOKEN`; prefill URL has been pushed to the user. | Acknowledge briefly: "我没有自动提交权限,已把预填链接单独发给你,点击即可提交。" Optionally remind the admin once to configure a token with `public_repo` scope for next time. **Do NOT repeat the URL.** | +| `success=false`, `reason=no_permission`, `url_delivered=true` | Token lacks write scope; prefill URL pushed. | Acknowledge briefly and remind the admin to regenerate the token with `public_repo` / `repo` scope. **Do NOT repeat the URL.** | +| `success=false`, `reason=rate_limited`, `url_delivered=true` | GitHub returned 403 with `X-RateLimit-Remaining: 0`. Prefill URL pushed. | Ask the user to retry later or click the link that was pushed separately. **Do NOT** tell them to reconfigure the token — this is rate limit, not permission. **Do NOT repeat the URL.** | +| `success=false`, `reason=invalid_payload`, `url_delivered=true` | GitHub returned 422; prefill URL pushed. | Ask the user to revise the title or body (likely forbidden characters), and note that the prefill link was already pushed for manual submission. **Do NOT repeat the URL.** | +| `success=false`, `reason=github_unavailable` / `network_error`, `url_delivered=true` | Transient GitHub failure; prefill URL pushed. | Ask the user to retry later or click the link that was pushed separately. **Do NOT repeat the URL.** | +| `success=false`, `reason=duplicate` | The same feedback was already submitted in the last 60 seconds. Nothing was sent to GitHub or to the user this time. | Acknowledge briefly that the issue was already filed in the previous attempt; ask the user to add a comment to the existing Issue if they have more details. **Do NOT call the tool again for the same payload.** | +| Any of the above with `url_delivered=false` | Notification push failed; the tool returned the URL in `issue_url` / `prefill_url` as a last-resort fallback. | Paste the URL verbatim into the chat reply (single line, no line breaks). This is the **only** scenario in which the LLM should emit the URL. | +| `success=false`, `reason=invalid_input` | Tool rejected the payload before calling GitHub (e.g. `environment` / `issue_type` not in the allowed enum). | Agent-side mistake — silently fix the payload and retry. Do not surface this error to the user. | + +Rule of thumb: if `url_delivered=true`, **never put the URL in your +conversation reply**. The link is already in the user's channel. Your +job is to confirm in one or two short Chinese sentences. + +#### Error handling — do NOT improvise + +If the tool call fails for any reason, the only allowed paths are: + +1. **Schema validation error / `reason=invalid_input` / missing + required field (e.g. `explanation`, `environment`, `issue_type`)** + — this is an Agent-side mistake. **Silently fix the payload and + call `submit_feedback_issue` again**, up to 2 retries. Never expose + "tool validation failed" / "system limitation" / "explanation field + missing" to the user. Never substitute a dialog-only "please copy + the following text to GitHub" message as a workaround — the user + is on a mobile chat client and that fallback is unusable. +2. **Tool returned a structured failure with `prefill_url`** (any of + `no_token` / `no_permission` / `invalid_payload` / + `github_unavailable` / `network_error`) — relay the `prefill_url` + per the table above. This is the **only** sanctioned manual-submit + fallback; the URL is engineered to open the upstream form with all + fields prefilled. +3. **Tool returned a real exception (network / unknown)** — log the + error, apologize briefly in one sentence, and offer to retry once + the user reports the same issue again. Do not invent a fallback + that asks the user to copy-paste raw issue text into GitHub. + +In short: **never fall back to "here is the issue text, please submit +it yourself"**. Either retry the tool, or relay the tool's own +`prefill_url`. There is no third path. + +### Step 5: After submission + +- If the tool returned an `issue_url`, tell the user that follow-up + details should go to a comment on that issue in the GitHub web UI — + do not call `submit_feedback_issue` again for the same problem. +- If the user provides more information later in the same session and + the issue is already filed, instruct them to add a GitHub comment + rather than spawning a duplicate issue. + +## Refuse / Redirect Scenarios + +- User asks to file against `jxxghp/MoviePilot-Frontend`, + `InfinityPacer/MoviePilot-Plugins`, or any other repository — refuse, + explain that this skill only serves the backend upstream, and hand + back the correct repository's issues URL for self-submission. +- Non-admin user invokes the skill — refuse to call the tool, explain + that only an administrator can submit on the instance's behalf, and + suggest relaying the problem to the admin or filing on GitHub + directly. +- User asks to "just submit, skip the preview" — refuse; the dry-run is + mandatory. +- The session lacks enough detail to describe a comprehensible bug + (no symptom, no repro, no logs) — refuse, ask the user to reproduce + or capture logs first. +- The user is actually asking a configuration / installation / usage + question — refuse and redirect to the Telegram channel or Wiki. + +## Examples + +### Example 1: backend bug already localized + +> User: "让 MP 的 Agent 给上游报一下这个问题吧。" + +Flow: + +1. Pull symptom, root-cause (`file_path:line_number`) and logs from + prior turns in the session. +2. Ask in one batch for the missing fields (`version`, `environment`, + `issue_type`). +3. Print the dry-run draft. +4. On confirmation, call `submit_feedback_issue` and respond per the + result table in Step 4. + +### Example 2: user provides everything at once + +> User: "2.12.2 Docker 主程序问题:订阅刷新时报错 xxx,日志是 yyy, +> 帮我提一个 issue。" + +Flow: + +1. Skip straight to Step 2; all six fields are derivable. +2. Print the dry-run and ask if anything else needs adding. +3. On confirmation, call the tool and reply with the outcome. + +### Example 3: plugin bug — redirect + +> User: "ChineseSubFinder 插件不工作,帮我给上游提 issue。" + +Flow: + +1. Recognize this as a plugin issue. +2. Refuse to file it through this skill; respond (in Chinese, matching + the user's language) with the plugin's repository issues URL and a + short note that plugin bugs should go to the plugin maintainer. + +### Example 4: instance has no GITHUB_TOKEN + +Tool returns: + +``` +{"success": false, "reason": "no_token", "prefill_url": "..."} +``` + +Reply (Chinese, since user wrote in Chinese): + +> 当前 MoviePilot 没有 GitHub Token 的写入权限,我没法直接帮你提交。 +> 请点击下面的链接,在浏览器或 GitHub App 中勾选 4 项 ✅ 后提交即可: +> +> +> +> 如果希望以后让 Agent 直接提交,请管理员到系统设置配置一个具备 +> `public_repo` 权限的 GitHub Token。 + +## Final Checklist + +Before calling `submit_feedback_issue`: + +- [ ] **`explanation` argument is present and non-empty** (workspace + convention; missing it causes pydantic to reject the call before + the tool runs). +- [ ] `title` no longer contains the placeholder + `请在此处简单描述你的问题`. +- [ ] `title` and `description` are written in Simplified Chinese. +- [ ] `version`, `environment`, `issue_type` are filled in and use + values from the allowed enumerations (else the tool will return + `reason=invalid_input`). +- [ ] `description` follows the section skeleton and separates + verified findings from speculation. Source-grep findings live in + `仅为推测`, not `已经验证`. +- [ ] `logs` is either real log text (post-redaction, ≤ ~3 KB) or + omitted. The full redaction pass from Step 1c has been applied. +- [ ] The user has explicitly confirmed the post-redaction draft in + Step 3. +- [ ] The caller is an admin (non-admin sessions should be refused + earlier). diff --git a/tests/test_agent_submit_feedback_issue_tool.py b/tests/test_agent_submit_feedback_issue_tool.py new file mode 100644 index 00000000..ae3c20bb --- /dev/null +++ b/tests/test_agent_submit_feedback_issue_tool.py @@ -0,0 +1,477 @@ +"""``submit_feedback_issue`` Agent 工具的单元测试。 + +覆盖范围(按 review 反馈"必修问题 2"补齐): +- 工厂注册:新工具能被正常加载到默认工具集中 +- 静态辅助:URL 构造、Issue body 渲染、日志脱敏、失败分类、长度截断 +- ``run()`` 主流程:枚举校验、no_token 降级、API 成功、API 失败 + + rate_limited 分支、网络异常分支、去重逻辑 +- send_tool_message 全部走 mock,保证测试无外部 IO +""" + +from __future__ import annotations + +import asyncio +import json +import unittest +from unittest.mock import patch +from urllib.parse import quote + +from app.agent.tools.factory import MoviePilotToolFactory +from app.agent.tools.impl.submit_feedback_issue import ( + FEEDBACK_REPO, + MAX_LOGS_CHARS, + MAX_TITLE_CHARS, + MAX_URL_LOGS_CHARS, + SubmitFeedbackIssueTool, +) +from app.core.config import settings + + +class _FakeResponse: + """``httpx.Response`` 的最小替身,覆盖工具用到的 4 个属性/方法。""" + + def __init__(self, status_code, payload=None, headers=None, text=""): + self.status_code = status_code + self._payload = payload + self.headers = headers or {} + self.text = text + + def json(self): + if self._payload is None: + raise ValueError("no json body") + return self._payload + + +def _run(coro): + """跑一个 coroutine,避免每个用例重复写 asyncio.run。""" + return asyncio.run(coro) + + +class TestSubmitFeedbackIssueStaticHelpers(unittest.TestCase): + """所有静态/类方法的纯函数测试,无副作用、无 IO。""" + + def test_validate_enum_accepts_allowed_values(self): + self.assertIsNone( + SubmitFeedbackIssueTool._validate_enum("Docker", ("Docker", "Windows"), "env") + ) + + def test_validate_enum_rejects_disallowed_values(self): + msg = SubmitFeedbackIssueTool._validate_enum( + "linux", ("Docker", "Windows"), "env" + ) + self.assertIsNotNone(msg) + self.assertIn("Docker", msg) + self.assertIn("Windows", msg) + self.assertIn("'linux'", msg) + + def test_truncate_keeps_short_text(self): + self.assertEqual(SubmitFeedbackIssueTool._truncate("hello", 100), "hello") + + def test_truncate_clips_long_text_with_marker(self): + out = SubmitFeedbackIssueTool._truncate("a" * 1000, 100) + self.assertLessEqual(len(out), 100) + self.assertIn("已截断", out) + + def test_redact_logs_strips_common_secrets(self): + sample = ( + "Cookie: session=foo; passkey=secret123\n" + "Authorization: Bearer ghp_abcdefghijklmn\n" + "api_key=mysecret\n" + "password: hunter2\n" + "Set-Cookie: session=foo" + ) + out = SubmitFeedbackIssueTool._redact_logs(sample) + self.assertNotIn("ghp_abcdefghijklmn", out) + self.assertNotIn("mysecret", out) + self.assertNotIn("hunter2", out) + self.assertNotIn("secret123", out) + self.assertIn("", out) + + def test_redact_logs_preserves_original_separator(self): + # gemini-code-assist review 提醒:原始分隔符(``:`` 或 ``=``)必须保留 + self.assertIn("api_key=", SubmitFeedbackIssueTool._redact_logs("api_key=xxx")) + self.assertIn("api_key: ", SubmitFeedbackIssueTool._redact_logs("api_key: xxx")) + self.assertIn("password: ", SubmitFeedbackIssueTool._redact_logs("password: xxx")) + self.assertIn("token=", SubmitFeedbackIssueTool._redact_logs("token=xxx")) + + def test_sanitize_logs_caps_to_limit_and_redacts(self): + result = SubmitFeedbackIssueTool._sanitize_logs( + "Cookie: secret\n" + "A" * 5000, limit=1024 + ) + self.assertNotIn("Cookie: secret", result) + self.assertIn("Cookie: ", result) + self.assertLessEqual(len(result), 1024) + + def test_sanitize_logs_returns_empty_for_blank_input(self): + self.assertEqual(SubmitFeedbackIssueTool._sanitize_logs(None, 1024), "") + self.assertEqual(SubmitFeedbackIssueTool._sanitize_logs(" \n ", 1024), "") + + def test_build_issue_body_contains_all_sections(self): + body = SubmitFeedbackIssueTool._build_issue_body( + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="## 现象\n- xxx", + logs="ERROR demo", + ) + for section in ( + "### 确认", + "### 当前程序版本", + "### 运行环境", + "### 问题类型", + "### 问题描述", + "### 发生问题时系统日志和配置文件", + "v2.12.2", + "Docker", + "主程序运行问题", + "ERROR demo", + ): + self.assertIn(section, body, msg=f"missing: {section!r}") + + def test_build_issue_body_handles_empty_logs(self): + body = SubmitFeedbackIssueTool._build_issue_body( + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="x", + logs=None, + ) + self.assertIn("会话中未捕获到相关后端日志。", body) + + def test_build_issue_body_redacts_logs(self): + body = SubmitFeedbackIssueTool._build_issue_body( + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="x", + logs="Cookie: foo=bar", + ) + self.assertIn("Cookie: ", body) + self.assertNotIn("Cookie: foo=bar", body) + + def test_build_issue_body_truncates_oversized_logs(self): + body = SubmitFeedbackIssueTool._build_issue_body( + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="x", + logs="A" * (MAX_LOGS_CHARS + 1000), + ) + # logs 段落在 ```bash ... ``` 之间;提取出来验证长度 + log_segment = body.split("```bash\n", 1)[1].rsplit("\n```", 1)[0] + self.assertLessEqual(len(log_segment), MAX_LOGS_CHARS) + self.assertIn("已截断", log_segment) + + def test_build_prefill_url_encodes_chinese_correctly(self): + url = SubmitFeedbackIssueTool._build_prefill_url( + title="[错误报告]: 版本测试", + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="line1\nline2", + logs=None, + ) + # "版" 的 UTF-8 percent-encoding 应为 %E7%89%88(曾经被 LLM 翻成 %E7%79%88) + self.assertIn("%E7%89%88", url) + # 换行用 %0A 而非 %0D,空格不能用 + 表示 + self.assertIn("%0A", url) + self.assertNotIn("+", url.split("?", 1)[1]) + # 必须带 template 参数才会进入 Issue Forms 表单 + self.assertIn("template=bug_report.yml", url) + + def test_build_prefill_url_redacts_and_caps_logs(self): + # gemini-code-assist HIGH 反馈:预填 URL 必须脱敏 + 截断到 3KB + sensitive_logs = "Cookie: leak_me\n" + ("A" * (MAX_URL_LOGS_CHARS + 5000)) + url = SubmitFeedbackIssueTool._build_prefill_url( + title="t", + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="d", + logs=sensitive_logs, + ) + # Cookie 必须不出现在 URL 里 + self.assertNotIn(quote("leak_me", safe=""), url) + self.assertIn(quote("", safe=""), url) + # 总 URL 长度可控(其它字段都很短,所以主要由 logs 决定) + # logs 的 percent-encoding 膨胀比 ~3x(每个 ASCII A 是 1 byte,不膨胀; + # 但 marker / 中文会膨胀),用 1.5x 余量验证 + self.assertLess(len(url), MAX_URL_LOGS_CHARS * 2) + + def test_classify_failure_handles_main_branches(self): + self.assertEqual(SubmitFeedbackIssueTool._classify_failure(401), "no_permission") + self.assertEqual(SubmitFeedbackIssueTool._classify_failure(404), "no_permission") + self.assertEqual( + SubmitFeedbackIssueTool._classify_failure(403), + "no_permission", + ) + self.assertEqual(SubmitFeedbackIssueTool._classify_failure(422), "invalid_payload") + self.assertEqual(SubmitFeedbackIssueTool._classify_failure(500), "github_unavailable") + self.assertEqual(SubmitFeedbackIssueTool._classify_failure(502), "github_unavailable") + self.assertEqual(SubmitFeedbackIssueTool._classify_failure(None), "api_error") + + def test_classify_failure_detects_rate_limit_on_403(self): + self.assertEqual( + SubmitFeedbackIssueTool._classify_failure( + 403, headers={"X-RateLimit-Remaining": "0"} + ), + "rate_limited", + ) + # 大小写不敏感 + self.assertEqual( + SubmitFeedbackIssueTool._classify_failure( + 403, headers={"x-ratelimit-remaining": "0"} + ), + "rate_limited", + ) + # 仍有余量时按无权限分类 + self.assertEqual( + SubmitFeedbackIssueTool._classify_failure( + 403, headers={"X-RateLimit-Remaining": "10"} + ), + "no_permission", + ) + + def test_safe_response_dict_falls_back_for_array_or_invalid_json(self): + # 合法 dict + self.assertEqual( + SubmitFeedbackIssueTool._safe_response_dict( + _FakeResponse(200, payload={"message": "ok"}) + ), + {"message": "ok"}, + ) + # array 不是 dict,应返回空 dict 而不是抛 AttributeError + self.assertEqual( + SubmitFeedbackIssueTool._safe_response_dict( + _FakeResponse(200, payload=[1, 2, 3]) + ), + {}, + ) + # 非 JSON 响应 + self.assertEqual( + SubmitFeedbackIssueTool._safe_response_dict(_FakeResponse(500)), + {}, + ) + + +class TestSubmitFeedbackIssueRun(unittest.TestCase): + """``run()`` 主流程测试;外部 HTTP / send_tool_message 全部 mock。""" + + def setUp(self): + # 每个用例独立清空进程级去重缓存 + SubmitFeedbackIssueTool._recent_submissions.clear() + # 默认无 token,避免误打真实 GitHub API + self._token_backup = settings.GITHUB_TOKEN + settings.GITHUB_TOKEN = None + self.tool = SubmitFeedbackIssueTool(session_id="s", user_id="u") + self.push_calls = [] + + async def fake_send(_self, text, title="", image=None): + self.push_calls.append({"text": text, "title": title}) + + self._push_patcher = patch.object( + SubmitFeedbackIssueTool, "send_tool_message", new=fake_send + ) + self._push_patcher.start() + + def tearDown(self): + self._push_patcher.stop() + settings.GITHUB_TOKEN = self._token_backup + + def _good_kwargs(self, **overrides): + kwargs = dict( + explanation="user authorized", + title="[错误报告]: 测试 issue", + version="v2.12.2", + environment="Docker", + issue_type="主程序运行问题", + description="## 现象\n- demo", + ) + kwargs.update(overrides) + return kwargs + + def test_rejects_invalid_environment_before_calling_api(self): + result = _run(self.tool.run(**self._good_kwargs(environment="linux"))) + data = json.loads(result) + self.assertFalse(data["success"]) + self.assertEqual(data["reason"], "invalid_input") + self.assertEqual(self.push_calls, []) + + def test_rejects_invalid_issue_type(self): + result = _run(self.tool.run(**self._good_kwargs(issue_type="random"))) + data = json.loads(result) + self.assertFalse(data["success"]) + self.assertEqual(data["reason"], "invalid_input") + + def test_no_token_branch_pushes_prefill_url_and_hides_it_from_llm(self): + result = _run(self.tool.run(**self._good_kwargs())) + data = json.loads(result) + self.assertFalse(data["success"]) + self.assertEqual(data["reason"], "no_token") + self.assertTrue(data["url_delivered"]) + # 关键不变量:URL 不应该回流给 LLM 转述 + self.assertIsNone(data["prefill_url"]) + # send_tool_message 必须被调一次,且消息体内含完整 URL + self.assertEqual(len(self.push_calls), 1) + self.assertIn("https://github.com/jxxghp/MoviePilot/issues/new", self.push_calls[0]["text"]) + + def test_truncates_oversized_title_before_submission(self): + title = "[错误报告]: " + ("超长" * 200) + result = _run(self.tool.run(**self._good_kwargs(title=title))) + data = json.loads(result) + self.assertEqual(data["reason"], "no_token") + # pushed message contains the truncated title via dedup-trail check; + # we can't see the actual title pushed, but we can confirm dedup uses + # the truncated form by re-submitting and verifying dedup hit. + SubmitFeedbackIssueTool._recent_submissions.clear() + # And verify directly: + truncated = SubmitFeedbackIssueTool._truncate(title, MAX_TITLE_CHARS, marker="…") + self.assertLessEqual(len(truncated), MAX_TITLE_CHARS) + + def test_success_branch_records_submission_and_dedups_next_call(self): + settings.GITHUB_TOKEN = "ghp_test_token" + + async def fake_post(_self, url, **kw): + return _FakeResponse( + 201, + payload={ + "html_url": "https://github.com/jxxghp/MoviePilot/issues/9999", + "number": 9999, + }, + ) + + with patch( + "app.agent.tools.impl.submit_feedback_issue.AsyncRequestUtils.post_res", + new=fake_post, + ): + first = _run(self.tool.run(**self._good_kwargs())) + second = _run(self.tool.run(**self._good_kwargs())) + + d1 = json.loads(first) + d2 = json.loads(second) + self.assertTrue(d1["success"]) + self.assertEqual(d1["repo"], FEEDBACK_REPO) + self.assertEqual(d1["issue_number"], 9999) + self.assertIsNone(d1["issue_url"]) # URL 走 send_tool_message + self.assertTrue(d1["url_delivered"]) + + # 第二次相同提交应被去重拒绝 + self.assertFalse(d2["success"]) + self.assertEqual(d2["reason"], "duplicate") + + def test_rate_limited_branch_when_403_with_zero_remaining(self): + settings.GITHUB_TOKEN = "ghp_test_token" + + async def fake_post(_self, url, **kw): + return _FakeResponse( + 403, + payload={"message": "API rate limit exceeded"}, + headers={"X-RateLimit-Remaining": "0"}, + ) + + with patch( + "app.agent.tools.impl.submit_feedback_issue.AsyncRequestUtils.post_res", + new=fake_post, + ): + result = _run(self.tool.run(**self._good_kwargs())) + + data = json.loads(result) + self.assertFalse(data["success"]) + self.assertEqual(data["reason"], "rate_limited") + self.assertTrue(data["url_delivered"]) + # 限流时不应该提示用户去改 token + self.assertNotIn("Token", data["message"][:80]) + + def test_no_permission_branch_when_403_with_remaining(self): + settings.GITHUB_TOKEN = "ghp_test_token" + + async def fake_post(_self, url, **kw): + return _FakeResponse( + 403, + payload={"message": "Resource not accessible by personal access token"}, + headers={"X-RateLimit-Remaining": "4990"}, + ) + + with patch( + "app.agent.tools.impl.submit_feedback_issue.AsyncRequestUtils.post_res", + new=fake_post, + ): + result = _run(self.tool.run(**self._good_kwargs())) + + data = json.loads(result) + self.assertEqual(data["reason"], "no_permission") + # 应该提示重新配 token + self.assertIn("Token", data["message"]) + + def test_invalid_payload_branch_when_422(self): + settings.GITHUB_TOKEN = "ghp_test_token" + + async def fake_post(_self, url, **kw): + return _FakeResponse( + 422, + payload={"message": "Validation Failed", "errors": []}, + ) + + with patch( + "app.agent.tools.impl.submit_feedback_issue.AsyncRequestUtils.post_res", + new=fake_post, + ): + result = _run(self.tool.run(**self._good_kwargs())) + + data = json.loads(result) + self.assertEqual(data["reason"], "invalid_payload") + + def test_network_error_branch_when_exception_raised(self): + settings.GITHUB_TOKEN = "ghp_test_token" + + async def fake_post(_self, url, **kw): + raise ConnectionError("simulated DNS failure") + + with patch( + "app.agent.tools.impl.submit_feedback_issue.AsyncRequestUtils.post_res", + new=fake_post, + ): + result = _run(self.tool.run(**self._good_kwargs())) + + data = json.loads(result) + self.assertFalse(data["success"]) + self.assertEqual(data["reason"], "network_error") + self.assertTrue(data["url_delivered"]) + + def test_dedup_blocks_repeat_within_window_for_attempted_api_call(self): + settings.GITHUB_TOKEN = "ghp_test_token" + + async def fake_post(_self, url, **kw): + return _FakeResponse(500, payload={"message": "internal"}) + + with patch( + "app.agent.tools.impl.submit_feedback_issue.AsyncRequestUtils.post_res", + new=fake_post, + ): + first = _run(self.tool.run(**self._good_kwargs())) + second = _run(self.tool.run(**self._good_kwargs())) + + d1 = json.loads(first) + d2 = json.loads(second) + self.assertEqual(d1["reason"], "github_unavailable") + # 即便首次失败也应进入 dedup 窗口,避免 LLM loop 不断重试同一提交 + self.assertEqual(d2["reason"], "duplicate") + + +class TestSubmitFeedbackIssueFactoryRegistration(unittest.TestCase): + def test_factory_registers_submit_feedback_issue_tool(self): + with patch( + "app.agent.tools.factory.PluginManager.get_plugin_agent_tools", + return_value=[], + ): + tools = MoviePilotToolFactory.create_tools( + session_id="feedback-issue-session", + user_id="10001", + ) + + tool_names = {tool.name for tool in tools} + self.assertIn("submit_feedback_issue", tool_names) + + +if __name__ == "__main__": + unittest.main()