dwy-webhooks 0.1.1__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.
@@ -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)
dwy_webhooks/config.py ADDED
@@ -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
dwy_webhooks/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """沿用 EAPI 异常基础,只传递安全错误码与调度建议。"""
2
+
3
+ from dwyeapi.exceptions import AppError
4
+
5
+
6
+ class WebhookDeliveryError(AppError):
7
+ """发送失败;是否重试及如何持久化由调用方决定。"""
8
+
9
+ def __init__(self, code: str, *, retry_after: float | None = None, permanent: bool = False):
10
+ """retry_after 为有限非负秒数;生产发送器只使用固定错误码。"""
11
+ self.code, self.retry_after, self.permanent = code, retry_after, permanent
12
+ super().__init__(code, code=code)
13
+
14
+
15
+ class WebhookVerificationError(AppError):
16
+ """提供方验签失败的安全结果;原始 JWS 与 SDK 异常不得向外传播。"""
17
+
18
+ def __init__(self, code: str = "signature_invalid", *, status_code: int = 401):
19
+ """status_code 区分无效签名与可重试的验证服务暂时不可用。"""
20
+ self.code, self.status_code = code, status_code
21
+ super().__init__(code, code=code)
dwy_webhooks/events.py ADDED
@@ -0,0 +1,20 @@
1
+ """跨来源的已验证通知,不包含签名、凭据或原始邮件内容。"""
2
+
3
+ from collections.abc import Awaitable, Callable, Mapping
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class WebhookEvent:
9
+ """scope 隔离同一提供方的不同应用;data 仅携带适配器筛选后的元数据。"""
10
+
11
+ source: str
12
+ scope: str
13
+ event_id: str
14
+ event_type: str
15
+ content: str
16
+ data: Mapping[str, object] = field(default_factory=dict, repr=False)
17
+
18
+
19
+ # True 表示本次接受,False 表示已接受的重复;异常必须阻止 HTTP 成功确认。
20
+ EventSink = Callable[[WebhookEvent], Awaitable[bool]]
@@ -0,0 +1,117 @@
1
+ """FastAPI 一次注册入口;来源配置、去重与关闭生命周期都由模块装配。"""
2
+
3
+ from contextlib import AsyncExitStack, asynccontextmanager
4
+ from dataclasses import dataclass, field
5
+ from typing import Protocol
6
+
7
+ from dwyeapi.exceptions import register_exception_handlers
8
+ from fastapi import APIRouter, FastAPI
9
+
10
+ from .config import WebhookConfig, WebhookSourceConfig
11
+ from .events import EventSink
12
+ from .sinks import DiscordRelay, MemoryReceiptStore, ReceiptStore
13
+
14
+
15
+ class _CloseableVerifier(Protocol):
16
+ """只约束安装器实际需要的关闭能力,不引入来源业务模型。"""
17
+
18
+ async def aclose(self) -> None:
19
+ """等待验证资源安全退出。"""
20
+ ...
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class WebhookSinks:
25
+ """有自己账本的项目可按来源替换接收行为;缺省使用 DiscordRelay。"""
26
+
27
+ resend: EventSink | None = None
28
+ app_store_connect: EventSink | None = None
29
+ apple_notifications: EventSink | None = None
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class WebhookRuntime:
34
+ """属于当前 FastAPI 实例的集成资源,不包含宿主配置或数据库。"""
35
+
36
+ receipts: ReceiptStore
37
+ verifiers: list[_CloseableVerifier] = field(default_factory=list, repr=False)
38
+ closed: bool = False
39
+
40
+ async def aclose(self) -> None:
41
+ """幂等关闭全部验证器;即使某一关闭失败也继续释放其余资源。"""
42
+ if self.closed:
43
+ return
44
+ try:
45
+ async with AsyncExitStack() as stack:
46
+ for verifier in self.verifiers:
47
+ stack.push_async_callback(verifier.aclose)
48
+ finally:
49
+ self.closed = True
50
+
51
+
52
+ def _sink(source: WebhookSourceConfig, config: WebhookConfig,
53
+ override: EventSink | None, receipts: ReceiptStore) -> EventSink:
54
+ """自定义接收优先;自动转发必须显式配置可信 Discord 目标。"""
55
+ if override is not None:
56
+ return override
57
+ destination = source.discord_webhook_url or config.discord_webhook_url
58
+ if destination is None:
59
+ raise ValueError("Enabled webhook source requires a Discord target or a custom sink")
60
+ return DiscordRelay(destination.get_secret_value(), receipts)
61
+
62
+
63
+ def install_webhooks(app: FastAPI, config: WebhookConfig, *,
64
+ sinks: WebhookSinks | None = None,
65
+ receipts: ReceiptStore | None = None) -> WebhookRuntime:
66
+ """注册全部已配置来源;复用原宿主 lifespan,公开 URL 可由独立配置选择。"""
67
+ if hasattr(app.state, "dwy_webhooks"):
68
+ raise ValueError("Webhooks already installed on this application")
69
+ selected = sinks if sinks is not None else WebhookSinks()
70
+ store = receipts if receipts is not None else MemoryReceiptStore()
71
+ runtime = WebhookRuntime(store)
72
+ router = APIRouter()
73
+ # 先验证默认接收器,避免配置错误后留下已注册的半套路由。
74
+ resend_sink = (_sink(config.resend, config, selected.resend, store)
75
+ if config.resend is not None else None)
76
+ asc_sink = (_sink(config.app_store_connect, config, selected.app_store_connect, store)
77
+ if config.app_store_connect is not None else None)
78
+ apple_sink = (_sink(config.apple_notifications, config, selected.apple_notifications, store)
79
+ if config.apple_notifications is not None else None)
80
+ if config.resend is not None:
81
+ from .providers.resend import create_resend_router
82
+
83
+ router.include_router(create_resend_router(config.resend, resend_sink))
84
+ if config.app_store_connect is not None:
85
+ from .providers.app_store_connect import create_asc_router
86
+
87
+ router.include_router(create_asc_router(config.app_store_connect, asc_sink))
88
+ if config.apple_notifications is not None:
89
+ from .providers.apple import AppleNotificationVerifier, create_apple_router
90
+
91
+ verifiers = {key: AppleNotificationVerifier(application)
92
+ for key, application in config.apple_notifications.apps.items()}
93
+ runtime.verifiers.extend(verifiers.values())
94
+ router.include_router(create_apple_router(config.apple_notifications, apple_sink, verifiers))
95
+ previous_lifespan = app.router.lifespan_context
96
+
97
+ @asynccontextmanager
98
+ async def lifespan(application):
99
+ """原项目启动失败或关闭异常时,也释放当前集成自己的资源。"""
100
+ try:
101
+ async with previous_lifespan(application) as state:
102
+ yield state
103
+ finally:
104
+ await runtime.aclose()
105
+
106
+ app.include_router(router)
107
+ app.router.lifespan_context = lifespan
108
+ app.state.dwy_webhooks = runtime
109
+ return runtime
110
+
111
+
112
+ def create_app(config: WebhookConfig) -> FastAPI:
113
+ """显式配置的独立 ASGI 应用;配置读取与密钥获取属于调用项目。"""
114
+ app = FastAPI(title="Webhooks", docs_url=None, redoc_url=None, openapi_url=None)
115
+ register_exception_handlers(app)
116
+ install_webhooks(app, config)
117
+ return app
@@ -0,0 +1,29 @@
1
+ """来源适配器共用的 HTTP 边界,不导入宿主、数据库或其他来源模块。"""
2
+
3
+ from fastapi import HTTPException, Request
4
+
5
+ from ..errors import WebhookDeliveryError
6
+ from ..events import EventSink, WebhookEvent
7
+
8
+
9
+ async def read_payload(request: Request, max_bytes: int = 65536) -> bytes:
10
+ """读取期间限制实际字节,不信任可省略或伪造的 Content-Length。"""
11
+ raw = bytearray()
12
+ async for chunk in request.stream():
13
+ raw.extend(chunk)
14
+ if len(raw) > max_bytes:
15
+ raise HTTPException(413, "Webhook payload is too large")
16
+ return bytes(raw)
17
+
18
+
19
+ async def accept_event(sink: EventSink, event: WebhookEvent) -> dict[str, bool]:
20
+ """只有接收/转发成功才确认;失败不得写成成功去重或吞掉上游重试。"""
21
+ try:
22
+ accepted = await sink(event)
23
+ except WebhookDeliveryError as error:
24
+ message = ("Invalid notification acknowledgement" if error.code == "discord_invalid_response"
25
+ else "Notification delivery failed")
26
+ raise HTTPException(502, message) from None
27
+ if not isinstance(accepted, bool):
28
+ raise RuntimeError("Webhook sink must return an acceptance boolean")
29
+ return {"received": True, "duplicate": not accepted}
@@ -0,0 +1,139 @@
1
+ """App Store Connect 入站适配:按应用验原始字节 HMAC,不依赖宿主账本。"""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from datetime import datetime
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from pydantic import BaseModel, Field, ValidationError
10
+
11
+ from ..config import AppStoreConnectWebhookConfig
12
+ from ..events import EventSink, WebhookEvent
13
+ from . import accept_event, read_payload
14
+
15
+ EVENTS = {
16
+ "appStoreVersionAppVersionStateUpdated": ("版本审核状态", "appStoreVersions"),
17
+ "buildUploadStateUpdated": ("构建上传状态", "buildUploads"),
18
+ }
19
+ STATES = {
20
+ "PREPARE_FOR_SUBMISSION": "准备提交", "READY_FOR_REVIEW": "准备送审",
21
+ "WAITING_FOR_REVIEW": "等待审核", "IN_REVIEW": "审核中",
22
+ "WAITING_FOR_EXPORT_COMPLIANCE": "等待出口合规处理", "PENDING_CONTRACT": "等待协议处理",
23
+ "REJECTED": "审核被拒", "METADATA_REJECTED": "元数据被拒",
24
+ "DEVELOPER_REJECTED": "开发者撤回", "INVALID_BINARY": "构建无效",
25
+ "PENDING_DEVELOPER_RELEASE": "审核通过,等待开发者发布",
26
+ "PENDING_APPLE_RELEASE": "等待 Apple 发布", "PROCESSING_FOR_APP_STORE": "正在处理上架",
27
+ "READY_FOR_SALE": "已上架", "READY_FOR_DISTRIBUTION": "可供分发",
28
+ "REMOVED_FROM_SALE": "已下架", "DEVELOPER_REMOVED_FROM_SALE": "开发者已下架",
29
+ "REPLACED_WITH_NEW_VERSION": "已被新版本替代", "ACCEPTED": "上传已接收",
30
+ "PROCESSING": "上传处理中", "COMPLETE": "上传处理完成", "FAILED": "上传处理失败",
31
+ }
32
+
33
+
34
+ class EventAttributes(BaseModel):
35
+ """两类状态通知字段不同;时间和原状态并非总是存在。"""
36
+
37
+ newValue: str | None = Field(default=None, min_length=1, max_length=128)
38
+ oldValue: str | None = Field(default=None, min_length=1, max_length=128)
39
+ newState: str | None = Field(default=None, min_length=1, max_length=128)
40
+ timestamp: datetime | None = None
41
+
42
+
43
+ class Resource(BaseModel):
44
+ """外部 JSON:API 资源只取有界标识,不访问传入的 URL。"""
45
+
46
+ type: str = Field(min_length=1, max_length=128)
47
+ id: str = Field(min_length=1, max_length=128)
48
+
49
+
50
+ class Instance(BaseModel):
51
+ """事件关联的版本或构建上传资源。"""
52
+
53
+ data: Resource
54
+
55
+
56
+ class Relationships(BaseModel):
57
+ """仅选择版本/构建状态事件后才要求 instance 关联。"""
58
+
59
+ instance: Instance
60
+
61
+
62
+ class StatusEvent(Resource):
63
+ """验证已选择的状态事件,不要求未选通知携带同一结构。"""
64
+
65
+ version: int = Field(ge=1, le=100)
66
+ attributes: EventAttributes
67
+ relationships: Relationships
68
+
69
+
70
+ class Envelope(BaseModel):
71
+ """先验证公共事件头,再判断是否为 ping 或已选择的状态类型。"""
72
+
73
+ data: Resource
74
+
75
+
76
+ def message(app_id: str, name: str, event: StatusEvent) -> str:
77
+ """保留 Apple 原始状态;未知枚举可读,不猜测拒审原因或版本号。"""
78
+ attrs = event.attributes
79
+ state = attrs.newValue if event.type == "appStoreVersionAppVersionStateUpdated" else attrs.newState
80
+ if state is None or event.relationships.instance.data.type != EVENTS[event.type][1]:
81
+ raise ValueError("invalid status event")
82
+ lines = [f"App Store Connect · {EVENTS[event.type][0]}", f"应用:{name}",
83
+ f"状态:{STATES.get(state, state)} ({state})"]
84
+ if attrs.oldValue:
85
+ lines.append(f"原状态:{STATES.get(attrs.oldValue, attrs.oldValue)} ({attrs.oldValue})")
86
+ if attrs.timestamp:
87
+ lines.append(f"事件时间:{attrs.timestamp.isoformat()}")
88
+ lines.extend([f"记录编号:{event.relationships.instance.data.id}", f"通知编号:{event.id}",
89
+ f"后台:https://appstoreconnect.apple.com/apps/{app_id}/distribution"])
90
+ return "\n".join(lines)
91
+
92
+
93
+ def create_asc_router(config: AppStoreConnectWebhookConfig, sink: EventSink) -> APIRouter:
94
+ """按配置挂载应用独立密钥;接收语义由可替换的 sink 决定。"""
95
+ router = APIRouter()
96
+
97
+ @router.post(config.path, include_in_schema=False)
98
+ async def receive(app_id: str, request: Request):
99
+ """签名覆盖原始字节;只在下游确认接收后向 Apple 返回成功。"""
100
+ application = config.apps.get(app_id)
101
+ if application is None:
102
+ raise HTTPException(404, "Webhook is not configured")
103
+ raw = await read_payload(request)
104
+ digest = hmac.new(application.secret.get_secret_value().encode(), raw, hashlib.sha256).hexdigest()
105
+ supplied = request.headers.get("x-apple-signature", "")
106
+ if not supplied.isascii() or not hmac.compare_digest(supplied, "hmacsha256=" + digest):
107
+ raise HTTPException(401, "Invalid webhook signature")
108
+ try:
109
+ envelope = Envelope.model_validate_json(raw)
110
+ event_type, event_id = envelope.data.type, envelope.data.id
111
+ selected = config.events if config.events is not None else {*EVENTS, "webhookPingCreated"}
112
+ if event_type not in selected:
113
+ return {"received": True, "ignored": True}
114
+ if event_type == "webhookPingCreated":
115
+ content = "\n".join([
116
+ "App Store Connect · 官方连通测试", f"应用:{application.name}",
117
+ "说明:验证 Apple → Cloud → Discord;不代表审核状态变化",
118
+ f"通知编号:{event_id}",
119
+ f"后台:https://appstoreconnect.apple.com/apps/{app_id}/distribution",
120
+ ])
121
+ data = {"app_id": app_id}
122
+ elif event_type in EVENTS:
123
+ event = StatusEvent.model_validate(json.loads(raw)["data"])
124
+ content = message(app_id, application.name, event)
125
+ state = (event.attributes.newValue if event_type == "appStoreVersionAppVersionStateUpdated"
126
+ else event.attributes.newState)
127
+ data = {"app_id": app_id, "instance_id": event.relationships.instance.data.id, "state": state}
128
+ else:
129
+ # 已配置但尚无适配器的类型仍明确忽略,不伪造可处理事件。
130
+ return {"received": True, "ignored": True}
131
+ except (ValidationError, ValueError, UnicodeError):
132
+ raise HTTPException(400, "Invalid webhook event") from None
133
+ prepared = WebhookEvent(
134
+ source="app_store_connect", scope=app_id, event_id=event_id, event_type=event_type,
135
+ content=content, data=data,
136
+ )
137
+ return await accept_event(sink, prepared)
138
+
139
+ return router
@@ -0,0 +1,231 @@
1
+ """可移植的 App Store Server Notifications V2 接收与官方验签边界。"""
2
+
3
+ import asyncio
4
+ import json
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+
11
+ from ..config import AppleNotificationApp, AppleNotificationsConfig
12
+ from ..errors import WebhookVerificationError
13
+ from ..events import EventSink, WebhookEvent
14
+ from . import accept_event, read_payload
15
+
16
+
17
+ def _text(value, *, maximum: int = 255) -> str:
18
+ """约束已验签字段的结构,不把 SDK 的 None 或任意对象直接转成文本。"""
19
+ value = getattr(value, "value", value)
20
+ if not isinstance(value, str) or not 1 <= len(value) <= maximum:
21
+ raise ValueError("invalid notification field")
22
+ return value
23
+
24
+
25
+ def _date(milliseconds) -> datetime:
26
+ """Apple 时间戳为 UTC 毫秒,拒绝布尔值、缺失及溢出。"""
27
+ if isinstance(milliseconds, bool) or not isinstance(milliseconds, (int, float)):
28
+ raise ValueError("invalid notification date")
29
+ return datetime.fromtimestamp(milliseconds / 1000, UTC)
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class VerifiedAppleNotification:
34
+ """仅包含已验签的安全元数据,不保存 JWS、账户 token 或任意外部正文。"""
35
+
36
+ notification_id: str
37
+ bundle_id: str
38
+ environment: str
39
+ notification_type: str
40
+ signed_at: datetime
41
+ subtype: str | None = None
42
+ product_id: str | None = None
43
+ transaction_id: str | None = None
44
+
45
+ def public_data(self) -> dict[str, str | None]:
46
+ """向项目回调提供稳定字段;这些数据不会授予任何购买权益。"""
47
+ return {
48
+ "notification_id": self.notification_id, "bundle_id": self.bundle_id,
49
+ "environment": self.environment, "notification_type": self.notification_type,
50
+ "subtype": self.subtype, "signed_at": self.signed_at.isoformat(),
51
+ "product_id": self.product_id, "transaction_id": self.transaction_id,
52
+ }
53
+
54
+
55
+ class AppleNotificationVerifier:
56
+ """每个配置应用独占官方 verifier;签名线程与在途任务均有上限。"""
57
+
58
+ def __init__(self, config: AppleNotificationApp):
59
+ """生产必须声明 App Apple ID;根证书字节由调用项目显式注入。"""
60
+ if config.environment == "Production" and config.app_apple_id is None:
61
+ raise ValueError("Production Apple notifications require app_apple_id")
62
+ self.config = config
63
+ self._verifier = None
64
+ self._closed = False
65
+ self._executor = ThreadPoolExecutor(
66
+ max_workers=config.verification_workers, thread_name_prefix="webhook-apple-verify"
67
+ )
68
+ self._semaphore = asyncio.Semaphore(config.verification_workers)
69
+ self._pending: set[asyncio.Future] = set()
70
+
71
+ def _sdk(self):
72
+ """延迟构造 SDK,缺失能力和证书错误仅返回固定的不可用分类。"""
73
+ if self._closed:
74
+ raise WebhookVerificationError("apple_verifier_closed", status_code=503)
75
+ if self._verifier is None:
76
+ try:
77
+ from appstoreserverlibrary.models.Environment import Environment
78
+ from appstoreserverlibrary.signed_data_verifier import SignedDataVerifier
79
+
80
+ roots = list(self.config.root_certificates)
81
+ if not roots:
82
+ raise ValueError("missing roots")
83
+ self._verifier = SignedDataVerifier(
84
+ roots, self.config.enable_online_checks, Environment(self.config.environment),
85
+ self.config.bundle_id, self.config.app_apple_id,
86
+ )
87
+ except (ImportError, OSError, ValueError):
88
+ raise WebhookVerificationError("apple_verifier_unavailable", status_code=503) from None
89
+ return self._verifier
90
+
91
+ async def _verify(self, method: str, signed: str):
92
+ """取消等待不提前释放在途预算,防止慢在线检查堆积无限线程池任务。"""
93
+ if not isinstance(signed, str) or not 1 <= len(signed) <= 131072:
94
+ raise WebhookVerificationError("apple_signature_invalid")
95
+ verifier = self._sdk()
96
+ await self._semaphore.acquire()
97
+ if self._closed:
98
+ self._semaphore.release()
99
+ raise WebhookVerificationError("apple_verifier_closed", status_code=503)
100
+ future = asyncio.get_running_loop().run_in_executor(self._executor, getattr(verifier, method), signed)
101
+ self._pending.add(future)
102
+
103
+ def finished(done):
104
+ """实际签名工作完成才释放预算,并消费被取消调用者留下的异常。"""
105
+ self._pending.discard(done)
106
+ self._semaphore.release()
107
+ if not done.cancelled():
108
+ done.exception()
109
+
110
+ future.add_done_callback(finished)
111
+ try:
112
+ return await asyncio.shield(future)
113
+ except Exception as error:
114
+ from appstoreserverlibrary.signed_data_verifier import VerificationException, VerificationStatus
115
+
116
+ if (isinstance(error, VerificationException)
117
+ and error.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE):
118
+ raise WebhookVerificationError("apple_verification_unavailable", status_code=503) from None
119
+ raise WebhookVerificationError("apple_signature_invalid") from None
120
+
121
+ async def verify(self, signed_payload: str) -> VerifiedAppleNotification:
122
+ """外层通知和每个嵌套 JWS 分别验签,再检查交易关系与元数据结构。"""
123
+ notice = await self._verify("verify_and_decode_notification", signed_payload)
124
+ try:
125
+ data = notice.data
126
+ target = data or notice.summary or getattr(notice, "appData", None)
127
+ external = getattr(notice, "externalPurchaseToken", None)
128
+ if target is None:
129
+ target = external
130
+ if target is None:
131
+ raise ValueError("missing notification identity")
132
+ bundle_id = _text(target.bundleId)
133
+ if target is external:
134
+ environment = ("Sandbox" if (external.externalPurchaseId or "").startswith("SANDBOX")
135
+ else "Production")
136
+ else:
137
+ environment = _text(target.environment, maximum=32)
138
+ if bundle_id != self.config.bundle_id or environment != self.config.environment:
139
+ raise ValueError("notification identity mismatch")
140
+ if self.config.environment == "Production" and target.appAppleId != self.config.app_apple_id:
141
+ raise ValueError("notification application mismatch")
142
+ transaction = None
143
+ if data and data.signedTransactionInfo:
144
+ transaction = await self._verify(
145
+ "verify_and_decode_signed_transaction", data.signedTransactionInfo
146
+ )
147
+ if (_text(transaction.bundleId) != bundle_id
148
+ or _text(transaction.environment, maximum=32) != environment):
149
+ raise ValueError("transaction identity mismatch")
150
+ if data and data.signedRenewalInfo:
151
+ renewal = await self._verify("verify_and_decode_renewal_info", data.signedRenewalInfo)
152
+ if transaction:
153
+ original = _text(transaction.originalTransactionId, maximum=128)
154
+ if _text(renewal.originalTransactionId, maximum=128) != original:
155
+ raise ValueError("transaction renewal mismatch")
156
+ return VerifiedAppleNotification(
157
+ notification_id=_text(notice.notificationUUID, maximum=128), bundle_id=bundle_id,
158
+ environment=environment, notification_type=_text(notice.notificationType, maximum=100),
159
+ signed_at=_date(notice.signedDate),
160
+ subtype=_text(notice.subtype, maximum=100) if notice.subtype is not None else None,
161
+ product_id=_text(transaction.productId) if transaction is not None else None,
162
+ transaction_id=(_text(transaction.transactionId, maximum=128)
163
+ if transaction is not None else None),
164
+ )
165
+ except (AttributeError, TypeError, ValueError, OverflowError, OSError):
166
+ raise WebhookVerificationError("apple_notification_invalid") from None
167
+
168
+ async def aclose(self) -> None:
169
+ """关闭后拒绝新工作,等待有界签名任务退出并回收线程资源。"""
170
+ if self._closed:
171
+ return
172
+ self._closed = True
173
+ try:
174
+ if self._pending:
175
+ await asyncio.gather(*tuple(self._pending), return_exceptions=True)
176
+ finally:
177
+ self._executor.shutdown(wait=False, cancel_futures=True)
178
+
179
+
180
+ def _content(name: str, notice: VerifiedAppleNotification) -> str:
181
+ """仅描述签名事实,不把取消续订、共享授权或测试通知误称为收入。"""
182
+ event_type = notice.notification_type
183
+ if notice.subtype is not None:
184
+ event_type += f" / {notice.subtype}"
185
+ lines = [
186
+ f"{name} · Apple 内购状态通知", f"环境:{notice.environment}", f"事件:{event_type}",
187
+ f"通知编号:{notice.notification_id}",
188
+ f"Apple 签名时间:{notice.signed_at.isoformat(timespec='seconds')}",
189
+ ]
190
+ if notice.product_id is not None:
191
+ lines.append(f"商品:{notice.product_id}")
192
+ if notice.transaction_id is not None:
193
+ lines.append(f"交易编号:{notice.transaction_id}")
194
+ return "\n".join(lines)
195
+
196
+
197
+ def create_apple_router(
198
+ config: AppleNotificationsConfig, sink: EventSink, verifiers: dict[str, AppleNotificationVerifier],
199
+ ) -> APIRouter:
200
+ """安装来源路由;verifier 由注册器持有并在宿主关闭时回收。"""
201
+ router = APIRouter()
202
+
203
+ @router.post(config.path, include_in_schema=False)
204
+ async def receive(app_id: str, request: Request):
205
+ """先定位固定配置,再限流读取和完整验签,最后投递已选事件。"""
206
+ application = config.apps.get(app_id)
207
+ verifier = verifiers.get(app_id)
208
+ if application is None or verifier is None:
209
+ raise HTTPException(404, "Webhook is not configured")
210
+ raw = await read_payload(request, max_bytes=262144)
211
+ try:
212
+ body = json.loads(raw)
213
+ signed = body.get("signedPayload") if isinstance(body, dict) else None
214
+ if not isinstance(signed, str) or not 1 <= len(signed) <= 131072:
215
+ raise ValueError("invalid signed payload")
216
+ except (ValueError, UnicodeError):
217
+ raise HTTPException(400, "Invalid Apple notification") from None
218
+ try:
219
+ notice = await verifier.verify(signed)
220
+ except WebhookVerificationError as error:
221
+ raise HTTPException(error.status_code, "Apple notification verification failed") from None
222
+ if config.events is not None and notice.notification_type not in config.events:
223
+ return {"received": True, "ignored": True}
224
+ event = WebhookEvent(
225
+ source="apple_notifications", scope=app_id, event_id=notice.notification_id,
226
+ event_type=notice.notification_type, content=_content(application.name, notice),
227
+ data=notice.public_data(),
228
+ )
229
+ return await accept_event(sink, event)
230
+
231
+ return router
@@ -0,0 +1,68 @@
1
+ """Resend 入站适配:官方 SDK 验签后只交付事件元数据,不读取邮件正文。"""
2
+
3
+ import resend
4
+ from fastapi import APIRouter, HTTPException, Request
5
+
6
+ from ..config import ResendWebhookConfig
7
+ from ..events import EventSink, WebhookEvent
8
+ from . import accept_event, read_payload
9
+
10
+ # 未显式选择的事件不进入发送链;新事件文案仅在此来源文件扩展。
11
+ EVENT_LABELS = {
12
+ "email.received": "收到新邮件",
13
+ "email.sent": "邮件已发送",
14
+ "email.delivered": "邮件已送达",
15
+ "email.failed": "邮件发送失败",
16
+ "email.bounced": "邮件退信",
17
+ "email.complained": "邮件被标记为垃圾邮件",
18
+ "email.delivery_delayed": "邮件投递延迟",
19
+ "email.suppressed": "邮件被抑制发送",
20
+ "email.opened": "邮件被打开",
21
+ "email.clicked": "邮件链接被点击",
22
+ }
23
+
24
+
25
+ def create_resend_router(config: ResendWebhookConfig, sink: EventSink) -> APIRouter:
26
+ """独立项目只提供来源配置和接收接口,去重或持久化属于 sink。"""
27
+ router = APIRouter()
28
+
29
+ @router.post(config.path, include_in_schema=False)
30
+ async def receive(request: Request):
31
+ """先限制并验证原始载荷,再解析类型,最后确认下游接收结果。"""
32
+ raw = await read_payload(request)
33
+ event_id = request.headers.get("svix-id", "")
34
+ if not event_id or len(event_id) > 256:
35
+ raise HTTPException(401, "Invalid webhook signature")
36
+ try:
37
+ event = resend.Webhooks.verify({
38
+ "payload": raw.decode("utf-8"),
39
+ "headers": {
40
+ "id": event_id,
41
+ "timestamp": request.headers.get("svix-timestamp", ""),
42
+ "signature": request.headers.get("svix-signature", ""),
43
+ },
44
+ "webhook_secret": config.secret.get_secret_value(),
45
+ })
46
+ except (ValueError, UnicodeError):
47
+ raise HTTPException(401, "Invalid webhook signature") from None
48
+ if not isinstance(event, dict) or not isinstance(event.get("type"), str):
49
+ raise HTTPException(400, "Invalid webhook event")
50
+ event_type = event["type"]
51
+ selected = config.events if config.events is not None else EVENT_LABELS
52
+ if event_type not in selected:
53
+ return {"received": True, "ignored": True}
54
+ data = event.get("data")
55
+ if not isinstance(data, dict) or not isinstance(data.get("email_id"), str):
56
+ raise HTTPException(400, "Invalid email event")
57
+ email_id = data["email_id"][:128]
58
+ content = "\n".join([
59
+ f"{config.name} · {EVENT_LABELS.get(event_type, event_type)}",
60
+ f"事件:{event_type}", f"邮件编号:{email_id}", f"通知编号:{event_id}",
61
+ ])
62
+ prepared = WebhookEvent(
63
+ source="resend", scope=config.scope, event_id=event_id, event_type=event_type,
64
+ content=content, data={"email_id": email_id},
65
+ )
66
+ return await accept_event(sink, prepared)
67
+
68
+ return router
dwy_webhooks/sinks.py ADDED
@@ -0,0 +1,68 @@
1
+ """缺省同步转发及有界去重;需要持久接收的项目可以注入自己的 sink。"""
2
+
3
+ import asyncio
4
+ from collections import OrderedDict
5
+ from typing import Protocol
6
+
7
+ from .events import WebhookEvent
8
+
9
+ EventKey = tuple[str, str, str]
10
+
11
+
12
+ class ReceiptStore(Protocol):
13
+ """可替换的已送达记录;不能把尚未送达的消息提前记作成功。"""
14
+
15
+ async def contains(self, key: EventKey) -> bool:
16
+ """查询来源、应用、通知编号组成的完整隔离键。"""
17
+ ...
18
+
19
+ async def add(self, key: EventKey) -> None:
20
+ """仅在目标确认发送成功后记录。"""
21
+ ...
22
+
23
+
24
+ class MemoryReceiptStore:
25
+ """实例内有界 LRU;重启后上游重投可能再次发送,不承诺持久去重。"""
26
+
27
+ def __init__(self, capacity: int = 4096):
28
+ """限制内存增长;共享多进程去重需由调用方提供原子持久接收入口。"""
29
+ if not 1 <= capacity <= 100000:
30
+ raise ValueError("Receipt capacity must be between 1 and 100000")
31
+ self.capacity = capacity
32
+ self._items: OrderedDict[EventKey, None] = OrderedDict()
33
+
34
+ async def contains(self, key: EventKey) -> bool:
35
+ """不执行阻塞 I/O;由 relay 的锁保护检查到提交之间的竞态。"""
36
+ if key not in self._items:
37
+ return False
38
+ self._items.move_to_end(key)
39
+ return True
40
+
41
+ async def add(self, key: EventKey) -> None:
42
+ """淘汰最旧的成功记录,不保存签名、消息正文或密钥。"""
43
+ self._items[key] = None
44
+ self._items.move_to_end(key)
45
+ if len(self._items) > self.capacity:
46
+ self._items.popitem(last=False)
47
+
48
+
49
+ class DiscordRelay:
50
+ """发送成功才接受事件;网络故障由接收端返回失败供提供方重试。"""
51
+
52
+ def __init__(self, webhook_url: str, receipts: ReceiptStore | None = None):
53
+ """锁和去重状态属于当前集成,不存在跨项目的模块全局状态。"""
54
+ from .discord import DiscordWebhookSender
55
+
56
+ self.sender = DiscordWebhookSender(webhook_url)
57
+ self.receipts = receipts if receipts is not None else MemoryReceiptStore()
58
+ self._lock = asyncio.Lock()
59
+
60
+ async def __call__(self, event: WebhookEvent) -> bool:
61
+ """所有提供方按完整身份去重;失败和取消都不会提交已送达记录。"""
62
+ key = (event.source, event.scope, event.event_id)
63
+ async with self._lock:
64
+ if await self.receipts.contains(key):
65
+ return False
66
+ await self.sender(event.content, suppress_embeds=True)
67
+ await self.receipts.add(key)
68
+ return True
@@ -0,0 +1,20 @@
1
+ """不依赖平台 SDK 的目标校验,用于配置和可选发送器。"""
2
+
3
+ import re
4
+
5
+
6
+ def paths_overlap(left: str, right: str) -> bool:
7
+ """已校验路径仅有末尾 app_id 参数,静态与模板同形时必须拒绝。"""
8
+ first, second = left.split("/"), right.split("/")
9
+ return len(first) == len(second) and all(
10
+ a == b or a == "{app_id}" or b == "{app_id}" for a, b in zip(first, second, strict=True)
11
+ )
12
+
13
+
14
+ def validate_discord_webhook_url(value: str) -> str:
15
+ """只接受官方 HTTPS 地址,不回显无效输入中的凭据。"""
16
+ if not isinstance(value, str) or re.fullmatch(
17
+ r"https://discord\.com/api/(?:v10/)?webhooks/[0-9]+/[A-Za-z0-9_-]+", value
18
+ ) is None:
19
+ raise ValueError("discord_webhook_url requires an official Discord HTTPS webhook URL")
20
+ return value
@@ -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,16 @@
1
+ dwy_webhooks/__init__.py,sha256=ic2O8u9FilNpo32XCKEvldlsDJMQXIEhfa-szfiEyM0,795
2
+ dwy_webhooks/config.py,sha256=gIV5R1ktPwX_KVe4aEGAQhDWjmKkpWyxCdpajWq4pNs,5343
3
+ dwy_webhooks/delivery.py,sha256=e6YsJm2weCN5cAcE1ox8U2aJIcegj_xeopgw3rcyq7U,7994
4
+ dwy_webhooks/discord.py,sha256=KsPrb_5oVYV5Cy_z98kOlv72SCiDmHgsqI4UjmNdz60,4053
5
+ dwy_webhooks/errors.py,sha256=fPP2USVxIdmeDAYptMIu8xPXN71jtbhwzoPbu6hS5rU,963
6
+ dwy_webhooks/events.py,sha256=bznvA4fwbMP3GUjIkMcDaxBDybks67m2AEnrAwQA3Yg,662
7
+ dwy_webhooks/fastapi.py,sha256=jP3vk9BuutUbwa0QJW2ig-i-LPmJLiKSjVmc5qNvdgc,4997
8
+ dwy_webhooks/sinks.py,sha256=axhtFHMymo5fn5_wNg-AgyEEK82zULHdx8gEXM_vPjY,2669
9
+ dwy_webhooks/validation.py,sha256=9bXQ1EmnLbdqI--_72WhHuDHz3caJitDMTzVnfV1mi8,832
10
+ dwy_webhooks/providers/__init__.py,sha256=KaV4w7dP6iQBVJTqnqNJPBacfEOuwI9GNZaaYa7rYZk,1267
11
+ dwy_webhooks/providers/app_store_connect.py,sha256=hMJtiSyN-juq2lyz86-i7jB-KhM3NLIThhHIc9qc55A,6546
12
+ dwy_webhooks/providers/apple.py,sha256=sFa0UdtbKnaHXNG2-lw24uOFtoYfAVugw67wjhGcNAg,11401
13
+ dwy_webhooks/providers/resend.py,sha256=2Vx4QQYaIZ_XrnfFlZAuefCAIhToHQDSH6Ku_W3UBTg,3057
14
+ dwy_webhooks-0.1.1.dist-info/METADATA,sha256=Re-pU0GBzM2Ns-GWRMUBVbkVM4Ql65xFuzfOd6iSKGE,5138
15
+ dwy_webhooks-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
+ dwy_webhooks-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any