roomkit 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.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Mock RCS provider for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from roomkit.models.event import RoomEvent
|
|
8
|
+
from roomkit.providers.rcs.base import RCSDeliveryResult, RCSProvider
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MockRCSProvider(RCSProvider):
|
|
12
|
+
"""Mock RCS provider for testing."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
sender_id: str = "mock_rcs_agent",
|
|
17
|
+
*,
|
|
18
|
+
simulate_fallback: bool = False,
|
|
19
|
+
simulate_failure: bool = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Initialize mock provider.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
sender_id: Mock sender/agent ID.
|
|
25
|
+
simulate_fallback: If True, simulate SMS fallback on sends.
|
|
26
|
+
simulate_failure: If True, simulate send failures.
|
|
27
|
+
"""
|
|
28
|
+
self._sender_id = sender_id
|
|
29
|
+
self._simulate_fallback = simulate_fallback
|
|
30
|
+
self._simulate_failure = simulate_failure
|
|
31
|
+
self.calls: list[dict[str, Any]] = []
|
|
32
|
+
self._message_counter = 0
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def sender_id(self) -> str:
|
|
36
|
+
return self._sender_id
|
|
37
|
+
|
|
38
|
+
async def send(
|
|
39
|
+
self,
|
|
40
|
+
event: RoomEvent,
|
|
41
|
+
to: str,
|
|
42
|
+
*,
|
|
43
|
+
fallback: bool = True,
|
|
44
|
+
) -> RCSDeliveryResult:
|
|
45
|
+
self._message_counter += 1
|
|
46
|
+
self.calls.append(
|
|
47
|
+
{
|
|
48
|
+
"event": event,
|
|
49
|
+
"to": to,
|
|
50
|
+
"fallback": fallback,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if self._simulate_failure:
|
|
55
|
+
return RCSDeliveryResult(
|
|
56
|
+
success=False,
|
|
57
|
+
error="simulated_failure",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Simulate fallback to SMS
|
|
61
|
+
if self._simulate_fallback and fallback:
|
|
62
|
+
return RCSDeliveryResult(
|
|
63
|
+
success=True,
|
|
64
|
+
provider_message_id=f"mock_sms_{self._message_counter}",
|
|
65
|
+
channel_used="sms",
|
|
66
|
+
fallback=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return RCSDeliveryResult(
|
|
70
|
+
success=True,
|
|
71
|
+
provider_message_id=f"mock_rcs_{self._message_counter}",
|
|
72
|
+
channel_used="rcs",
|
|
73
|
+
fallback=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
async def check_capability(self, phone_number: str) -> bool:
|
|
77
|
+
"""Mock capability check - returns opposite of simulate_fallback."""
|
|
78
|
+
return not self._simulate_fallback
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""SendGrid provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, SecretStr
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SendGridConfig(BaseModel):
|
|
9
|
+
"""SendGrid email provider configuration."""
|
|
10
|
+
|
|
11
|
+
api_key: SecretStr
|
|
12
|
+
from_email: str
|
|
13
|
+
from_name: str | None = None
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Sinch provider configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, SecretStr
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SinchConfig(BaseModel):
|
|
11
|
+
"""Sinch SMS provider configuration."""
|
|
12
|
+
|
|
13
|
+
service_plan_id: str
|
|
14
|
+
api_token: SecretStr
|
|
15
|
+
from_number: str
|
|
16
|
+
region: Literal["us", "eu", "au", "br", "ca"] = "us"
|
|
17
|
+
webhook_secret: SecretStr | None = None
|
|
18
|
+
timeout: float = 10.0
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def api_url(self) -> str:
|
|
22
|
+
return f"https://{self.region}.sms.api.sinch.com/xms/v1/{self.service_plan_id}/batches"
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Sinch SMS provider — sends SMS via the Sinch REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
from roomkit.models.delivery import InboundMessage, ProviderResult
|
|
11
|
+
from roomkit.models.event import RoomEvent
|
|
12
|
+
from roomkit.providers.sinch.config import SinchConfig
|
|
13
|
+
from roomkit.providers.sms.base import SMSProvider
|
|
14
|
+
from roomkit.providers.sms.meta import (
|
|
15
|
+
build_inbound_content,
|
|
16
|
+
extract_media_urls,
|
|
17
|
+
extract_text_body,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SinchSMSProvider(SMSProvider):
|
|
25
|
+
"""SMS provider using the Sinch REST API."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, config: SinchConfig) -> None:
|
|
28
|
+
try:
|
|
29
|
+
import httpx as _httpx
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"httpx is required for SinchSMSProvider. "
|
|
33
|
+
"Install it with: pip install roomkit[httpx]"
|
|
34
|
+
) from exc
|
|
35
|
+
self._config = config
|
|
36
|
+
self._httpx = _httpx
|
|
37
|
+
self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def from_number(self) -> str:
|
|
41
|
+
return self._config.from_number
|
|
42
|
+
|
|
43
|
+
async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
|
|
44
|
+
content = event.content
|
|
45
|
+
body = extract_text_body(content)
|
|
46
|
+
media_urls = extract_media_urls(content)
|
|
47
|
+
|
|
48
|
+
if not body and not media_urls:
|
|
49
|
+
return ProviderResult(success=False, error="empty_message")
|
|
50
|
+
|
|
51
|
+
from_number = from_ or self._config.from_number
|
|
52
|
+
|
|
53
|
+
headers = {
|
|
54
|
+
"Authorization": f"Bearer {self._config.api_token.get_secret_value()}",
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Sinch expects 'to' as an array
|
|
59
|
+
payload: dict[str, Any] = {
|
|
60
|
+
"from": from_number,
|
|
61
|
+
"to": [to],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if media_urls:
|
|
65
|
+
# Sinch MMS: type mt_media, body contains url and optional message
|
|
66
|
+
payload["type"] = "mt_media"
|
|
67
|
+
media_body: dict[str, str] = {"url": media_urls[0]}
|
|
68
|
+
if body:
|
|
69
|
+
media_body["message"] = body
|
|
70
|
+
payload["body"] = media_body
|
|
71
|
+
else:
|
|
72
|
+
payload["body"] = body
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
resp = await self._client.post(
|
|
76
|
+
self._config.api_url,
|
|
77
|
+
headers=headers,
|
|
78
|
+
json=payload,
|
|
79
|
+
)
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
response_data = resp.json()
|
|
82
|
+
except self._httpx.TimeoutException:
|
|
83
|
+
return ProviderResult(success=False, error="timeout")
|
|
84
|
+
except self._httpx.HTTPStatusError as exc:
|
|
85
|
+
status = exc.response.status_code
|
|
86
|
+
if status == 401:
|
|
87
|
+
return ProviderResult(success=False, error="auth_error")
|
|
88
|
+
if status == 429:
|
|
89
|
+
return ProviderResult(success=False, error="rate_limit")
|
|
90
|
+
if status == 400:
|
|
91
|
+
try:
|
|
92
|
+
error_data = exc.response.json()
|
|
93
|
+
error_code = error_data.get("code", "invalid_request")
|
|
94
|
+
return ProviderResult(
|
|
95
|
+
success=False,
|
|
96
|
+
error=f"sinch_{error_code}",
|
|
97
|
+
metadata={"message": error_data.get("text", "")},
|
|
98
|
+
)
|
|
99
|
+
except Exception:
|
|
100
|
+
return ProviderResult(success=False, error="invalid_request")
|
|
101
|
+
return ProviderResult(success=False, error=f"http_{status}")
|
|
102
|
+
except self._httpx.HTTPError as exc:
|
|
103
|
+
return ProviderResult(success=False, error=str(exc))
|
|
104
|
+
|
|
105
|
+
return ProviderResult(
|
|
106
|
+
success=True,
|
|
107
|
+
provider_message_id=response_data.get("id"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def verify_signature(
|
|
111
|
+
self,
|
|
112
|
+
payload: bytes,
|
|
113
|
+
signature: str,
|
|
114
|
+
timestamp: str | None = None,
|
|
115
|
+
) -> bool:
|
|
116
|
+
"""Verify a Sinch webhook signature using HMAC-SHA1.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
payload: Raw request body bytes (JSON).
|
|
120
|
+
signature: Value of the ``X-Sinch-Signature`` header.
|
|
121
|
+
timestamp: Not used by Sinch (included for interface compatibility).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if the signature is valid, False otherwise.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ValueError: If webhook_secret was not provided in config.
|
|
128
|
+
"""
|
|
129
|
+
if not self._config.webhook_secret:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"webhook_secret must be provided in SinchConfig for signature verification"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
expected_sig = base64.b64encode(
|
|
136
|
+
hmac.new(
|
|
137
|
+
self._config.webhook_secret.get_secret_value().encode(),
|
|
138
|
+
payload,
|
|
139
|
+
hashlib.sha1,
|
|
140
|
+
).digest()
|
|
141
|
+
).decode()
|
|
142
|
+
return hmac.compare_digest(expected_sig, signature)
|
|
143
|
+
except Exception:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
async def close(self) -> None:
|
|
147
|
+
await self._client.aclose()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_sinch_webhook(
|
|
151
|
+
payload: dict[str, Any],
|
|
152
|
+
channel_id: str,
|
|
153
|
+
) -> InboundMessage:
|
|
154
|
+
"""Convert a Sinch SMS webhook POST body into an InboundMessage.
|
|
155
|
+
|
|
156
|
+
Sinch inbound SMS webhook structure:
|
|
157
|
+
{
|
|
158
|
+
"id": "message-id",
|
|
159
|
+
"from": "+15551234567",
|
|
160
|
+
"to": "12345",
|
|
161
|
+
"body": "Message text",
|
|
162
|
+
"received_at": "2026-01-28T12:00:00.000Z",
|
|
163
|
+
"operator_id": "...",
|
|
164
|
+
"media": [{"url": "...", "mimeType": "image/jpeg"}],
|
|
165
|
+
...
|
|
166
|
+
}
|
|
167
|
+
"""
|
|
168
|
+
body = payload.get("body", "")
|
|
169
|
+
|
|
170
|
+
media: list[dict[str, str | None]] = []
|
|
171
|
+
for m in payload.get("media", []):
|
|
172
|
+
if url := m.get("url"):
|
|
173
|
+
media.append(
|
|
174
|
+
{
|
|
175
|
+
"url": url,
|
|
176
|
+
"mime_type": m.get("mimeType"),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return InboundMessage(
|
|
181
|
+
channel_id=channel_id,
|
|
182
|
+
sender_id=payload.get("from", ""),
|
|
183
|
+
content=build_inbound_content(body, media),
|
|
184
|
+
external_id=payload.get("id"),
|
|
185
|
+
idempotency_key=payload.get("id"),
|
|
186
|
+
metadata={
|
|
187
|
+
"to": payload.get("to", ""),
|
|
188
|
+
"received_at": payload.get("received_at"),
|
|
189
|
+
"operator_id": payload.get("operator_id"),
|
|
190
|
+
"client_reference": payload.get("client_reference"),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""SMS providers."""
|
|
2
|
+
|
|
3
|
+
from roomkit.providers.sms.base import SMSProvider
|
|
4
|
+
from roomkit.providers.sms.meta import WebhookMeta, extract_sms_meta
|
|
5
|
+
from roomkit.providers.sms.mock import MockSMSProvider
|
|
6
|
+
from roomkit.providers.sms.phone import is_valid_phone, normalize_phone
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MockSMSProvider",
|
|
10
|
+
"SMSProvider",
|
|
11
|
+
"WebhookMeta",
|
|
12
|
+
"extract_sms_meta",
|
|
13
|
+
"is_valid_phone",
|
|
14
|
+
"normalize_phone",
|
|
15
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Abstract base class for SMS providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from roomkit.models.delivery import InboundMessage, ProviderResult
|
|
9
|
+
from roomkit.models.event import RoomEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SMSProvider(ABC):
|
|
13
|
+
"""SMS delivery provider."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
"""Provider name (e.g. 'twilio', 'sinch')."""
|
|
18
|
+
return self.__class__.__name__
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def from_number(self) -> str:
|
|
23
|
+
"""Default sender phone number."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
|
|
28
|
+
"""Send an SMS message.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
event: The room event containing the message content.
|
|
32
|
+
to: Recipient phone number (E.164 format).
|
|
33
|
+
from_: Sender phone number override. Defaults to ``from_number``.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Result with provider-specific delivery metadata.
|
|
37
|
+
"""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
async def parse_webhook(self, payload: dict[str, Any]) -> InboundMessage:
|
|
41
|
+
"""Parse an inbound webhook payload into an InboundMessage."""
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def verify_signature(
|
|
45
|
+
self,
|
|
46
|
+
payload: bytes,
|
|
47
|
+
signature: str,
|
|
48
|
+
timestamp: str | None = None,
|
|
49
|
+
) -> bool:
|
|
50
|
+
"""Verify that a webhook payload was signed by the provider.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
payload: Raw request body bytes.
|
|
54
|
+
signature: Signature header value from the webhook request.
|
|
55
|
+
timestamp: Timestamp header value (required by some providers).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if the signature is valid, False otherwise.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
NotImplementedError: If the provider does not support signature
|
|
62
|
+
verification.
|
|
63
|
+
"""
|
|
64
|
+
raise NotImplementedError(f"{self.name} does not support webhook signature verification")
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None: # noqa: B027
|
|
67
|
+
"""Release resources. Override in subclasses that hold connections."""
|