Files
MoviePilot/app/adapters/external/github.py
2026-09-23 12:41:18 +08:00

188 lines
8.1 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.
"""GitHub OAuth Device Flow 的网络适配器。"""
from __future__ import annotations
from typing import Any
from app.adapters.network.http import AsyncRequestUtils
from app.domain.github import (
GithubAuthPort,
GithubAuthTransportError,
GithubDeviceCode,
GithubSettingsReader,
GithubTokenExchange,
GithubUser,
)
from app.runtime.settings import get_runtime_setting
# Device Flow 的 client_id 本身不是秘密;沿用当前内置 Copilot 登录使用的公开应用标识,
# 后续如果注册专用 OAuth App,只需在组合根替换这一常量,不会影响授权协议和存储合同。
GITHUB_DEVICE_CLIENT_ID = "Ov23li8tweQw6odWQebz"
class _RuntimeGithubSettings:
"""把 runtime 设置读取端口适配为 GitHub 客户端所需的 get 接口。"""
def get(self, key: str, default: Any = None) -> Any:
"""读取一个运行时设置,并在启动早期保留默认值语义。"""
return get_runtime_setting(key, default)
class GithubAuthClient(GithubAuthPort):
"""通过 MoviePilot 统一异步 HTTP 适配器访问 GitHub OAuth API。"""
_DEVICE_CODE_URL = "https://github.com/login/device/code"
_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
_USER_URL = "https://api.github.com/user"
def __init__(self, settings: GithubSettingsReader | None = None) -> None:
"""保存代理、User-Agent 等部署设置提供器。"""
self._settings = settings or _RuntimeGithubSettings()
async def request_device_code(
self,
client_id: str,
scope: str = "read:user",
) -> GithubDeviceCode:
"""按调用方 scope 请求设备码,默认保留 Copilot 登录的只读用户权限。"""
try:
response = await self._request().post_res(
self._DEVICE_CODE_URL,
json={"client_id": client_id, "scope": scope},
raise_exception=True,
)
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
payload = self._json_payload(response)
self._raise_payload_error(payload, "申请 GitHub 设备码失败")
try:
return GithubDeviceCode(
device_code=str(payload["device_code"]),
user_code=str(payload["user_code"]),
verification_uri=str(payload.get("verification_uri") or payload.get("verification_url") or ""),
expires_in=max(int(payload.get("expires_in") or 900), 1),
interval_seconds=max(int(payload.get("interval") or 5), 1),
)
except (KeyError, TypeError, ValueError) as error:
raise GithubAuthTransportError("GitHub 返回的设备授权信息无效") from error
async def exchange_device_code(
self,
client_id: str,
device_code: str,
) -> GithubTokenExchange:
"""轮询 GitHub 设备码,并保留协议层授权状态。"""
try:
response = await self._request().post_res(
self._ACCESS_TOKEN_URL,
json={
"client_id": client_id,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
},
raise_exception=True,
)
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
return self._exchange_payload(self._json_payload(response))
async def refresh_access_token(
self,
client_id: str,
refresh_token: str,
) -> GithubTokenExchange:
"""使用 GitHub 返回的 refresh_token 申请新的访问 Token。"""
try:
response = await self._request().post_res(
self._ACCESS_TOKEN_URL,
json={
"client_id": client_id,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
raise_exception=True,
)
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
return self._exchange_payload(self._json_payload(response))
async def get_user(self, access_token: str) -> GithubUser:
"""调用 GitHub 用户接口验证 Token 并读取登录名。"""
try:
response = await self._request(headers={"Authorization": f"Bearer {access_token}"}).get_res(
self._USER_URL,
raise_exception=True,
)
except Exception as error: # noqa: BLE001 - 网络异常统一转换为领域传输错误
raise GithubAuthTransportError("无法连接 GitHub,请稍后重试。") from error
if response is None:
raise GithubAuthTransportError("GitHub 未返回用户校验结果")
if response.status_code == 401:
raise GithubAuthTransportError("GitHub Token 已失效", unauthorized=True)
if response.status_code >= 400:
raise GithubAuthTransportError("GitHub Token 校验失败")
payload = self._json_payload(response)
login = str(payload.get("login") or "").strip()
if not login:
raise GithubAuthTransportError("GitHub 未返回用户信息")
return GithubUser(login=login)
def _request(self, *, headers: dict[str, str] | None = None) -> AsyncRequestUtils:
"""构造 GitHub OAuth 请求客户端,不复用当前业务 Token 请求头。"""
request_headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": str(self._settings.get("USER_AGENT") or "MoviePilot"),
}
request_headers.update(headers or {})
return AsyncRequestUtils(
headers=request_headers,
proxies=self._settings.get("PROXY"),
timeout=20,
verify=True,
trust_env=False,
)
@staticmethod
def _json_payload(response: Any) -> dict[str, Any]:
"""读取 GitHub JSON 响应,避免把原始响应文本写入业务错误。"""
try:
payload = response.json()
except Exception as error: # noqa: BLE001 - 外部 JSON 异常统一收敛
raise GithubAuthTransportError("GitHub 返回了无效响应") from error
if not isinstance(payload, dict):
raise GithubAuthTransportError("GitHub 返回了无效响应")
return payload
@staticmethod
def _raise_payload_error(payload: dict[str, Any], message: str) -> None:
"""将 GitHub OAuth 错误字段转换为不泄露响应原文的业务异常。"""
if payload.get("error"):
raise GithubAuthTransportError(message)
@classmethod
def _exchange_payload(cls, payload: dict[str, Any]) -> GithubTokenExchange:
"""把设备码或刷新响应转换为统一 Token 交换结果。"""
return GithubTokenExchange(
access_token=cls._optional_text(payload.get("access_token")),
refresh_token=cls._optional_text(payload.get("refresh_token")),
expires_in=cls._optional_int(payload.get("expires_in")),
refresh_token_expires_in=cls._optional_int(payload.get("refresh_token_expires_in")),
scope=cls._optional_text(payload.get("scope")),
error=cls._optional_text(payload.get("error")),
)
@staticmethod
def _optional_text(value: Any) -> str | None:
"""规范化 GitHub 响应中的可选文本字段。"""
text = str(value or "").strip()
return text or None
@staticmethod
def _optional_int(value: Any) -> int | None:
"""规范化 GitHub 响应中的可选整数时间字段。"""
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None