mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-01 13:40:54 +08:00
新增 feedback-issue Agent skill:把用户反馈整理为上游 Issue (#5799)
This commit is contained in:
@@ -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)
|
||||
|
||||
682
app/agent/tools/impl/submit_feedback_issue.py
Normal file
682
app/agent/tools/impl/submit_feedback_issue.py
Normal 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 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<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_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,
|
||||
)
|
||||
444
skills/feedback-issue/SKILL.md
Normal file
444
skills/feedback-issue/SKILL.md
Normal 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).
|
||||
477
tests/test_agent_submit_feedback_issue_tool.py
Normal file
477
tests/test_agent_submit_feedback_issue_tool.py
Normal 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()
|
||||
Reference in New Issue
Block a user