Files
archived-MoviePilot/app/agent/tools/impl/submit_feedback_issue.py

683 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""向 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,
)