新增 feedback-issue Agent skill:把用户反馈整理为上游 Issue (#5799)

This commit is contained in:
InfinityPacer
2026-05-20 20:10:03 +08:00
committed by GitHub
parent c52ccaf75f
commit ad7cce72f4
4 changed files with 1605 additions and 0 deletions

View File

@@ -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)

View File

@@ -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 KBGitHub 上限 ~65535留 5KB 余量)
# - logs 8 KBSKILL.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<REDACTED>"),
(re.compile(r"(?i)(Set-Cookie\s*:\s*)[^\r\n]+"), r"\1<REDACTED>"),
(
re.compile(r"(?i)(Authorization\s*:\s*)(Bearer|Basic|Token)\s+\S+"),
r"\1\2 <REDACTED>",
),
(
# 捕获原始分隔符(``:`` 或 ``=``)并在替换中保留,避免把 ``key: val``
# 强制改成 ``key=<REDACTED>`` 破坏日志阅读体验
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<REDACTED>",
),
)
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_TOKENfeedback 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,
)

View File

@@ -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 `[错误报告]: <one-line Chinese summary>`.
- `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
`<CONFIG_PATH>/logs/`. Typical Docker default is `/config/logs/`.
Plugin logs live under `<CONFIG_PATH>/logs/plugins/<plugin_id>/`.
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 <CONFIG_PATH>/logs/moviepilot.log | \
grep -nE -B 5 -A 30 'ERROR|Traceback|Exception|<keyword>'
# Plugin-specific, both main log and plugin log
tail -n 1500 <CONFIG_PATH>/logs/plugins/<plugin_id>/<plugin_id>.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 '<symbol_or_endpoint>' 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: <REDACTED>` |
| `Set-Cookie:\s*[^\n]+` | `Set-Cookie: <REDACTED>` |
| `Authorization:\s*(Bearer|Basic|Token)\s+\S+` | `Authorization: $1 <REDACTED>` |
| `(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|passkey|pwd|password|secret|token)\s*[:=]\s*['"]?[^\s'"&]+` | `$1=<REDACTED>` |
| `passkey=[0-9a-f]{8,}` (URL query) | `passkey=<REDACTED>` |
| `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}` (email) | `<EMAIL_REDACTED>` |
| Public IPv4 (skip private 10/172.16/192.168/127) | `<IP_REDACTED>` |
| `/Users/[^/\s]+/` or `/home/[^/\s]+/` | `/Users/<USER>/` / `/home/<USER>/` |
| WeChat / Telegram / Lark webhook URLs containing tokens | host kept, token segment → `<REDACTED>` |
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`** — `[错误报告]: <a single Chinese sentence summarizing the
symptom>`. 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 项 ✅ 后提交即可:
>
> <prefill_url>
>
> 如果希望以后让 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).

View File

@@ -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("<REDACTED>", out)
def test_redact_logs_preserves_original_separator(self):
# gemini-code-assist review 提醒:原始分隔符(``:`` 或 ``=``)必须保留
self.assertIn("api_key=<REDACTED>", SubmitFeedbackIssueTool._redact_logs("api_key=xxx"))
self.assertIn("api_key: <REDACTED>", SubmitFeedbackIssueTool._redact_logs("api_key: xxx"))
self.assertIn("password: <REDACTED>", SubmitFeedbackIssueTool._redact_logs("password: xxx"))
self.assertIn("token=<REDACTED>", 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: <REDACTED>", 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: <REDACTED>", 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("<REDACTED>", 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()