mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-28 19:06:36 +08:00
perf(webpush): send Web Push with http-ece and py-vapid instead of pywebpush
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
InfinityPacer
parent
0833169781
commit
4bd7e3dc04
@@ -114,7 +114,7 @@ These decisions are architectural constraints, not naming suggestions:
|
||||
* External media detail projection stays in `app/domain/projection/`: `mapping.py` owns the immutable builder and `tmdb.py`/`douban.py`/`bangumi.py`/`anilist.py` own source rules. `app/domain/context.py` keeps canonical `MediaInfo` construction and the four historical setter ABI as thin delegates. Host callers import source owners directly; do not add SDK/Compat aliases for these internal owners.
|
||||
* Kodi-style NFO reading and metadata document generation are one domain capability and stay together in `app/domain/scraper.py`; a separate `domain/nfo.py` must not be recreated.
|
||||
* `app/application/mediaserver.py` is the single media-server service capability module. It owns configured service discovery together with Provider ID normalization and music-library matching, while reusing generic identity rules from `app/domain/media.py`.
|
||||
* Configured notification-service discovery belongs in `app/application/notification.py`. Web Push subscription and manual-send HTTP behavior stays in `app/api/endpoints/message.py`; it is not a reusable messaging capability module.
|
||||
* Configured notification-service discovery belongs in `app/application/notification.py`. Web Push subscription and manual-send HTTP behavior stays in `app/api/endpoints/message.py`; it is not a reusable messaging capability module. The Web Push protocol itself (aes128gcm encryption, VAPID signing, delivery through `RequestUtils`) is the network adapter `app/adapters/network/webpush.py`, shared by that endpoint and `WebPushModule`.
|
||||
* `app/adapters/system/resource.py` detects/downloads/installs resources and returns whether installation occurred. Only `app/startup/initializers/modules.py` may decide to restart the process afterward.
|
||||
* Process memory/GC policy belongs in `app/runtime/gc.py`; external IP-location APIs belong in `app/adapters/external/location.py`.
|
||||
* Security implementation filenames use package-context nouns: `app/application/security/url.py` and `app/application/security/twofactor.py`. Historical `app.utils.security` and `app.helper.twofa` remain compatibility mappings only.
|
||||
|
||||
116
app/adapters/network/webpush.py
Normal file
116
app/adapters/network/webpush.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Web Push 协议发送(RFC 8030 投递、RFC 8291 aes128gcm 加密、RFC 8292 VAPID 签名)。
|
||||
|
||||
直接使用 http_ece 与 py_vapid 完成加密和签名,经 RequestUtils 投递,不导入 pywebpush:
|
||||
后者只是这两个库的薄封装,却会在导入时加载 aiohttp。请求头、VAPID 声明与 pywebpush
|
||||
2.3 的 ``webpush()`` 保持一致。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import time
|
||||
from typing import Any, Dict, Mapping, Optional, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import http_ece
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from py_vapid import Vapid
|
||||
|
||||
from app.adapters.network.http import RequestUtils
|
||||
|
||||
# VAPID JWT 有效期;与 pywebpush 默认一致,推送服务要求不超过 24 小时。
|
||||
_VAPID_EXPIRE_SECONDS = 12 * 60 * 60
|
||||
# 推送服务返回 2xx 中不超过 202 的状态码视为已接收。
|
||||
_MAX_ACCEPTED_STATUS = 202
|
||||
_DEFAULT_TIMEOUT_SECONDS = 20
|
||||
|
||||
|
||||
class WebPushDeliveryError(Exception):
|
||||
"""
|
||||
Web Push 投递失败。
|
||||
|
||||
``response`` 为推送服务的 HTTP 响应,网络失败或订阅数据无效时为 None;
|
||||
调用方据其状态码(404/410)判断订阅是否已失效。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, response: Any = None):
|
||||
super().__init__(message)
|
||||
self.response = response
|
||||
|
||||
|
||||
def _b64url_decode(value: str) -> bytes:
|
||||
"""解码浏览器订阅中去掉填充的 base64url 字段。"""
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def _vapid_headers(endpoint: str, private_key: str, subject: str) -> Dict[str, str]:
|
||||
"""按推送端点源站生成 VAPID Authorization 请求头。"""
|
||||
url = urlparse(endpoint)
|
||||
claims = {
|
||||
"sub": subject,
|
||||
"aud": f"{url.scheme}://{url.netloc}",
|
||||
"exp": int(time.time()) + _VAPID_EXPIRE_SECONDS,
|
||||
}
|
||||
return cast(Dict[str, str], Vapid.from_string(private_key=private_key).sign(claims))
|
||||
|
||||
|
||||
def _encrypt(subscription: Mapping[str, Any], payload: bytes) -> bytes:
|
||||
"""用订阅公钥与 auth 密钥按 aes128gcm 加密消息体,每次使用新的临时 ECDH 密钥。"""
|
||||
keys = subscription.get("keys") or {}
|
||||
if not keys.get("p256dh") or not keys.get("auth"):
|
||||
raise WebPushDeliveryError("订阅缺少 p256dh 或 auth 密钥")
|
||||
receiver_key = _b64url_decode(keys["p256dh"])
|
||||
if len(receiver_key) != 65 or receiver_key[0] != 0x04:
|
||||
raise WebPushDeliveryError("订阅的 p256dh 公钥格式无效")
|
||||
return cast(bytes, http_ece.encrypt(
|
||||
payload,
|
||||
private_key=ec.generate_private_key(ec.SECP256R1()),
|
||||
dh=receiver_key,
|
||||
auth_secret=_b64url_decode(keys["auth"]),
|
||||
version="aes128gcm",
|
||||
))
|
||||
|
||||
|
||||
def send_webpush(
|
||||
subscription: Mapping[str, Any],
|
||||
data: str,
|
||||
vapid_private_key: str,
|
||||
vapid_subject: str,
|
||||
ttl: int = 0,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
timeout: int = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> Any:
|
||||
"""
|
||||
向单个浏览器订阅投递一条加密通知。
|
||||
|
||||
:param subscription: 浏览器 PushSubscription JSON(endpoint 与 keys.p256dh/auth)
|
||||
:param data: 通知正文,通常是 Service Worker 解析的 JSON 字符串
|
||||
:param vapid_private_key: VAPID 私钥(base64url 编码的原始私钥或 DER)
|
||||
:param vapid_subject: VAPID 联系方式,形如 ``mailto:...`` 或站点 URL
|
||||
:param ttl: 接收端离线时推送服务保留消息的秒数,0 表示不保留
|
||||
:param headers: 端点专用的附加请求头,例如 WNS 缓存策略
|
||||
:param timeout: 请求超时秒数
|
||||
:return: 推送服务的 HTTP 响应
|
||||
:raises WebPushDeliveryError: 订阅无效、网络失败或推送服务拒收
|
||||
"""
|
||||
endpoint = subscription.get("endpoint")
|
||||
if not endpoint:
|
||||
raise WebPushDeliveryError("订阅缺少 endpoint")
|
||||
if not vapid_private_key:
|
||||
raise WebPushDeliveryError("未配置 VAPID 私钥")
|
||||
request_headers = {
|
||||
**(headers or {}),
|
||||
**_vapid_headers(endpoint, vapid_private_key, vapid_subject),
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"TTL": str(ttl),
|
||||
}
|
||||
response = RequestUtils(
|
||||
headers=request_headers, timeout=timeout, verify=True
|
||||
).post_res(endpoint, data=_encrypt(subscription, data.encode("utf-8")))
|
||||
if response is None:
|
||||
raise WebPushDeliveryError("推送服务无响应")
|
||||
if response.status_code > _MAX_ACCEPTED_STATUS:
|
||||
raise WebPushDeliveryError(
|
||||
f"推送服务拒收:{response.status_code} {response.reason},{response.text[:200]}",
|
||||
response=response,
|
||||
)
|
||||
return response
|
||||
@@ -43,7 +43,7 @@ _WNS_DEFAULT_TTL = 86400
|
||||
class WebPushError(Protocol):
|
||||
"""Web Push 订阅状态判断所需的最小异常协议。"""
|
||||
|
||||
response: Any # 推送服务响应,状态码字段由具体 SDK 提供
|
||||
response: Any # 推送服务 HTTP 响应,失败前未收到响应时为 None
|
||||
|
||||
|
||||
def is_webpush_subscription_gone(error: WebPushError) -> bool:
|
||||
@@ -63,7 +63,7 @@ def is_wns_endpoint(endpoint: str | None) -> bool:
|
||||
|
||||
|
||||
def webpush_options_for_endpoint(endpoint: str | None) -> dict[str, Any]:
|
||||
"""返回指定推送端点需要的 pywebpush 附加参数。"""
|
||||
"""返回指定推送端点需要的 Web Push 附加参数(ttl 与请求头)。"""
|
||||
if not is_wns_endpoint(endpoint):
|
||||
return {}
|
||||
return {
|
||||
@@ -372,7 +372,7 @@ def send_notification(
|
||||
"""
|
||||
发送webpush通知
|
||||
"""
|
||||
from pywebpush import WebPushException, webpush
|
||||
from app.adapters.network.webpush import WebPushDeliveryError, send_webpush
|
||||
|
||||
subscriptions = webpush_registry.list()
|
||||
if not subscriptions:
|
||||
@@ -382,15 +382,15 @@ def send_notification(
|
||||
failure_count = 0
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info=sub,
|
||||
send_webpush(
|
||||
subscription=sub,
|
||||
data=json.dumps(payload.model_dump()),
|
||||
vapid_private_key=get_api_runtime_config_snapshot().vapid_private_key,
|
||||
vapid_claims={"sub": get_api_runtime_config_snapshot().vapid_subject},
|
||||
vapid_subject=get_api_runtime_config_snapshot().vapid_subject,
|
||||
**webpush_options_for_endpoint(sub.get("endpoint")),
|
||||
)
|
||||
success_count += 1
|
||||
except WebPushException as err:
|
||||
except WebPushDeliveryError as err:
|
||||
logger.error(f"WebPush发送失败: {str(err)}")
|
||||
if is_webpush_subscription_gone(err) and webpush_registry.remove(sub):
|
||||
logger.info(f"已移除失效WebPush订阅: {sub.get('endpoint')}")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import json
|
||||
from typing import Union, Tuple
|
||||
|
||||
from pywebpush import webpush, WebPushException
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
|
||||
from app.adapters.network.webpush import WebPushDeliveryError, send_webpush
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
from app.runtime.webpush import webpush_registry
|
||||
|
||||
@@ -89,26 +88,24 @@ class WebPushModule(_ModuleBase, _MessageBase):
|
||||
logger.debug(f"给 {sub} 发送WebPush:{caption} {content}")
|
||||
try:
|
||||
endpoint = sub.get("endpoint")
|
||||
webpush_options = {}
|
||||
webpush_options: Dict[str, Any] = {}
|
||||
if endpoint and "notify.windows.com" in endpoint:
|
||||
webpush_options = {
|
||||
"ttl": 86400,
|
||||
"headers": {"X-WNS-Cache-Policy": "cache"},
|
||||
}
|
||||
webpush(
|
||||
subscription_info=sub,
|
||||
send_webpush(
|
||||
subscription=sub,
|
||||
data=json.dumps({
|
||||
"title": caption,
|
||||
"body": content,
|
||||
"url": message.link or "/?shotcut=message"
|
||||
}),
|
||||
vapid_private_key=get_runtime_setting('VAPID').get("privateKey"),
|
||||
vapid_claims={
|
||||
"sub": get_runtime_setting('VAPID').get("subject")
|
||||
},
|
||||
vapid_subject=get_runtime_setting('VAPID').get("subject"),
|
||||
**webpush_options,
|
||||
)
|
||||
except WebPushException as err:
|
||||
except WebPushDeliveryError as err:
|
||||
logger.error(f"WebPush发送失败: {str(err)}")
|
||||
response = getattr(err, "response", None)
|
||||
status_code = getattr(response, "status_code", None) or getattr(
|
||||
|
||||
@@ -743,7 +743,7 @@ flowchart LR
|
||||
`--write-host` 不会替代人工决策。
|
||||
- 同一 baseline 的 `direct_adapter_imports` 记录现存原始直连;Application 与 Chain 均已清零,
|
||||
policy 目标为空集合。新增、替换、删除后未清理 policy 都会失败。
|
||||
- `direct_egress` 记录全宿主 50 条 raw transport、network SDK 和协议操作 identity;普通 HTTP/
|
||||
- `direct_egress` 记录全宿主 48 条 raw transport、network SDK 和协议操作 identity;普通 HTTP/
|
||||
Session bridge 与 Application DNS I/O 债务已清零,其余 canonical transport、SDK、
|
||||
stream/vendor/diagnostic/control-plane 事实是精确 containment。每条初始边的指纹由测试独立冻结,
|
||||
bindings/uses 变化、分类互换、通配导入和初始边增长都会失败;债务删除时同步删除冻结项以禁止恢复,
|
||||
@@ -762,18 +762,18 @@ flowchart LR
|
||||
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 1056 |
|
||||
| 内部导入边 | 9,033 |
|
||||
| Python 模块 | 1057 |
|
||||
| 内部导入边 | 9,041 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 50(债务已清零,50 条精确 containment) |
|
||||
| Direct egress | 48(债务已清零,48 条精确 containment) |
|
||||
| Module Contract V2 spec | 215(其中 211 个进入 `run_module` 观察面) |
|
||||
| Event Contract | 53 |
|
||||
| Event producer / consumer | 86(85 静态、1 动态)/ 17(16 静态、1 动态) |
|
||||
| Model/Oper 自动事务与自建 Session | 0 |
|
||||
| 组合根外 `SystemConfigOper()` | 0 |
|
||||
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责;宿主依赖边现为 9,033,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责;宿主依赖边现为 9,041,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。当前 `app.startup.lifecycle` 为 576、`app.factory` 为 588、`app.main` 为 590。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
|
||||
|
||||
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
|
||||
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次
|
||||
|
||||
@@ -96,7 +96,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1056 / 9,033 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 1057 / 9,041 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -543,7 +543,7 @@ S4/ARCH-110/111 的取消不撤销现有防回退门禁。后续按路线图的
|
||||
具体 cache adapter;Memory/Redis 均实现严格 `AtomicCacheBackend.store/consume`。
|
||||
- S0-L2.4b 已为 LLM streaming、第三方 SDK、移植库和本地控制面建立完整 egress identity 与
|
||||
zero-growth policy;11 条普通 HTTP/Session bridge 和 1 条 Application DNS I/O 债务已清零。
|
||||
当前 50 条出口事实均为精确 containment,每条指纹由独立上界冻结,不能靠同时刷新
|
||||
当前 48 条出口事实均为精确 containment,每条指纹由独立上界冻结,不能靠同时刷新
|
||||
baseline/policy 掩盖调用面增长,已删除债务也不得恢复。
|
||||
|
||||
**目标与步骤**
|
||||
|
||||
@@ -965,7 +965,9 @@ expired claimed task remains exclusively owned by fenced recovery APIs.
|
||||
application service.
|
||||
- Configured notification discovery lives in
|
||||
`app/application/notification.py`. Web Push subscription and manual-send HTTP
|
||||
behavior stays in `app/api/endpoints/message.py`.
|
||||
behavior stays in `app/api/endpoints/message.py`; the Web Push protocol
|
||||
(aes128gcm encryption, VAPID signing, delivery) is the network adapter
|
||||
`app/adapters/network/webpush.py`, shared by that endpoint and `WebPushModule`.
|
||||
- System network testing lives in `app/application/network.py`: it owns the
|
||||
server-only target catalog, exact legacy URL matching, HTTPS and credential
|
||||
checks, per-target redirect allowlists and content validation. The System API
|
||||
|
||||
@@ -31,6 +31,7 @@ dependencies = [
|
||||
"fast-bencode~=1.1.8",
|
||||
"fastapi~=0.141.1",
|
||||
"google-genai~=2.8.0",
|
||||
"http-ece~=1.2.1",
|
||||
"httpx[http2,socks]~=0.28.1",
|
||||
"httpx2[http2,socks]~=2.12.0",
|
||||
"jinja2~=3.1.6",
|
||||
@@ -57,6 +58,7 @@ dependencies = [
|
||||
"pinyin2hanzi~=0.1.1",
|
||||
"plexapi~=4.18.1",
|
||||
"psutil~=7.2.2",
|
||||
"py-vapid~=1.9.4",
|
||||
"pycryptodome~=3.23.0",
|
||||
"pydantic>=2.13.4,<3.0.0",
|
||||
"pydantic-settings>=2.14.2,<3.0.0",
|
||||
@@ -72,6 +74,7 @@ dependencies = [
|
||||
"python-multipart~=0.0.32",
|
||||
"pytz~=2026.2",
|
||||
"pyvirtualdisplay~=3.0",
|
||||
# 宿主 Web Push 已直接使用 http-ece 与 py-vapid,此 SDK 暂时保留给插件使用。
|
||||
"pywebpush~=2.3.0",
|
||||
"pyyaml~=6.0.3",
|
||||
"qbittorrent-api==2026.6.0",
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
"app.application": 0,
|
||||
"app.chain": 0
|
||||
},
|
||||
"count": 50,
|
||||
"count": 48,
|
||||
"counts_by_kind": {
|
||||
"network_sdk": 25,
|
||||
"network_sdk": 23,
|
||||
"protocol_operation": 7,
|
||||
"raw_transport": 18
|
||||
},
|
||||
@@ -418,19 +418,6 @@
|
||||
"SearchWebTool._search_ddgs.sync_search|call:text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"bindings": [
|
||||
"from:pywebpush.WebPushException as WebPushException",
|
||||
"from:pywebpush.webpush as webpush"
|
||||
],
|
||||
"fingerprint": "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b",
|
||||
"kind": "network_sdk",
|
||||
"source": "app.api.endpoints.message",
|
||||
"target": "pywebpush",
|
||||
"uses": [
|
||||
"send_notification|call:webpush"
|
||||
]
|
||||
},
|
||||
{
|
||||
"bindings": [
|
||||
"from:urllib.request.Request as Request",
|
||||
@@ -892,19 +879,6 @@
|
||||
"Transmission.__sync_incomplete_file_suffix|call:set_session"
|
||||
]
|
||||
},
|
||||
{
|
||||
"bindings": [
|
||||
"from:pywebpush.WebPushException as WebPushException",
|
||||
"from:pywebpush.webpush as webpush"
|
||||
],
|
||||
"fingerprint": "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76",
|
||||
"kind": "network_sdk",
|
||||
"source": "app.modules.webpush",
|
||||
"target": "pywebpush",
|
||||
"uses": [
|
||||
"WebPushModule.post_message|call:webpush"
|
||||
]
|
||||
},
|
||||
{
|
||||
"bindings": [
|
||||
"import:websocket as websocket"
|
||||
@@ -1021,8 +995,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 9033,
|
||||
"edge_sha256": "f522f58bff01f9a88f784c346dec18d467f3efd72898b134073bb6a1756b0d9b",
|
||||
"edge_count": 9041,
|
||||
"edge_sha256": "5d588f777a2453573d913ff6a0c4a403656a3a22eb1041a2397d50b9ba8c5b61",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -1134,6 +1108,9 @@
|
||||
"app.adapters.network.doh -> app.runtime.settings",
|
||||
"app.adapters.network.http -> app.runtime",
|
||||
"app.adapters.network.http -> app.runtime.correlation",
|
||||
"app.adapters.network.webpush -> app.adapters",
|
||||
"app.adapters.network.webpush -> app.adapters.network",
|
||||
"app.adapters.network.webpush -> app.adapters.network.http",
|
||||
"app.adapters.observability.otel -> app.runtime",
|
||||
"app.adapters.observability.otel -> app.runtime.observability",
|
||||
"app.adapters.system.backup.files -> app.runtime",
|
||||
@@ -2505,6 +2482,8 @@
|
||||
"app.api.endpoints.message -> app.adapters",
|
||||
"app.api.endpoints.message -> app.adapters.external",
|
||||
"app.api.endpoints.message -> app.adapters.external.wechat",
|
||||
"app.api.endpoints.message -> app.adapters.network",
|
||||
"app.api.endpoints.message -> app.adapters.network.webpush",
|
||||
"app.api.endpoints.message -> app.adapters.web",
|
||||
"app.api.endpoints.message -> app.adapters.web.security",
|
||||
"app.api.endpoints.message -> app.adapters.web.security.access",
|
||||
@@ -8203,6 +8182,9 @@
|
||||
"app.modules.vocechat.vocechat -> app.runtime",
|
||||
"app.modules.vocechat.vocechat -> app.runtime.execution",
|
||||
"app.modules.vocechat.vocechat -> app.runtime.log",
|
||||
"app.modules.webpush -> app.adapters",
|
||||
"app.modules.webpush -> app.adapters.network",
|
||||
"app.modules.webpush -> app.adapters.network.webpush",
|
||||
"app.modules.webpush -> app.modules",
|
||||
"app.modules.webpush -> app.runtime",
|
||||
"app.modules.webpush -> app.runtime.log",
|
||||
@@ -10058,7 +10040,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 1056,
|
||||
"module_count": 1057,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -10082,6 +10064,7 @@
|
||||
"app.adapters.network.http",
|
||||
"app.adapters.network.ip",
|
||||
"app.adapters.network.resolver",
|
||||
"app.adapters.network.webpush",
|
||||
"app.adapters.observability",
|
||||
"app.adapters.observability.otel",
|
||||
"app.adapters.system",
|
||||
|
||||
@@ -138,7 +138,6 @@
|
||||
{"source": "app.agent.llm.discovery", "target": "google.genai", "kind": "network_sdk", "fingerprint": "5b87a7bb210b521bd21971fb6e3d19ca450658548670106e3c756c0ce3a83cad"},
|
||||
{"source": "app.agent.llm.discovery", "target": "openai", "kind": "network_sdk", "fingerprint": "fff154461ff3fdfdf1daea0530dcb68589d6ad7a67f3966e3b86338b483e6dfd"},
|
||||
{"source": "app.agent.tools.impl.search_web", "target": "ddgs", "kind": "network_sdk", "fingerprint": "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b"},
|
||||
{"source": "app.api.endpoints.message", "target": "pywebpush", "kind": "network_sdk", "fingerprint": "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b"},
|
||||
{"source": "app.modules.discord.discord", "target": "discord", "kind": "network_sdk", "fingerprint": "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86"},
|
||||
{"source": "app.modules.feishu.feishu", "target": "lark_oapi", "kind": "network_sdk", "fingerprint": "99d16968a59932b5980f6d42ea2bbc8fa35ccdb1a27933beec29b4bdc093d78b"},
|
||||
{"source": "app.modules.filemanager.storages.smb", "target": "smbclient", "kind": "network_sdk", "fingerprint": "37619f050b03598fa559c6ae2df94bda6adf7cffd1cc42d9db9059f1fb41af47"},
|
||||
@@ -147,8 +146,7 @@
|
||||
{"source": "app.modules.qbittorrent.qbittorrent", "target": "qbittorrentapi", "kind": "network_sdk", "fingerprint": "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d"},
|
||||
{"source": "app.modules.slack.slack", "target": "slack_bolt", "kind": "network_sdk", "fingerprint": "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6"},
|
||||
{"source": "app.modules.slack.slack", "target": "slack_sdk", "kind": "network_sdk", "fingerprint": "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14"},
|
||||
{"source": "app.modules.transmission.transmission", "target": "transmission_rpc", "kind": "network_sdk", "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf"},
|
||||
{"source": "app.modules.webpush", "target": "pywebpush", "kind": "network_sdk", "fingerprint": "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76"}
|
||||
{"source": "app.modules.transmission.transmission", "target": "transmission_rpc", "kind": "network_sdk", "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf"}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -38,7 +38,6 @@ FROZEN_EGRESS_EDGES_BY_REASON = {
|
||||
("app.agent.llm.discovery", "google.genai"),
|
||||
("app.agent.llm.discovery", "openai"),
|
||||
("app.agent.tools.impl.search_web", "ddgs"),
|
||||
("app.api.endpoints.message", "pywebpush"),
|
||||
("app.modules.discord.discord", "discord"),
|
||||
("app.modules.feishu.feishu", "lark_oapi"),
|
||||
("app.modules.filemanager.storages.smb", "smbclient"),
|
||||
@@ -48,7 +47,6 @@ FROZEN_EGRESS_EDGES_BY_REASON = {
|
||||
("app.modules.slack.slack", "slack_bolt"),
|
||||
("app.modules.slack.slack", "slack_sdk"),
|
||||
("app.modules.transmission.transmission", "transmission_rpc"),
|
||||
("app.modules.webpush", "pywebpush"),
|
||||
},
|
||||
"streaming_protocol": {
|
||||
("app.startup.composition.network", "websocket"),
|
||||
@@ -110,7 +108,6 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
|
||||
("app.agent.llm.discovery", "google.genai"): "5b87a7bb210b521bd21971fb6e3d19ca450658548670106e3c756c0ce3a83cad",
|
||||
("app.agent.llm.discovery", "openai"): "fff154461ff3fdfdf1daea0530dcb68589d6ad7a67f3966e3b86338b483e6dfd",
|
||||
("app.agent.tools.impl.search_web", "ddgs"): "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b",
|
||||
("app.api.endpoints.message", "pywebpush"): "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b",
|
||||
("app.cli", "urllib.request"): "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b",
|
||||
("app.doctor.checks", "socket.create_connection"): "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6",
|
||||
("app.doctor.checks", "urllib.request"): "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354",
|
||||
@@ -131,7 +128,6 @@ FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
|
||||
("app.modules.slack.slack", "slack_sdk"): "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14",
|
||||
("app.modules.themoviedb.tmdbv3api.tmdb", "requests"): "d605eb176a203b3f4d205c5d183469b3002426682eb9cb4e7408bb6013652484",
|
||||
("app.modules.transmission.transmission", "transmission_rpc"): "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf",
|
||||
("app.modules.webpush", "pywebpush"): "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76",
|
||||
("app.modules.wechat.wechatbot", "websocket"): "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6",
|
||||
("app.startup.composition.network", "websocket"): "09e1909ac750fe40b145dde8fee52eedf84aaf2123f6a1bc38109252c89d6000",
|
||||
("app.modules.zspace.zspace", "requests"): "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51",
|
||||
|
||||
@@ -5,7 +5,7 @@ from app.api.endpoints.message import (
|
||||
is_wns_endpoint,
|
||||
webpush_options_for_endpoint,
|
||||
)
|
||||
from pywebpush import WebPushException
|
||||
from app.adapters.network.webpush import WebPushDeliveryError
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -15,14 +15,12 @@ class _FakeResponse:
|
||||
|
||||
def test_is_webpush_subscription_gone_for_expired_status_codes() -> None:
|
||||
for status_code in (404, 410):
|
||||
err = WebPushException("gone")
|
||||
err.response = _FakeResponse(status_code)
|
||||
err = WebPushDeliveryError("gone", response=_FakeResponse(status_code))
|
||||
assert is_webpush_subscription_gone(err)
|
||||
|
||||
|
||||
def test_is_webpush_subscription_gone_for_other_errors() -> None:
|
||||
err = WebPushException("bad request")
|
||||
err.response = _FakeResponse(400)
|
||||
err = WebPushDeliveryError("bad request", response=_FakeResponse(400))
|
||||
assert not is_webpush_subscription_gone(err)
|
||||
|
||||
|
||||
|
||||
163
tests/test_webpush_sender.py
Normal file
163
tests/test_webpush_sender.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Web Push 协议发送测试:用模拟浏览器订阅解密投递内容,并校验 VAPID 签名与请求头。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import http_ece
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from py_vapid import Vapid
|
||||
|
||||
from app.adapters.network.webpush import WebPushDeliveryError, send_webpush
|
||||
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser():
|
||||
"""模拟浏览器 PushSubscription:接收方 P-256 密钥对与 16 字节 auth 密钥。"""
|
||||
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||
public_raw = private_key.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
auth = b"0123456789abcdef"
|
||||
subscription = {
|
||||
"endpoint": "https://push.example.com/send/abc?x=1",
|
||||
"keys": {"p256dh": _b64url(public_raw), "auth": _b64url(auth)},
|
||||
}
|
||||
return SimpleNamespace(private_key=private_key, auth=auth, subscription=subscription)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vapid():
|
||||
"""生成与宿主 VAPID 配置同格式(base64url 原始私钥)的服务端密钥。"""
|
||||
key = Vapid()
|
||||
key.generate_keys()
|
||||
raw = key.private_key.private_numbers().private_value.to_bytes(32, "big")
|
||||
return SimpleNamespace(private_key=_b64url(raw), public_key=key.public_key)
|
||||
|
||||
|
||||
def _post(status_code=201):
|
||||
"""拦截 RequestUtils 构造与 POST,记录请求头和正文。"""
|
||||
captured = {}
|
||||
|
||||
class _Utils:
|
||||
def __init__(self, headers=None, timeout=None, verify=None, **_):
|
||||
captured.update(headers=headers, timeout=timeout, verify=verify)
|
||||
|
||||
def post_res(self, url, data=None, **_):
|
||||
captured.update(url=url, body=data)
|
||||
return SimpleNamespace(status_code=status_code, reason="R", text="detail")
|
||||
|
||||
return captured, patch("app.adapters.network.webpush.RequestUtils", _Utils)
|
||||
|
||||
|
||||
def test_payload_decrypts_with_browser_keys_and_vapid_verifies(browser, vapid):
|
||||
"""浏览器私钥能解出原文;VAPID JWT 由服务端公钥签名,aud 为端点源站。"""
|
||||
captured, patcher = _post()
|
||||
payload = json.dumps({"title": "标题", "body": "内容"}, ensure_ascii=False)
|
||||
with patcher:
|
||||
send_webpush(browser.subscription, payload, vapid.private_key, "mailto:admin@example.com")
|
||||
|
||||
decrypted = http_ece.decrypt(
|
||||
captured["body"],
|
||||
private_key=browser.private_key,
|
||||
auth_secret=browser.auth,
|
||||
version="aes128gcm",
|
||||
)
|
||||
assert decrypted.decode("utf-8") == payload
|
||||
|
||||
headers = captured["headers"]
|
||||
assert headers["Content-Encoding"] == "aes128gcm"
|
||||
assert headers["TTL"] == "0"
|
||||
assert captured["url"] == browser.subscription["endpoint"]
|
||||
assert captured["verify"] is True
|
||||
|
||||
scheme, _, params = headers["Authorization"].partition(" ")
|
||||
assert scheme == "vapid"
|
||||
fields = dict(item.strip().split("=", 1) for item in params.split(","))
|
||||
public_pem = vapid.public_key.public_bytes(
|
||||
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
|
||||
)
|
||||
claims = jwt.decode(fields["t"], public_pem, algorithms=["ES256"], audience="https://push.example.com")
|
||||
assert claims["sub"] == "mailto:admin@example.com"
|
||||
assert time.time() < claims["exp"] <= time.time() + 24 * 3600
|
||||
assert base64.urlsafe_b64decode(fields["k"] + "==") == vapid.public_key.public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
|
||||
def test_each_send_uses_fresh_ephemeral_key(browser, vapid):
|
||||
"""同一内容两次加密结果不同(盐与临时 ECDH 公钥都随机),且都能解密。"""
|
||||
captured, patcher = _post()
|
||||
bodies = []
|
||||
with patcher:
|
||||
for _ in range(2):
|
||||
send_webpush(browser.subscription, "same", vapid.private_key, "mailto:a@b.c")
|
||||
bodies.append(captured["body"])
|
||||
assert bodies[0] != bodies[1]
|
||||
for body in bodies:
|
||||
assert http_ece.decrypt(body, private_key=browser.private_key,
|
||||
auth_secret=browser.auth, version="aes128gcm") == b"same"
|
||||
|
||||
|
||||
def test_endpoint_headers_and_ttl_are_forwarded(browser, vapid):
|
||||
"""WNS 等端点的附加请求头和 TTL 原样发送,VAPID 头不被调用方覆盖。"""
|
||||
captured, patcher = _post()
|
||||
with patcher:
|
||||
send_webpush(browser.subscription, "x", vapid.private_key, "mailto:a@b.c",
|
||||
ttl=86400, headers={"X-WNS-Cache-Policy": "cache", "Authorization": "fake"})
|
||||
assert captured["headers"]["X-WNS-Cache-Policy"] == "cache"
|
||||
assert captured["headers"]["TTL"] == "86400"
|
||||
assert captured["headers"]["Authorization"].startswith("vapid t=")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [404, 410, 413])
|
||||
def test_rejected_delivery_raises_with_response(browser, vapid, status_code):
|
||||
"""推送服务返回 202 以上状态码时抛错并带上响应,供调用方清理失效订阅。"""
|
||||
_, patcher = _post(status_code)
|
||||
with patcher, pytest.raises(WebPushDeliveryError) as exc:
|
||||
send_webpush(browser.subscription, "x", vapid.private_key, "mailto:a@b.c")
|
||||
assert exc.value.response.status_code == status_code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("keys", [{}, {"p256dh": "AAAA", "auth": "AAAA"}])
|
||||
def test_invalid_subscription_keys_are_rejected_before_request(vapid, keys):
|
||||
"""缺少或格式错误的订阅密钥在发请求前报错,且不带响应(不会被当成失效订阅删除)。"""
|
||||
captured, patcher = _post()
|
||||
with patcher, pytest.raises(WebPushDeliveryError) as exc:
|
||||
send_webpush({"endpoint": "https://push.example.com/x", "keys": keys}, "x",
|
||||
vapid.private_key, "mailto:a@b.c")
|
||||
assert exc.value.response is None
|
||||
assert "url" not in captured
|
||||
|
||||
|
||||
def test_no_response_is_reported(browser, vapid):
|
||||
"""网络失败(RequestUtils 返回 None)时抛错而不是静默成功。"""
|
||||
with patch("app.adapters.network.webpush.RequestUtils") as utils:
|
||||
utils.return_value.post_res.return_value = None
|
||||
with pytest.raises(WebPushDeliveryError):
|
||||
send_webpush(browser.subscription, "x", vapid.private_key, "mailto:a@b.c")
|
||||
|
||||
|
||||
def test_host_configured_vapid_key_signs(browser):
|
||||
"""宿主内置的 VAPID 私钥(base64url 原始格式)可以签出有效的 Authorization 头。"""
|
||||
from app.runtime.settings import get_runtime_setting
|
||||
|
||||
vapid_config = get_runtime_setting("VAPID")
|
||||
captured, patcher = _post()
|
||||
with patcher:
|
||||
send_webpush(browser.subscription, "x", vapid_config["privateKey"], vapid_config["subject"])
|
||||
fields = dict(
|
||||
item.strip().split("=", 1)
|
||||
for item in captured["headers"]["Authorization"].partition(" ")[2].split(",")
|
||||
)
|
||||
assert _b64url(base64.urlsafe_b64decode(fields["k"] + "==")) == vapid_config["publicKey"]
|
||||
@@ -78,13 +78,13 @@ def test_send_notification_reports_all_delivery_failures(monkeypatch):
|
||||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||||
)
|
||||
|
||||
from pywebpush import WebPushException
|
||||
from app.adapters.network.webpush import WebPushDeliveryError
|
||||
|
||||
def fail_delivery(**_):
|
||||
"""模拟 Web Push SDK 抛出发送异常。"""
|
||||
raise WebPushException("failed")
|
||||
"""模拟推送服务拒收。"""
|
||||
raise WebPushDeliveryError("failed")
|
||||
|
||||
monkeypatch.setattr("pywebpush.webpush", fail_delivery)
|
||||
monkeypatch.setattr("app.adapters.network.webpush.send_webpush", fail_delivery)
|
||||
|
||||
response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object())
|
||||
|
||||
@@ -102,9 +102,9 @@ def test_send_notification_reports_partial_delivery(monkeypatch):
|
||||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||||
)
|
||||
|
||||
from pywebpush import WebPushException
|
||||
from app.adapters.network.webpush import WebPushDeliveryError
|
||||
|
||||
attempts = iter([None, WebPushException("failed")])
|
||||
attempts = iter([None, WebPushDeliveryError("failed")])
|
||||
|
||||
def deliver_once(**_):
|
||||
"""模拟一次成功和一次失败的设备发送。"""
|
||||
@@ -113,7 +113,7 @@ def test_send_notification_reports_partial_delivery(monkeypatch):
|
||||
raise attempt
|
||||
return attempt
|
||||
|
||||
monkeypatch.setattr("pywebpush.webpush", deliver_once)
|
||||
monkeypatch.setattr("app.adapters.network.webpush.send_webpush", deliver_once)
|
||||
|
||||
response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object())
|
||||
|
||||
|
||||
4
uv.lock
generated
4
uv.lock
generated
@@ -1672,6 +1672,7 @@ dependencies = [
|
||||
{ name = "fast-bencode" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "google-genai" },
|
||||
{ name = "http-ece" },
|
||||
{ name = "httpx", extra = ["http2", "socks"] },
|
||||
{ name = "httpx2", extra = ["http2", "socks"] },
|
||||
{ name = "jinja2" },
|
||||
@@ -1698,6 +1699,7 @@ dependencies = [
|
||||
{ name = "pinyin2hanzi" },
|
||||
{ name = "plexapi" },
|
||||
{ name = "psutil" },
|
||||
{ name = "py-vapid" },
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
@@ -1791,6 +1793,7 @@ requires-dist = [
|
||||
{ name = "fast-bencode", specifier = "~=1.1.8" },
|
||||
{ name = "fastapi", specifier = "~=0.141.1" },
|
||||
{ name = "google-genai", specifier = "~=2.8.0" },
|
||||
{ name = "http-ece", specifier = "~=1.2.1" },
|
||||
{ name = "httpx", extras = ["http2", "socks"], specifier = "~=0.28.1" },
|
||||
{ name = "httpx2", extras = ["http2", "socks"], specifier = "~=2.12.0" },
|
||||
{ name = "jinja2", specifier = "~=3.1.6" },
|
||||
@@ -1817,6 +1820,7 @@ requires-dist = [
|
||||
{ name = "pinyin2hanzi", specifier = "~=0.1.1" },
|
||||
{ name = "plexapi", specifier = "~=4.18.1" },
|
||||
{ name = "psutil", specifier = "~=7.2.2" },
|
||||
{ name = "py-vapid", specifier = "~=1.9.4" },
|
||||
{ name = "pycryptodome", specifier = "~=3.23.0" },
|
||||
{ name = "pydantic", specifier = ">=2.13.4,<3.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.14.2,<3.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user