dwy-webhooks 0.1.1__tar.gz

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.
@@ -0,0 +1,44 @@
1
+ node_modules/
2
+ .build/
3
+ .build-ios/
4
+ .swiftpm/
5
+ dist/
6
+ private-dist/
7
+ .DS_Store
8
+ *.tsbuildinfo
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.pyc
13
+ *.egg-info/
14
+ build/
15
+ .venv/
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+
19
+ # Environment
20
+ .env
21
+ *.env
22
+ .superpowers/
23
+
24
+ # Keys
25
+ .key
26
+ /.npmrc
27
+
28
+ # Local Claude Code settings
29
+ .claude/settings.local.json
30
+ # dwy 本机缓存(sync-state.json 等同步状态,每机不同,不入库)
31
+ .dwy/
32
+ dwy-cli/.agents/
33
+ dwy-cli/.claude/
34
+ dwy-cli/.codex/
35
+ dwy-cli/.cursor/
36
+ dwy-cli/.opencode/
37
+ dwy-cli/AGENTS.md
38
+
39
+ # uv lock (library should not lock dependencies)
40
+ backend/uv.lock
41
+ .playwright-mcp/
42
+ .pentest-results/
43
+ .gstack/
44
+ .codegraph/
@@ -0,0 +1,14 @@
1
+ # dwy-webhooks
2
+
3
+ ## 0.1.1
4
+
5
+ - 首个公开 PyPI 版本,通过 GitHub Actions OIDC 发布。
6
+ - 显式声明 Ruff 的本包模块分类,使仓库根目录与包目录运行的导入排序检查一致。
7
+ - Discord 请求的 User-Agent 版本同步为 `0.1.1`;验签、注入与发送行为保持不变。
8
+
9
+ ## 0.1.0(未公开)
10
+
11
+ - 首次通过 GitHub Actions OIDC 发布到公开 PyPI,导入命名空间为 `dwy_webhooks`,基础依赖为 `dwyeapi==0.12.0`。
12
+ - 提供公开配置、FastAPI 注册入口、中立事件与可替换接收器;密钥、路由、目标地址和存储资源由调用项目注入。
13
+ - 将 Discord 发送、Resend 事件、App Store Connect HMAC 和 Apple Server Notifications V2 适配为可复用模块,包含验签、去重、失败语义和发送边界。
14
+ - `discord`、`resend`、`apple` SDK 分别通过 extras 安装;基础安装及公开入口导入不要求这些平台 SDK。
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: dwy-webhooks
3
+ Version: 0.1.1
4
+ Summary: 独立于业务来源的 Webhook 发送、安全边界与失败语义
5
+ Requires-Python: <3.13,>=3.11
6
+ Requires-Dist: dwyeapi==0.12.0
7
+ Provides-Extra: apple
8
+ Requires-Dist: app-store-server-library[async]==3.1.2; extra == 'apple'
9
+ Provides-Extra: dev
10
+ Requires-Dist: aiosqlite==0.22.1; extra == 'dev'
11
+ Requires-Dist: httpx==0.28.1; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio==1.3.0; extra == 'dev'
13
+ Requires-Dist: pytest==9.0.3; extra == 'dev'
14
+ Requires-Dist: ruff==0.15.12; extra == 'dev'
15
+ Provides-Extra: discord
16
+ Requires-Dist: discord-webhook[async]==1.4.1; extra == 'discord'
17
+ Requires-Dist: httpx==0.28.1; extra == 'discord'
18
+ Provides-Extra: resend
19
+ Requires-Dist: resend==2.49.1; extra == 'resend'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # dwy-webhooks
23
+
24
+ 基于 EAPI 的通用 Webhook 模块。接收 Resend、App Store Connect、Apple Server Notifications V2,并按项目配置转发到 Discord 或外部注入的业务接收函数。
25
+
26
+ ## 安装与依赖
27
+
28
+ 核心依赖固定兼容版本 `dwyeapi`;平台 SDK 按需选择 `dwy-webhooks[discord,resend,apple]`。ASC 使用标准库 HMAC;只有 Apple Server Notifications 需要 Apple SDK。源码由 dwy-shared 维护,项目安装独立 wheel,不引用共享仓库的绝对源码路径。发布配置不表示已经发布。
29
+
30
+ ## 配置与接入
31
+
32
+ 库不读取环境变量、`.env`、项目设置或证书文件。`WebhookConfig` 是纯 Pydantic 模型;项目可组合进自己的 EAPI Settings。密钥由项目取得后传入,Apple 根证书传入字节。
33
+
34
+ ```python
35
+ from dwy_webhooks import WebhookConfig, install_webhooks
36
+ from dwy_webhooks.config import ResendWebhookConfig
37
+
38
+ # app 与 project_settings 都属于调用项目;库不再回读环境。
39
+ config = WebhookConfig(
40
+ discord_webhook_url=project_settings.discord_webhook_url,
41
+ resend=ResendWebhookConfig(
42
+ secret=project_settings.resend_webhook_secret,
43
+ name="My Project", events=frozenset({"email.received", "email.failed"}),
44
+ ),
45
+ )
46
+ runtime = install_webhooks(app, config)
47
+ ```
48
+
49
+ Resend 使用 Webhook 签名密钥,不使用发送邮件的 API Key。未配置来源不注册路由;各来源可独立覆盖 Discord 目标、事件集合和接收路径。
50
+
51
+ - `AppStoreConnectWebhookConfig(apps={app_id: AppStoreConnectApp(name=..., secret=...)})` 配置审核、构建及 ping 通知。
52
+ - `AppleNotificationsConfig(apps={key: AppleNotificationApp(name=..., bundle_id=..., environment="Production", app_apple_id=..., root_certificates=(certificate_bytes, ...))})` 配置 Server Notifications V2。
53
+ - 将前两者放入 `WebhookConfig.app_store_connect`、`.apple_notifications` 后,一次注册即可接入。
54
+ - 默认路径:`/webhooks/resend`、`/webhooks/app-store-connect/{app_id}`、`/webhooks/apple/{app_id}`;完整公开 URL 由项目域名及反向代理决定。
55
+
56
+ ## 资源与接收语义
57
+
58
+ - 默认 `DiscordRelay` 在发送成功后确认,实例内保留最多 4096 条成功记录。失败返回非 2xx 供提供方重试。重启可能再次发送,这不是持久或恰好一次投递保证。
59
+ - 项目可注入 `ReceiptStore`,或通过 `WebhookSinks` 按来源注入接收函数。函数接收已验证的 `WebhookEvent`,返回 True 表示接受、False 表示重复;异常不能被确认成功。
60
+ - `SQLAlchemyDeliveryQueue` 提供持久队列,sessions、table、sender、source 和可选策略由外部注入。使用 EAPI 创建的会话,不创建、迁移或关闭项目数据库。一个表固定一个来源,防止跨来源编号碰撞。
61
+ - `build_delivery_table` 注册到项目 metadata,由项目自己的 Alembic 管理 schema。库不在注册路由时自动建表。
62
+ - `install_webhooks` 保留宿主生命周期,只关闭自己拥有的验证器;`create_app(config)` 可建立独立 ASGI 应用,配置仍然必填。普通错误复用 EAPI,第三方成功 ACK 保持提供方协议。
63
+
64
+ ```python
65
+ from dwy_webhooks import WebhookSinks, install_webhooks
66
+
67
+ # persist_verified_event 是项目自己的持久入口,或已注入资源的队列实例。
68
+ install_webhooks(app, config, sinks=WebhookSinks(app_store_connect=persist_verified_event))
69
+ ```
70
+
71
+ Apple 商品目录、账户绑定和权益发放保留在业务项目。通用模块只验证通知并提供安全事件元数据,不把测试通知或续订设置变更报告为收入。
72
+
73
+ ## 安全和扩展
74
+
75
+ 来源分别验签,并在读取原始字节时限制体积。Apple 外层、交易及续订 JWS 分别走官方 SDK,生产强制 App Apple ID。Discord 固定官方 HTTPS 地址,拒绝重定向和 mentions,等待有效消息编号;错误不记录 token 或原始响应正文。
76
+
77
+ 新增来源增加 provider,新增目标增加 transport;共用事件、sink 和 EAPI 资源,避免平台大分支。注册入口不覆盖项目已有的全局异常或缓存配置。
78
+
79
+ `examples/new_project/app.py` 是不依赖 App Cloud 的接入示例。隔离 wheel 验收要求 `app_cloud`、`apple_iap`、`cloud_kernel`、旧 `cloud_webhooks` 和产品包缺席,公共配置、注册和三类来源仍能通过测试。
@@ -0,0 +1,58 @@
1
+ # dwy-webhooks
2
+
3
+ 基于 EAPI 的通用 Webhook 模块。接收 Resend、App Store Connect、Apple Server Notifications V2,并按项目配置转发到 Discord 或外部注入的业务接收函数。
4
+
5
+ ## 安装与依赖
6
+
7
+ 核心依赖固定兼容版本 `dwyeapi`;平台 SDK 按需选择 `dwy-webhooks[discord,resend,apple]`。ASC 使用标准库 HMAC;只有 Apple Server Notifications 需要 Apple SDK。源码由 dwy-shared 维护,项目安装独立 wheel,不引用共享仓库的绝对源码路径。发布配置不表示已经发布。
8
+
9
+ ## 配置与接入
10
+
11
+ 库不读取环境变量、`.env`、项目设置或证书文件。`WebhookConfig` 是纯 Pydantic 模型;项目可组合进自己的 EAPI Settings。密钥由项目取得后传入,Apple 根证书传入字节。
12
+
13
+ ```python
14
+ from dwy_webhooks import WebhookConfig, install_webhooks
15
+ from dwy_webhooks.config import ResendWebhookConfig
16
+
17
+ # app 与 project_settings 都属于调用项目;库不再回读环境。
18
+ config = WebhookConfig(
19
+ discord_webhook_url=project_settings.discord_webhook_url,
20
+ resend=ResendWebhookConfig(
21
+ secret=project_settings.resend_webhook_secret,
22
+ name="My Project", events=frozenset({"email.received", "email.failed"}),
23
+ ),
24
+ )
25
+ runtime = install_webhooks(app, config)
26
+ ```
27
+
28
+ Resend 使用 Webhook 签名密钥,不使用发送邮件的 API Key。未配置来源不注册路由;各来源可独立覆盖 Discord 目标、事件集合和接收路径。
29
+
30
+ - `AppStoreConnectWebhookConfig(apps={app_id: AppStoreConnectApp(name=..., secret=...)})` 配置审核、构建及 ping 通知。
31
+ - `AppleNotificationsConfig(apps={key: AppleNotificationApp(name=..., bundle_id=..., environment="Production", app_apple_id=..., root_certificates=(certificate_bytes, ...))})` 配置 Server Notifications V2。
32
+ - 将前两者放入 `WebhookConfig.app_store_connect`、`.apple_notifications` 后,一次注册即可接入。
33
+ - 默认路径:`/webhooks/resend`、`/webhooks/app-store-connect/{app_id}`、`/webhooks/apple/{app_id}`;完整公开 URL 由项目域名及反向代理决定。
34
+
35
+ ## 资源与接收语义
36
+
37
+ - 默认 `DiscordRelay` 在发送成功后确认,实例内保留最多 4096 条成功记录。失败返回非 2xx 供提供方重试。重启可能再次发送,这不是持久或恰好一次投递保证。
38
+ - 项目可注入 `ReceiptStore`,或通过 `WebhookSinks` 按来源注入接收函数。函数接收已验证的 `WebhookEvent`,返回 True 表示接受、False 表示重复;异常不能被确认成功。
39
+ - `SQLAlchemyDeliveryQueue` 提供持久队列,sessions、table、sender、source 和可选策略由外部注入。使用 EAPI 创建的会话,不创建、迁移或关闭项目数据库。一个表固定一个来源,防止跨来源编号碰撞。
40
+ - `build_delivery_table` 注册到项目 metadata,由项目自己的 Alembic 管理 schema。库不在注册路由时自动建表。
41
+ - `install_webhooks` 保留宿主生命周期,只关闭自己拥有的验证器;`create_app(config)` 可建立独立 ASGI 应用,配置仍然必填。普通错误复用 EAPI,第三方成功 ACK 保持提供方协议。
42
+
43
+ ```python
44
+ from dwy_webhooks import WebhookSinks, install_webhooks
45
+
46
+ # persist_verified_event 是项目自己的持久入口,或已注入资源的队列实例。
47
+ install_webhooks(app, config, sinks=WebhookSinks(app_store_connect=persist_verified_event))
48
+ ```
49
+
50
+ Apple 商品目录、账户绑定和权益发放保留在业务项目。通用模块只验证通知并提供安全事件元数据,不把测试通知或续订设置变更报告为收入。
51
+
52
+ ## 安全和扩展
53
+
54
+ 来源分别验签,并在读取原始字节时限制体积。Apple 外层、交易及续订 JWS 分别走官方 SDK,生产强制 App Apple ID。Discord 固定官方 HTTPS 地址,拒绝重定向和 mentions,等待有效消息编号;错误不记录 token 或原始响应正文。
55
+
56
+ 新增来源增加 provider,新增目标增加 transport;共用事件、sink 和 EAPI 资源,避免平台大分支。注册入口不覆盖项目已有的全局异常或缓存配置。
57
+
58
+ `examples/new_project/app.py` 是不依赖 App Cloud 的接入示例。隔离 wheel 验收要求 `app_cloud`、`apple_iap`、`cloud_kernel`、旧 `cloud_webhooks` 和产品包缺席,公共配置、注册和三类来源仍能通过测试。
@@ -0,0 +1,22 @@
1
+ """独立项目示例:项目创建配置后注入,不依赖 App Cloud 的任何包。"""
2
+
3
+ from dwyeapi.exceptions import register_exception_handlers
4
+ from fastapi import FastAPI
5
+
6
+ from dwy_webhooks import WebhookConfig, WebhookSinks, install_webhooks
7
+ from dwy_webhooks.sinks import ReceiptStore
8
+
9
+
10
+ def create_project_app(config: WebhookConfig, *, sinks: WebhookSinks | None = None,
11
+ receipts: ReceiptStore | None = None) -> FastAPI:
12
+ """配置、密钥取得方式和可选资源均由调用项目决定。"""
13
+ app = FastAPI(title="Independent webhook consumer")
14
+ register_exception_handlers(app)
15
+
16
+ @app.get("/status")
17
+ def status():
18
+ """项目自己的端点用于确认集成不会占用整个应用。"""
19
+ return {"status": "ok"}
20
+
21
+ install_webhooks(app, config, sinks=sinks, receipts=receipts)
22
+ return app
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "dwy-webhooks"
3
+ version = "0.1.1"
4
+ description = "独立于业务来源的 Webhook 发送、安全边界与失败语义"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.13"
7
+ # EAPI 提供配置模型、Web 框架、数据库和日志基础,平台 SDK 按需安装。
8
+ dependencies = ["dwyeapi==0.12.0"]
9
+
10
+ [project.optional-dependencies]
11
+ # 沿用当前集成已验收版本;基础包导入不强制加载平台 SDK。
12
+ discord = ["discord-webhook[async]==1.4.1", "httpx==0.28.1"]
13
+ resend = ["resend==2.49.1"]
14
+ apple = ["app-store-server-library[async]==3.1.2"]
15
+ dev = ["pytest==9.0.3", "pytest-asyncio==1.3.0", "httpx==0.28.1", "aiosqlite==0.22.1", "ruff==0.15.12"]
16
+
17
+ [build-system]
18
+ requires = ["hatchling==1.27.0"]
19
+ build-backend = "hatchling.build"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/dwy_webhooks"]
23
+
24
+ [tool.pytest.ini_options]
25
+ asyncio_mode = "auto"
26
+ testpaths = ["tests"]
27
+
28
+ [tool.ruff]
29
+ target-version = "py311"
30
+ line-length = 110
31
+
32
+ [tool.ruff.lint]
33
+ select = ["E", "F", "I", "UP", "B"]
34
+
35
+ [tool.ruff.lint.isort]
36
+ known-first-party = ["dwy_webhooks"]
@@ -0,0 +1,21 @@
1
+ """基于 EAPI 的 Webhook 集成;配置由项目注入,平台 SDK 按需加载。"""
2
+
3
+ from .config import WebhookConfig
4
+ from .errors import WebhookDeliveryError
5
+ from .events import WebhookEvent
6
+ from .fastapi import WebhookRuntime, WebhookSinks, install_webhooks
7
+ from .validation import validate_discord_webhook_url
8
+
9
+ __all__ = [
10
+ "DiscordWebhookSender", "WebhookConfig", "WebhookDeliveryError", "WebhookEvent",
11
+ "WebhookRuntime", "WebhookSinks", "install_webhooks", "validate_discord_webhook_url",
12
+ ]
13
+
14
+
15
+ def __getattr__(name: str):
16
+ """核心导入不强制安装 Discord SDK;只有使用发送能力才加载该 extra。"""
17
+ if name == "DiscordWebhookSender":
18
+ from .discord import DiscordWebhookSender
19
+
20
+ return DiscordWebhookSender
21
+ raise AttributeError(name)
@@ -0,0 +1,126 @@
1
+ """外部注入的配置契约;不读取环境变量、dotenv 或任何项目设置。"""
2
+
3
+ import base64
4
+ import binascii
5
+ from itertools import combinations
6
+ from typing import Annotated, Literal
7
+
8
+ from pydantic import (
9
+ BaseModel,
10
+ BeforeValidator,
11
+ ConfigDict,
12
+ Field,
13
+ SecretStr,
14
+ field_validator,
15
+ model_validator,
16
+ )
17
+
18
+ from .validation import paths_overlap, validate_discord_webhook_url
19
+
20
+
21
+ def _destination(value):
22
+ """共享官方目标校验;空环境值关闭目标,错误不得回显凭据。"""
23
+ if value is None or value == "":
24
+ return None
25
+ raw = value.get_secret_value() if isinstance(value, SecretStr) else value
26
+ validate_discord_webhook_url(raw)
27
+ return value
28
+
29
+
30
+ Destination = Annotated[SecretStr | None, BeforeValidator(_destination)]
31
+ EventType = Annotated[str, Field(min_length=1, max_length=100, pattern=r"^[A-Za-z0-9_.-]+$")]
32
+ AppKey = Annotated[str, Field(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")]
33
+ ASCAppID = Annotated[str, Field(min_length=1, max_length=20, pattern=r"^[0-9]+$")]
34
+ LocalPath = Annotated[str, Field(max_length=256, pattern=r"^/(?:[A-Za-z0-9_-]+/)*[A-Za-z0-9_-]+$")]
35
+ ScopedPath = Annotated[str, Field(max_length=256, pattern=r"^/(?:[A-Za-z0-9_-]+/)+\{app_id\}$")]
36
+
37
+
38
+ class WebhookSourceConfig(BaseModel):
39
+ """已实际重复的来源选项:可覆盖发送目标,并按事件类型筛选。"""
40
+
41
+ model_config = ConfigDict(extra="forbid", frozen=True)
42
+ discord_webhook_url: Destination = Field(default=None, max_length=2048, repr=False)
43
+ # None 使用提供方适配器自己的已知事件集,避免配置与事件格式维护两份列表。
44
+ events: frozenset[EventType] | None = Field(default=None, max_length=100)
45
+
46
+
47
+ class ResendWebhookConfig(WebhookSourceConfig):
48
+ """Resend 原文签名配置,不需要发送邮件的 API Key。"""
49
+
50
+ secret: SecretStr = Field(min_length=1, max_length=512, repr=False)
51
+ name: str = Field(default="Resend", min_length=1, max_length=100)
52
+ scope: AppKey = "resend"
53
+ path: LocalPath = "/webhooks/resend"
54
+
55
+ @field_validator("secret")
56
+ @classmethod
57
+ def valid_secret(cls, value: SecretStr) -> SecretStr:
58
+ """验证 Svix 密钥编码,不接受格式错误后在请求阶段静默失败。"""
59
+ raw = value.get_secret_value()
60
+ try:
61
+ if not raw.startswith("whsec_") or len(base64.b64decode(raw[6:], validate=True)) < 16:
62
+ raise ValueError
63
+ except (ValueError, binascii.Error):
64
+ raise ValueError("Resend requires a valid signing secret") from None
65
+ return value
66
+
67
+
68
+ class AppStoreConnectApp(BaseModel):
69
+ """每个 App 的显示名称与独立 HMAC 密钥。"""
70
+
71
+ model_config = ConfigDict(extra="forbid", frozen=True)
72
+ name: str = Field(min_length=1, max_length=128)
73
+ secret: SecretStr = Field(min_length=32, max_length=512, repr=False)
74
+
75
+
76
+ class AppStoreConnectWebhookConfig(WebhookSourceConfig):
77
+ """ASC 审核/构建通知;App ID 与签名密钥固定由项目配置。"""
78
+
79
+ apps: dict[ASCAppID, AppStoreConnectApp] = Field(min_length=1, max_length=100)
80
+ path: ScopedPath = "/webhooks/app-store-connect/{app_id}"
81
+
82
+
83
+ class AppleNotificationApp(BaseModel):
84
+ """Server Notifications V2 身份及证书;不需要 App Cloud 商品目录。"""
85
+
86
+ model_config = ConfigDict(extra="forbid", frozen=True)
87
+ name: str = Field(default="Apple", min_length=1, max_length=100)
88
+ bundle_id: str = Field(min_length=1, max_length=255)
89
+ environment: Literal["Production", "Sandbox"] = "Production"
90
+ app_apple_id: int | None = Field(default=None, ge=1)
91
+ root_certificates: tuple[bytes, ...] = Field(min_length=1, max_length=8, repr=False)
92
+ enable_online_checks: bool = True
93
+ verification_workers: int = Field(default=2, ge=1, le=8)
94
+
95
+ @model_validator(mode="after")
96
+ def production_identity(self):
97
+ """生产环境必须同时校验 Apple App ID;不能退回 Sandbox 验证。"""
98
+ if self.environment == "Production" and self.app_apple_id is None:
99
+ raise ValueError("Production Apple notifications require app_apple_id")
100
+ return self
101
+
102
+
103
+ class AppleNotificationsConfig(WebhookSourceConfig):
104
+ """多个独立 App 的 Server Notifications 接入配置。"""
105
+
106
+ apps: dict[AppKey, AppleNotificationApp] = Field(min_length=1, max_length=100)
107
+ path: ScopedPath = "/webhooks/apple/{app_id}"
108
+
109
+
110
+ class WebhookConfig(BaseModel):
111
+ """由项目 Settings 显式组合;未配置来源不注册路由,不读取部署环境。"""
112
+
113
+ model_config = ConfigDict(extra="forbid", frozen=True)
114
+ discord_webhook_url: Destination = Field(default=None, max_length=2048, repr=False)
115
+ resend: ResendWebhookConfig | None = None
116
+ app_store_connect: AppStoreConnectWebhookConfig | None = None
117
+ apple_notifications: AppleNotificationsConfig | None = None
118
+
119
+ @model_validator(mode="after")
120
+ def unique_paths(self):
121
+ """显式配置不同来源时,不能让相同路径静默遮蔽另一验签协议。"""
122
+ paths = [source.path for source in
123
+ (self.resend, self.app_store_connect, self.apple_notifications) if source is not None]
124
+ if any(paths_overlap(left, right) for left, right in combinations(paths, 2)):
125
+ raise ValueError("Webhook source paths must not overlap")
126
+ return self
@@ -0,0 +1,153 @@
1
+ """外部会话、表与发送器驱动的持久投递;模块不连接或初始化项目数据库。"""
2
+
3
+ import asyncio
4
+ import math
5
+ import re
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from uuid import uuid4
9
+
10
+ from dwyeapi.logger import get_logger
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+ from sqlalchemy import JSON, BigInteger, Column, Integer, MetaData, String, Table, select, update
13
+ from sqlalchemy.dialects.postgresql import insert as pg_insert
14
+ from sqlalchemy.dialects.sqlite import insert as sqlite_insert
15
+
16
+ from .errors import WebhookDeliveryError
17
+ from .events import WebhookEvent
18
+
19
+
20
+ class DeliveryPolicy(BaseModel):
21
+ """调用方可显式注入投递预算,缺省与已有 ASC 消费者行为一致。"""
22
+
23
+ model_config = ConfigDict(frozen=True, extra="forbid")
24
+ max_attempts: int = Field(default=8, ge=1, le=100)
25
+ batch_size: int = Field(default=25, ge=1, le=500)
26
+ lease_seconds: int = Field(default=60, ge=1, le=3600)
27
+ poll_seconds: float = Field(default=2, gt=0, le=3600, allow_inf_nan=False)
28
+ retry_cap_seconds: int = Field(default=3600, ge=1, le=86400)
29
+ provider_retry_cap_seconds: int = Field(default=86400, ge=1, le=604800)
30
+
31
+
32
+ def build_delivery_table(metadata: MetaData, name: str = "webhook_deliveries", *,
33
+ scope_length: int = 128) -> Table:
34
+ """仅构造表定义;调用项目通过自己的 Alembic 迁移管理实际 schema。
35
+
36
+ app_id 是来源内的隔离 scope;沿用已有字段名以兼容 ASC 账本。
37
+ 一个表归属一个来源,由调用方分别注入,不能把不同来源写进同一表。
38
+ """
39
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,62}", name) is None or not 1 <= scope_length <= 255:
40
+ raise ValueError("Invalid webhook delivery table definition")
41
+ return Table(
42
+ name, metadata,
43
+ Column("app_id", String(scope_length), primary_key=True),
44
+ Column("id", String(128), primary_key=True),
45
+ Column("event_type", String(128), nullable=False),
46
+ Column("payload", JSON, nullable=False),
47
+ Column("status", String(20), nullable=False),
48
+ Column("attempts", Integer, nullable=False, server_default="0"),
49
+ Column("next_attempt_at", BigInteger, nullable=False, server_default="0"),
50
+ Column("lease_until", BigInteger, nullable=False, server_default="0"),
51
+ Column("lease_token", String(36)),
52
+ Column("error_code", String(64)),
53
+ Column("message_id", String(128)),
54
+ )
55
+
56
+
57
+ class SQLAlchemyDeliveryQueue:
58
+ """每个来源拥有独立账本;发送在事务外,完成更新必须持有本轮租约。"""
59
+
60
+ def __init__(self, sessions, table: Table, sender: Callable[[str], Awaitable[str]], *,
61
+ source: str, policy: DeliveryPolicy | None = None,
62
+ clock: Callable[[], float] = time.time):
63
+ """资源全部由调用方注入;消费者不创建、迁移或关闭任何项目资源。"""
64
+ if re.fullmatch(r"[a-z][a-z0-9_.-]{0,63}", source) is None:
65
+ raise ValueError("Invalid webhook source")
66
+ self.sessions, self.table, self.sender = sessions, table, sender
67
+ self.source, self.clock = source, clock
68
+ self.policy = policy if policy is not None else DeliveryPolicy()
69
+
70
+ async def __call__(self, event: WebhookEvent) -> bool:
71
+ """接收器可直接作为 sink 注入;错误来源必须在持久化前拒绝。"""
72
+ if event.source != self.source:
73
+ raise ValueError("Webhook source does not match the delivery queue")
74
+ return await self.enqueue(event.scope, event.event_id, event.event_type, event.content)
75
+
76
+ async def enqueue(self, app_id: str, event_id: str, event_type: str, content: str) -> bool:
77
+ """原子去重和入队;返回后接收端才能向提供方确认成功。"""
78
+ table = self.table
79
+ async with self.sessions.begin() as db:
80
+ dialect = db.get_bind().dialect.name
81
+ factory = {"sqlite": sqlite_insert, "postgresql": pg_insert}.get(dialect)
82
+ if factory is None:
83
+ raise ValueError("Webhook queue requires PostgreSQL or SQLite")
84
+ result = await db.execute(factory(table).values(
85
+ app_id=app_id, id=event_id, event_type=event_type,
86
+ payload={"content": content}, status="pending",
87
+ ).on_conflict_do_nothing(index_elements=["app_id", "id"]))
88
+ return bool(result.rowcount)
89
+
90
+ async def process_pending(self) -> int:
91
+ """有界条件领取;进程崩溃后由其他消费者接管过期租约。"""
92
+ table, sent = self.table, 0
93
+ for _ in range(self.policy.batch_size):
94
+ now, token = int(self.clock()), str(uuid4())
95
+ ready = (table.c.status.in_(("pending", "retry", "processing")),
96
+ table.c.next_attempt_at <= now, table.c.lease_until <= now)
97
+ async with self.sessions.begin() as db:
98
+ row = (await db.execute(select(table).where(*ready).order_by(
99
+ table.c.next_attempt_at, table.c.app_id, table.c.id
100
+ ).limit(1))).mappings().first()
101
+ if row is None:
102
+ break
103
+ key = (table.c.app_id == row["app_id"], table.c.id == row["id"])
104
+ claimed = await db.execute(update(table).where(*key, *ready).values(
105
+ status="processing", lease_token=token, lease_until=now + self.policy.lease_seconds))
106
+ if not claimed.rowcount:
107
+ continue
108
+ row = dict(row)
109
+ values = await self._send(row)
110
+ async with self.sessions.begin() as db:
111
+ result = await db.execute(update(table).where(
112
+ *key, table.c.lease_token == token
113
+ ).values(**values, lease_token=None, lease_until=0))
114
+ sent += int(bool(result.rowcount) and values["status"] == "sent")
115
+ return sent
116
+
117
+ async def _send(self, row) -> dict:
118
+ failure = None
119
+ try:
120
+ message_id = await self.sender(row["payload"]["content"])
121
+ if not isinstance(message_id, str) or not 1 <= len(message_id) <= 128:
122
+ raise WebhookDeliveryError("webhook_invalid_response")
123
+ except WebhookDeliveryError as error:
124
+ failure = error
125
+ except Exception:
126
+ # 第三方 sender 的异常也不能泄露原文,且必须受重试次数约束。
127
+ failure = WebhookDeliveryError("webhook_delivery_error")
128
+ if failure is not None:
129
+ # 不持久化用户自定义 sender 可能抛出的地址或响应正文。
130
+ code = (failure.code if isinstance(failure.code, str)
131
+ and re.fullmatch(r"[a-z][a-z0-9_.-]{0,63}", failure.code) else "webhook_delivery_error")
132
+ delay = 0
133
+ if (not isinstance(failure.retry_after, bool) and isinstance(failure.retry_after, (int, float))
134
+ and math.isfinite(failure.retry_after) and failure.retry_after >= 0):
135
+ delay = min(math.ceil(failure.retry_after), self.policy.provider_retry_cap_seconds)
136
+ attempts = row["attempts"] + 1
137
+ return dict(
138
+ status="dead" if failure.permanent or attempts >= self.policy.max_attempts else "retry",
139
+ attempts=attempts,
140
+ next_attempt_at=int(self.clock()) + max(
141
+ delay, min(self.policy.retry_cap_seconds, 2 ** attempts)),
142
+ error_code=code,
143
+ )
144
+ return dict(status="sent", message_id=message_id, error_code=None)
145
+
146
+ async def run(self) -> None:
147
+ """由调用项目的 lifespan 启停;未知失败只记录固定错误码。"""
148
+ while True:
149
+ try:
150
+ await self.process_pending()
151
+ except Exception:
152
+ get_logger(__name__).error("Webhook processor failed code=WEBHOOK_PROCESSOR_FAILED")
153
+ await asyncio.sleep(self.policy.poll_seconds)
@@ -0,0 +1,100 @@
1
+ """Discord 单次传输;消息内容、验签和重试账本不属于传输层。"""
2
+
3
+ import logging
4
+ import re
5
+ from contextlib import asynccontextmanager
6
+ from math import isfinite
7
+
8
+ import httpx
9
+ from discord_webhook import AsyncDiscordWebhook
10
+
11
+ from .errors import WebhookDeliveryError
12
+ from .validation import validate_discord_webhook_url
13
+
14
+
15
+ class _WebhookCredentialsFilter(logging.Filter):
16
+ """渲染后移除 token,正常 HTTP 请求日志继续保留。"""
17
+
18
+ def filter(self, record: logging.LogRecord) -> bool:
19
+ """修改已渲染消息并清空 args,避免日志格式化再次插入原始 token。"""
20
+ message = record.getMessage()
21
+ redacted = re.sub(
22
+ r"(https://discord\.com/api/(?:v10/)?webhooks/[0-9]+/)[A-Za-z0-9_-]+",
23
+ r"\1[REDACTED]", message,
24
+ )
25
+ if redacted != message:
26
+ record.msg, record.args = redacted, ()
27
+ return True
28
+
29
+
30
+ # 常驻安装,避免并发请求在临时添加/移除过滤器时出现脱敏空窗。
31
+ logging.getLogger("httpx").addFilter(_WebhookCredentialsFilter())
32
+
33
+
34
+ def discord_client() -> httpx.AsyncClient:
35
+ """每次请求使用有界客户端,取消和异常时由上下文负责关闭。"""
36
+ return httpx.AsyncClient(
37
+ timeout=10, follow_redirects=False, headers={"User-Agent": "dwy-webhooks/0.1.1"}
38
+ )
39
+
40
+
41
+ class _DiscordWebhook(AsyncDiscordWebhook):
42
+ """复用成熟 SDK 的载荷和查询参数,只替换受控客户端。"""
43
+
44
+ @property
45
+ @asynccontextmanager
46
+ async def http_client(self):
47
+ """禁止跟随重定向,防止将凭据发送到另一地址。"""
48
+ async with discord_client() as client:
49
+ yield client
50
+
51
+
52
+ def _retry_after(response: httpx.Response) -> float | None:
53
+ """Discord 延迟单位为秒;畸形值交给调用方的默认退避。"""
54
+ try:
55
+ body = response.json()
56
+ except ValueError:
57
+ body = None
58
+ value = body.get("retry_after") if isinstance(body, dict) else None
59
+ if value is None:
60
+ value = response.headers.get("Retry-After")
61
+ if isinstance(value, bool):
62
+ return None
63
+ try:
64
+ seconds = float(value)
65
+ except (TypeError, ValueError, OverflowError):
66
+ return None
67
+ return seconds if isfinite(seconds) and seconds >= 0 else None
68
+
69
+
70
+ class DiscordWebhookSender:
71
+ """只发送一次并返回消息编号;不休眠、重发或保存领域状态。"""
72
+
73
+ def __init__(self, webhook_url: str):
74
+ """公共包独立使用时也必须校验目标;异常不回显传入 URL。"""
75
+ self._webhook_url = validate_discord_webhook_url(webhook_url)
76
+
77
+ async def __call__(self, content: str, *, suppress_embeds: bool = False) -> str:
78
+ """wait 确认实际消息;预览选项只映射 Discord 的原生消息标志。"""
79
+ webhook = _DiscordWebhook(
80
+ url=self._webhook_url, content=content, allowed_mentions={"parse": []},
81
+ flags=4 if suppress_embeds else None, rate_limit_retry=False, timeout=10, wait=True,
82
+ )
83
+ try:
84
+ # execute() 会记录原始错误正文;只复用 SDK 的单次请求入口。
85
+ response = await webhook.api_post_request()
86
+ except httpx.HTTPError:
87
+ raise WebhookDeliveryError("discord_network_error") from None
88
+ if response.status_code == 429:
89
+ raise WebhookDeliveryError("discord_rate_limited", retry_after=_retry_after(response))
90
+ if not 200 <= response.status_code < 300:
91
+ raise WebhookDeliveryError("discord_http_error", permanent=300 <= response.status_code < 500)
92
+ try:
93
+ body = response.json()
94
+ except ValueError:
95
+ raise WebhookDeliveryError("discord_invalid_response") from None
96
+ message_id = body.get("id") if isinstance(body, dict) else None
97
+ if (not isinstance(message_id, str) or not message_id.isascii()
98
+ or not message_id.isdigit() or len(message_id) > 128):
99
+ raise WebhookDeliveryError("discord_invalid_response")
100
+ return message_id