dwy-notify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwy_notify/__init__.py +6 -0
- dwy_notify/_logging.py +21 -0
- dwy_notify/_plugins.py +127 -0
- dwy_notify/_transport.py +58 -0
- dwy_notify/models.py +52 -0
- dwy_notify/notifier.py +83 -0
- dwy_notify-0.1.0.dist-info/METADATA +59 -0
- dwy_notify-0.1.0.dist-info/RECORD +9 -0
- dwy_notify-0.1.0.dist-info/WHEEL +4 -0
dwy_notify/__init__.py
ADDED
dwy_notify/_logging.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""只过滤当前通知工作线程的原始 HTTP 调试日志,不改变宿主日志级别。"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
|
|
6
|
+
notification_http_active: ContextVar[bool] = ContextVar("dwy_notify_http_active", default=False)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _NotificationHTTPFilter(logging.Filter):
|
|
10
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
11
|
+
return not notification_http_active.get()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# urllib3 会在 DEBUG 输出包含凭据的请求路径;过滤器只作用于通知的同步 HTTP 区间。
|
|
15
|
+
for _name in (
|
|
16
|
+
"urllib3.connectionpool",
|
|
17
|
+
"urllib3.util.retry",
|
|
18
|
+
"requests.packages.urllib3.connectionpool",
|
|
19
|
+
"requests.packages.urllib3.util.retry",
|
|
20
|
+
):
|
|
21
|
+
logging.getLogger(_name).addFilter(_NotificationHTTPFilter())
|
dwy_notify/_plugins.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""固定版本 Apprise 的窄适配;保留上游解析、分段和 Discord 消息构造。"""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import logging
|
|
5
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from apprise import AppriseAsset, NotifyFormat
|
|
9
|
+
from apprise.plugins import N_MGR
|
|
10
|
+
from apprise.plugins.discord import NotifyDiscord
|
|
11
|
+
from apprise.plugins.wecombot import NotifyWeComBot
|
|
12
|
+
|
|
13
|
+
from . import _transport
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _QuietLogger(logging.Logger):
|
|
17
|
+
def trace(self, *args, **kwargs):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 插件调试日志会包含 URL 和正文;只隔离本包子类,不能修改宿主的 logger。
|
|
22
|
+
_quiet_logger = _QuietLogger("dwy_notify.apprise", level=100)
|
|
23
|
+
_quiet_logger.propagate = False
|
|
24
|
+
_quiet_logger.addHandler(logging.NullHandler())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _Discord(NotifyDiscord):
|
|
28
|
+
logger = _quiet_logger
|
|
29
|
+
|
|
30
|
+
def _send(self, payload, attach=None, params=None, **kwargs):
|
|
31
|
+
if self.outcome.get("error_code"):
|
|
32
|
+
return False
|
|
33
|
+
payload.pop("allow_mentions", None)
|
|
34
|
+
payload["allowed_mentions"] = {"parse": []}
|
|
35
|
+
payload["flags"] = payload.get("flags", 0) | 4
|
|
36
|
+
response = _transport.send(
|
|
37
|
+
self,
|
|
38
|
+
f"{self.notify_url}/{self.webhook_id}/{self.webhook_token}",
|
|
39
|
+
payload,
|
|
40
|
+
params={**(params or {}), "wait": "true"},
|
|
41
|
+
)
|
|
42
|
+
if response is None:
|
|
43
|
+
return False
|
|
44
|
+
try:
|
|
45
|
+
data = response.json()
|
|
46
|
+
except ValueError:
|
|
47
|
+
data = None
|
|
48
|
+
message_id = data.get("id") if isinstance(data, dict) else None
|
|
49
|
+
if (
|
|
50
|
+
not isinstance(message_id, str)
|
|
51
|
+
or not 1 <= len(message_id) <= 128
|
|
52
|
+
or not message_id.isascii()
|
|
53
|
+
or not message_id.isdecimal()
|
|
54
|
+
):
|
|
55
|
+
self.outcome = {"error_code": "invalid_response"}
|
|
56
|
+
return False
|
|
57
|
+
self.outcome = {"message_id": message_id}
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _WeComBot(NotifyWeComBot):
|
|
62
|
+
logger = _quiet_logger
|
|
63
|
+
|
|
64
|
+
def send(self, body, title="", **kwargs):
|
|
65
|
+
response = _transport.send(
|
|
66
|
+
self,
|
|
67
|
+
self.api_url,
|
|
68
|
+
{"msgtype": "text", "text": {"content": f"{title}\r\n{body}" if title else body}},
|
|
69
|
+
)
|
|
70
|
+
if response is None:
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
data = response.json()
|
|
74
|
+
except ValueError:
|
|
75
|
+
data = None
|
|
76
|
+
if not isinstance(data, dict) or type(data.get("errcode")) is not int or data["errcode"] != 0:
|
|
77
|
+
self.outcome = {"error_code": "provider_error"}
|
|
78
|
+
return False
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def create_plugin(url: str, timeout: float):
|
|
83
|
+
parsed = urlsplit(url)
|
|
84
|
+
if parsed.scheme == "https" and parsed.hostname in {"discord.com", "discordapp.com"}:
|
|
85
|
+
parts = parsed.path.strip("/").split("/")
|
|
86
|
+
if len(parts) == 4 and parts[:2] == ["api", "webhooks"] and not parsed.username:
|
|
87
|
+
url = urlunsplit(("discord", parts[2], "/" + parts[3], parsed.query, ""))
|
|
88
|
+
parsed = urlsplit(url)
|
|
89
|
+
if parsed.scheme not in N_MGR:
|
|
90
|
+
raise ValueError("invalid_target")
|
|
91
|
+
base = N_MGR[parsed.scheme]
|
|
92
|
+
if base.__module__.split(".")[2] in {"fluxer", "revolt", "matrix", "sparkpost", "sendpulse"}:
|
|
93
|
+
# 这些固定版本插件有绕过 retry=0 的内部重试,不能兑现单次发送约定。
|
|
94
|
+
raise ValueError("unsupported_transport")
|
|
95
|
+
# 只接受上游基于 requests 的 HTTPS 渠道;SMTP、本地命令等不共享 HTTP 安全约束。
|
|
96
|
+
if getattr(importlib.import_module(base.__module__), "requests", None) is not requests:
|
|
97
|
+
raise ValueError("unsupported_transport")
|
|
98
|
+
protocols = base.secure_protocol
|
|
99
|
+
if isinstance(protocols, str):
|
|
100
|
+
protocols = (protocols,)
|
|
101
|
+
if not protocols or parsed.scheme not in protocols or not base.enabled:
|
|
102
|
+
raise ValueError("unsupported_transport")
|
|
103
|
+
plugin_type = {NotifyDiscord: _Discord, NotifyWeComBot: _WeComBot}.get(base)
|
|
104
|
+
if plugin_type is None:
|
|
105
|
+
plugin_type = type(f"_Safe{base.__name__}", (base,), {"logger": _quiet_logger})
|
|
106
|
+
try:
|
|
107
|
+
options = plugin_type.parse_url(url)
|
|
108
|
+
if options is None:
|
|
109
|
+
raise ValueError("invalid_target")
|
|
110
|
+
options.update(
|
|
111
|
+
verify=True,
|
|
112
|
+
redirect=False,
|
|
113
|
+
retry=0,
|
|
114
|
+
optional=False,
|
|
115
|
+
cto=timeout,
|
|
116
|
+
rto=timeout,
|
|
117
|
+
secure=True,
|
|
118
|
+
asset=AppriseAsset(secure_logging=True, http_redirects=False),
|
|
119
|
+
)
|
|
120
|
+
plugin = plugin_type(**options)
|
|
121
|
+
except (TypeError, ValueError, AttributeError):
|
|
122
|
+
raise ValueError("invalid_target") from None
|
|
123
|
+
if not plugin.enabled:
|
|
124
|
+
raise ValueError("invalid_target")
|
|
125
|
+
if isinstance(plugin, _Discord):
|
|
126
|
+
plugin.notify_format = NotifyFormat.TEXT
|
|
127
|
+
return plugin
|
dwy_notify/_transport.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Discord / 企业微信的单次 HTTP 边界,便于宿主测试替换。"""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def post(url: str, **kwargs: Any) -> requests.Response:
|
|
10
|
+
"""执行一次 HTTP POST;安全参数由包内适配器显式传入。"""
|
|
11
|
+
return requests.post(url, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def retry_after(response: requests.Response) -> float | None:
|
|
15
|
+
try:
|
|
16
|
+
value = response.json().get("retry_after", response.headers.get("Retry-After"))
|
|
17
|
+
except (ValueError, AttributeError):
|
|
18
|
+
value = response.headers.get("Retry-After")
|
|
19
|
+
if isinstance(value, bool):
|
|
20
|
+
return None
|
|
21
|
+
try:
|
|
22
|
+
seconds = float(value)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def send(plugin: Any, url: str, payload: dict, *, params: dict | None = None) -> requests.Response | None:
|
|
29
|
+
try:
|
|
30
|
+
response = post(
|
|
31
|
+
url,
|
|
32
|
+
json=payload,
|
|
33
|
+
params=params,
|
|
34
|
+
headers={"User-Agent": plugin.app_id},
|
|
35
|
+
verify=True,
|
|
36
|
+
timeout=plugin.socket_read_timeout,
|
|
37
|
+
allow_redirects=False,
|
|
38
|
+
)
|
|
39
|
+
except requests.Timeout:
|
|
40
|
+
plugin.outcome = {"error_code": "timeout"}
|
|
41
|
+
return None
|
|
42
|
+
except requests.RequestException:
|
|
43
|
+
plugin.outcome = {"error_code": "network_error"}
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
if response.status_code == 429:
|
|
47
|
+
plugin.outcome = {"error_code": "rate_limited", "retry_after": retry_after(response)}
|
|
48
|
+
return None
|
|
49
|
+
if 300 <= response.status_code < 400:
|
|
50
|
+
plugin.outcome = {"error_code": "redirect_rejected", "permanent": True}
|
|
51
|
+
return None
|
|
52
|
+
if not 200 <= response.status_code < 300:
|
|
53
|
+
plugin.outcome = {
|
|
54
|
+
"error_code": "http_error",
|
|
55
|
+
"permanent": 400 <= response.status_code < 500 and response.status_code != 408,
|
|
56
|
+
}
|
|
57
|
+
return None
|
|
58
|
+
return response
|
dwy_notify/models.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""通知包拥有的配置与结果契约,不向调用方泄漏 Apprise 类型。"""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotificationConfig(BaseModel):
|
|
9
|
+
"""显式目标映射;URL 可含凭据,不应解密后写入日志或错误。"""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(frozen=True, extra="forbid", hide_input_in_errors=True)
|
|
12
|
+
|
|
13
|
+
targets: dict[str, SecretStr] = Field(min_length=1)
|
|
14
|
+
timeout_seconds: float = Field(default=10, gt=0, le=30, strict=True, allow_inf_nan=False)
|
|
15
|
+
|
|
16
|
+
@field_validator("targets")
|
|
17
|
+
@classmethod
|
|
18
|
+
def validate_targets(cls, targets: dict[str, SecretStr]) -> dict[str, SecretStr]:
|
|
19
|
+
if any(not name.strip() or not url.get_secret_value().strip() for name, url in targets.items()):
|
|
20
|
+
raise ValueError("empty_target")
|
|
21
|
+
return targets
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class NotificationDelivery:
|
|
26
|
+
"""单个命名目标的结果;错误码固定且不含第三方响应或请求内容。"""
|
|
27
|
+
|
|
28
|
+
target: str
|
|
29
|
+
success: bool
|
|
30
|
+
message_id: str | None = None
|
|
31
|
+
error_code: str | None = None
|
|
32
|
+
retry_after: float | None = None
|
|
33
|
+
permanent: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class NotificationResult:
|
|
38
|
+
"""按请求目标顺序保留结果;failed 可直接作为下次 send 的 targets。"""
|
|
39
|
+
|
|
40
|
+
deliveries: tuple[NotificationDelivery, ...]
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def success(self) -> bool:
|
|
44
|
+
return bool(self.deliveries) and all(delivery.success for delivery in self.deliveries)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def succeeded(self) -> tuple[str, ...]:
|
|
48
|
+
return tuple(delivery.target for delivery in self.deliveries if delivery.success)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def failed(self) -> tuple[str, ...]:
|
|
52
|
+
return tuple(delivery.target for delivery in self.deliveries if not delivery.success)
|
dwy_notify/notifier.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""同步 SDK 在线程中执行,锁只覆盖同一实例内的同一个目标。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from ._logging import notification_http_active
|
|
11
|
+
from ._plugins import create_plugin
|
|
12
|
+
from .models import NotificationConfig, NotificationDelivery, NotificationResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Notifier:
|
|
16
|
+
"""显式配置的异步通知器;不读环境变量、不重试、不记录 URL 或正文。
|
|
17
|
+
|
|
18
|
+
取消等待会向调用方传播;已开始的同步请求由超时结束,同目标锁直到请求结束才释放。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: NotificationConfig):
|
|
22
|
+
"""固定目标与安全配置;无效 URL 只抛出固定错误,不回显凭据。"""
|
|
23
|
+
self._plugins = {
|
|
24
|
+
name: create_plugin(url.get_secret_value(), config.timeout_seconds)
|
|
25
|
+
for name, url in config.targets.items()
|
|
26
|
+
}
|
|
27
|
+
self._fingerprints = {
|
|
28
|
+
name: hashlib.sha256(url.get_secret_value().encode()).hexdigest()
|
|
29
|
+
for name, url in config.targets.items()
|
|
30
|
+
}
|
|
31
|
+
self._locks = {name: threading.Lock() for name in self._plugins}
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def targets(self) -> tuple[str, ...]:
|
|
35
|
+
return tuple(self._plugins)
|
|
36
|
+
|
|
37
|
+
def target_fingerprint(self, name: str) -> str:
|
|
38
|
+
"""返回目标 URL 的 SHA-256 摘要,用于队列检测接收方配置变更。"""
|
|
39
|
+
return self._fingerprints[name]
|
|
40
|
+
|
|
41
|
+
async def send(
|
|
42
|
+
self, body: str, *, title: str = "", targets: Sequence[str] | None = None
|
|
43
|
+
) -> NotificationResult:
|
|
44
|
+
"""发送到全部或指定目标;结果保留顺序,未知或重复目标在网络调用前报错。"""
|
|
45
|
+
if not isinstance(body, str) or not body.strip():
|
|
46
|
+
raise ValueError("invalid_body")
|
|
47
|
+
if not isinstance(title, str):
|
|
48
|
+
raise ValueError("invalid_title")
|
|
49
|
+
if targets is not None and (isinstance(targets, (str, bytes)) or not targets):
|
|
50
|
+
raise ValueError("invalid_targets")
|
|
51
|
+
selected = self.targets if targets is None else tuple(targets)
|
|
52
|
+
if any(not isinstance(name, str) for name in selected):
|
|
53
|
+
raise ValueError("invalid_targets")
|
|
54
|
+
if any(name not in self._plugins for name in selected):
|
|
55
|
+
raise ValueError("unknown_target")
|
|
56
|
+
if len(selected) != len(set(selected)):
|
|
57
|
+
raise ValueError("duplicate_target")
|
|
58
|
+
deliveries = await asyncio.gather(
|
|
59
|
+
*(asyncio.to_thread(self._send, name, body, title) for name in selected)
|
|
60
|
+
)
|
|
61
|
+
return NotificationResult(deliveries=tuple(deliveries))
|
|
62
|
+
|
|
63
|
+
def _send(self, target: str, body: str, title: str) -> NotificationDelivery:
|
|
64
|
+
with self._locks[target]:
|
|
65
|
+
plugin = self._plugins[target]
|
|
66
|
+
plugin.outcome = {}
|
|
67
|
+
token = notification_http_active.set(True)
|
|
68
|
+
try:
|
|
69
|
+
success = bool(plugin.notify(body=body, title=title))
|
|
70
|
+
except requests.Timeout:
|
|
71
|
+
return NotificationDelivery(target=target, success=False, error_code="timeout")
|
|
72
|
+
except requests.RequestException:
|
|
73
|
+
return NotificationDelivery(target=target, success=False, error_code="network_error")
|
|
74
|
+
except Exception:
|
|
75
|
+
# 第三方插件可能把响应正文放入异常;在 SDK 边界只返回固定错误码。
|
|
76
|
+
return NotificationDelivery(target=target, success=False, error_code="provider_error")
|
|
77
|
+
finally:
|
|
78
|
+
notification_http_active.reset(token)
|
|
79
|
+
return NotificationDelivery(
|
|
80
|
+
target=target,
|
|
81
|
+
success=success,
|
|
82
|
+
**(plugin.outcome if plugin.outcome else {} if success else {"error_code": "send_failed"}),
|
|
83
|
+
)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dwy-notify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 基于 Apprise 的独立多目标通知与安全发送边界
|
|
5
|
+
Requires-Python: <3.13,>=3.11
|
|
6
|
+
Requires-Dist: apprise==2.1.0
|
|
7
|
+
Requires-Dist: dwyeapi==0.12.1
|
|
8
|
+
Requires-Dist: requests==2.34.2
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest-asyncio==1.3.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest==9.0.3; extra == 'dev'
|
|
12
|
+
Requires-Dist: ruff==0.15.12; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# dwy-notify
|
|
16
|
+
|
|
17
|
+
独立多目标异步通知包,基于 `apprise==2.1.0`,公共配置和结果类型由本包拥有。依赖 `dwyeapi==0.12.1` 基础规范,不要求运行 Webhook 服务。
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install dwy-notify==0.1.0
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import asyncio
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from dwy_notify import NotificationConfig, Notifier
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def main() -> None:
|
|
31
|
+
notifier = Notifier(NotificationConfig(targets={
|
|
32
|
+
"discord": os.environ["DISCORD_NOTIFY_URL"],
|
|
33
|
+
"wecom": os.environ["WECOM_NOTIFY_URL"],
|
|
34
|
+
}))
|
|
35
|
+
result = await notifier.send("任务完成", title="每日汇总")
|
|
36
|
+
for delivery in result.deliveries:
|
|
37
|
+
if not delivery.success:
|
|
38
|
+
print(delivery.target, delivery.error_code, delivery.retry_after, delivery.permanent)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
asyncio.run(main())
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`DISCORD_NOTIFY_URL` 来自宿主环境,可用 `discord://WEBHOOK_ID/WEBHOOK_TOKEN` 或 `https://discord.com/api/webhooks/WEBHOOK_ID/WEBHOOK_TOKEN`。`WECOM_NOTIFY_URL` 使用 `wecombot://WEBHOOK_KEY`。凭据应来自环境变量或秘密存储,不写入版本库。配置使用 `SecretStr` 隐藏 URL;调用方不要解密后记录。
|
|
45
|
+
|
|
46
|
+
`NotificationConfig(targets=..., timeout_seconds=10)` 为 frozen Pydantic 模型,目标映射非空,超时必须为有限正数且不超过 30 秒,不接受布尔值。`Notifier` 在构造时固定目标快照,不读取环境变量。`targets` 返回目标名元组,`target_fingerprint(name)` 返回 URL 的 SHA-256 摘要,供调用方检测配置变更;摘要不包含明文 URL。
|
|
47
|
+
|
|
48
|
+
`await notifier.send(body, title="", targets=None)` 默认发送全部目标,返回 `NotificationResult`。`success` 仅在至少一个目标且全部成功时为真,`succeeded` / `failed` 是目标名元组,详情位于 `deliveries`。每条 `NotificationDelivery` 包含 `target`、`success`、`message_id`、`error_code`、`retry_after`、`permanent`。正文必须为非空白字符串,标题必须为字符串,显式 targets 必须非空且不重复;无效输入或未知目标在发请求前抛出固定 `ValueError`。调用方应遵守 `retry_after` 并排除永久失败,再用失败目标列表重试。
|
|
49
|
+
|
|
50
|
+
安全边界:
|
|
51
|
+
|
|
52
|
+
- HTTP 渠道强制 TLS 校验、禁重定向、禁 SDK retry 和 optional 成功;覆盖 URL 中相反选项。普通渠道沿用 Apprise 的 HTTPS 协议和安全参数,不使用全局 monkeypatch。
|
|
53
|
+
- Discord 复用上游解析、消息构造和分段,仅适配单次 HTTP 发送:`allowed_mentions={"parse": []}`、始终设置 flags 的 `4` 位,并用 `wait=true` 确认真实消息 ID;响应 ID 必须为 1 至 128 位 ASCII 数字。需要分段时使用 Apprise 的 `?overflow=split`,分段遇失败停止后续请求。企业微信仅在 JSON `errcode` 为整数 `0` 时成功。
|
|
54
|
+
- Discord / 企业微信返回 HTTP 状态对应的安全错误码,429 的有效秒数放入 `retry_after`,408 与 5xx 可重试。普通渠道以 SDK 返回值判断成功,失败统一 `send_failed`,不暴露原始响应或异常。插件日志通过本包子类隔离;urllib3 原始 HTTP 日志仅在通知工作线程的发送区间被过滤,宿主其它线程、普通 HTTP 调用与应用日志保留。
|
|
55
|
+
- 支持 Apprise 基于 `requests` 的 HTTPS 渠道,例如 Discord、企业微信、钉钉、飞书和 `jsons` 自定义 HTTPS;不支持 SMTP、本地命令等非 HTTP 协议或明文协议,也拒绝固定版本中自带内部重试的 Fluxer、Revolt、Matrix、SparkPost、SendPulse,构造时返回 `unsupported_transport`。可选渠道的额外依赖仍需由宿主安装。
|
|
56
|
+
- 同一目标串行,不同目标可并发。取消等待会传播 `CancelledError`;已开始的同步请求在线程中继续,由连接/读取超时结束,同目标锁直到请求结束才释放。超时是 socket 超时,不是整条通知的硬截止时间;上游分段、多接收人或节流可能增加总耗时。
|
|
57
|
+
- `Notifier` 不提供持久化、调度或自动重试。分段通知中已经成功的部分可能在调用方整体重试时重复;HTTP 接收后丢失响应也无法保证恰好一次。
|
|
58
|
+
|
|
59
|
+
仅 Discord、企业微信、钉钉和飞书通过模拟 HTTP 验证;未进行真实渠道投递验收,也未逐一验收全部 Apprise 插件。发布必须通过 GitHub Actions OIDC,不使用长期发布 token。
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dwy_notify/__init__.py,sha256=GMHZf4uHmMYrc7XJcj4C4E4zVJOPBMm5k8lC3Y7VAxE,301
|
|
2
|
+
dwy_notify/_logging.py,sha256=K-m7T6EYSPSzly0BmByZ9NK3gG08sEvg2-Ebya7x9C4,750
|
|
3
|
+
dwy_notify/_plugins.py,sha256=FUNa-2QdcsGJPLNe0IDzCjOAR9M0yrufCQvREkUhBzg,4729
|
|
4
|
+
dwy_notify/_transport.py,sha256=URaeWwkoEoCg9QTojLv-wpBk9oWR3LkOWgv87I6DbTg,1931
|
|
5
|
+
dwy_notify/models.py,sha256=dqiqUlJEZpTKU1dKSNvwI2hhtKRMYRAz1oFjWiTZ6Eo,1835
|
|
6
|
+
dwy_notify/notifier.py,sha256=uSCn6IcB7Cu-CgyPPbvVLNGVY46XwGKkEwRxB8ILz4U,3775
|
|
7
|
+
dwy_notify-0.1.0.dist-info/METADATA,sha256=ZEOt01fC2For-i7pjT0jqmQFxjEL0q3GhxAhJaKLKA8,4780
|
|
8
|
+
dwy_notify-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
9
|
+
dwy_notify-0.1.0.dist-info/RECORD,,
|