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,374 @@
|
|
|
1
|
+
"""VoiceMeUp SMS provider — sends SMS via the VoiceMeUp REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from roomkit.models.delivery import InboundMessage, ProviderResult
|
|
13
|
+
from roomkit.models.event import RoomEvent
|
|
14
|
+
from roomkit.providers.sms.base import SMSProvider
|
|
15
|
+
from roomkit.providers.sms.meta import (
|
|
16
|
+
build_inbound_content,
|
|
17
|
+
extract_media_urls,
|
|
18
|
+
extract_text_body,
|
|
19
|
+
)
|
|
20
|
+
from roomkit.providers.voicemeup.config import VoiceMeUpConfig
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
_MAX_SEGMENT_LENGTH = 1000
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# MMS Aggregation — automatic handling of VoiceMeUp's split MMS webhooks
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
#
|
|
33
|
+
# VoiceMeUp sends MMS as two separate webhooks:
|
|
34
|
+
# 1. First: text message + .mms.html metadata wrapper (not real media)
|
|
35
|
+
# 2. Second: actual media attachment (no text)
|
|
36
|
+
#
|
|
37
|
+
# This module automatically buffers the first webhook and merges it with
|
|
38
|
+
# the second to produce a single InboundMessage with both text and media.
|
|
39
|
+
#
|
|
40
|
+
# Usage:
|
|
41
|
+
# message = parse_voicemeup_webhook(payload, channel_id="sms")
|
|
42
|
+
# if message:
|
|
43
|
+
# await kit.process_inbound(message)
|
|
44
|
+
# # If None, webhook was buffered (waiting for second part)
|
|
45
|
+
#
|
|
46
|
+
# Configure timeout callback (optional):
|
|
47
|
+
# configure_voicemeup_mms(
|
|
48
|
+
# timeout_seconds=5.0,
|
|
49
|
+
# on_timeout=my_handler, # Called with text-only message if image never arrives
|
|
50
|
+
# )
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class _PendingMMS:
|
|
56
|
+
"""Buffered first part of a split MMS webhook."""
|
|
57
|
+
|
|
58
|
+
payload: dict[str, Any]
|
|
59
|
+
text: str
|
|
60
|
+
timestamp: float
|
|
61
|
+
channel_id: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Module-level state for MMS aggregation
|
|
65
|
+
_mms_buffer: dict[str, _PendingMMS] = {}
|
|
66
|
+
_mms_timeout_seconds: float = 5.0
|
|
67
|
+
_mms_on_timeout: Callable[[InboundMessage], Awaitable[None] | None] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def configure_voicemeup_mms(
|
|
71
|
+
*,
|
|
72
|
+
timeout_seconds: float = 5.0,
|
|
73
|
+
on_timeout: Callable[[InboundMessage], Awaitable[None] | None] | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Configure VoiceMeUp MMS aggregation behavior.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
timeout_seconds: How long to wait for the second MMS part (default: 5.0)
|
|
79
|
+
on_timeout: Callback invoked with text-only message if image never arrives.
|
|
80
|
+
If not set, orphaned text messages are logged and discarded.
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
async def handle_orphaned_mms(message: InboundMessage) -> None:
|
|
84
|
+
await kit.process_inbound(message)
|
|
85
|
+
|
|
86
|
+
configure_voicemeup_mms(timeout_seconds=5.0, on_timeout=handle_orphaned_mms)
|
|
87
|
+
"""
|
|
88
|
+
global _mms_timeout_seconds, _mms_on_timeout
|
|
89
|
+
_mms_timeout_seconds = timeout_seconds
|
|
90
|
+
_mms_on_timeout = on_timeout
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_mms_metadata_wrapper(url: str | None, mime_type: str | None) -> bool:
|
|
94
|
+
"""Check if attachment is a VoiceMeUp MMS metadata wrapper (not real media)."""
|
|
95
|
+
if not url:
|
|
96
|
+
return False
|
|
97
|
+
if url.endswith(".mms.html"):
|
|
98
|
+
return True
|
|
99
|
+
return mime_type == "text/html" and ".mms." in url
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_correlation_key(payload: dict[str, Any]) -> str:
|
|
103
|
+
"""Create a key to correlate split MMS webhooks."""
|
|
104
|
+
return (
|
|
105
|
+
f"{payload.get('source_number', '')}:"
|
|
106
|
+
f"{payload.get('destination_number', '')}:"
|
|
107
|
+
f"{payload.get('datetime_transmission', '')}"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _handle_mms_timeout(key: str) -> None:
|
|
112
|
+
"""Emit buffered message as text-only if timeout expires."""
|
|
113
|
+
await asyncio.sleep(_mms_timeout_seconds)
|
|
114
|
+
|
|
115
|
+
if key not in _mms_buffer:
|
|
116
|
+
return # Already merged, nothing to do
|
|
117
|
+
|
|
118
|
+
pending = _mms_buffer.pop(key)
|
|
119
|
+
|
|
120
|
+
# Create text-only message (no media since .mms.html is useless)
|
|
121
|
+
payload_copy = dict(pending.payload)
|
|
122
|
+
payload_copy.pop("attachment", None)
|
|
123
|
+
payload_copy.pop("attachment_url", None)
|
|
124
|
+
payload_copy.pop("attachment_mime_type", None)
|
|
125
|
+
payload_copy.pop("attachment_type", None)
|
|
126
|
+
|
|
127
|
+
message = _build_inbound_message(payload_copy, pending.channel_id)
|
|
128
|
+
|
|
129
|
+
if _mms_on_timeout:
|
|
130
|
+
logger.debug("VoiceMeUp MMS timeout: invoking on_timeout callback for %s", key)
|
|
131
|
+
result = _mms_on_timeout(message)
|
|
132
|
+
if asyncio.iscoroutine(result):
|
|
133
|
+
await result
|
|
134
|
+
else:
|
|
135
|
+
logger.warning(
|
|
136
|
+
"VoiceMeUp MMS timeout: discarding orphaned text message from %s "
|
|
137
|
+
"(configure on_timeout to handle this)",
|
|
138
|
+
pending.payload.get("source_number"),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _build_inbound_message(payload: dict[str, Any], channel_id: str) -> InboundMessage:
|
|
143
|
+
"""Build an InboundMessage from a VoiceMeUp webhook payload."""
|
|
144
|
+
body = payload.get("message", "")
|
|
145
|
+
|
|
146
|
+
media: list[dict[str, str | None]] = []
|
|
147
|
+
attachment_url = payload.get("attachment") or payload.get("attachment_url")
|
|
148
|
+
if attachment_url:
|
|
149
|
+
media.append(
|
|
150
|
+
{
|
|
151
|
+
"url": attachment_url,
|
|
152
|
+
"mime_type": payload.get("attachment_mime_type") or payload.get("attachment_type"),
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return InboundMessage(
|
|
157
|
+
channel_id=channel_id,
|
|
158
|
+
sender_id=payload["source_number"],
|
|
159
|
+
content=build_inbound_content(body, media),
|
|
160
|
+
external_id=payload.get("sms_hash"),
|
|
161
|
+
idempotency_key=payload.get("sms_hash"),
|
|
162
|
+
metadata={
|
|
163
|
+
"destination_number": payload.get("destination_number", ""),
|
|
164
|
+
"direction": payload.get("direction", "inbound"),
|
|
165
|
+
"datetime_transmission": payload.get("datetime_transmission", ""),
|
|
166
|
+
"has_attachment": bool(media),
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_voicemeup_webhook(
|
|
172
|
+
payload: dict[str, Any],
|
|
173
|
+
channel_id: str,
|
|
174
|
+
) -> InboundMessage | None:
|
|
175
|
+
"""Parse a VoiceMeUp webhook and return an InboundMessage.
|
|
176
|
+
|
|
177
|
+
Automatically handles MMS aggregation: VoiceMeUp sends MMS as two separate
|
|
178
|
+
webhooks (text + metadata first, image second). This function buffers the
|
|
179
|
+
first part and merges it with the second.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
payload: The webhook POST body from VoiceMeUp
|
|
183
|
+
channel_id: The channel ID to associate with this message
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
InboundMessage if ready to process (SMS or merged MMS)
|
|
187
|
+
None if buffered (waiting for second MMS part)
|
|
188
|
+
|
|
189
|
+
Example:
|
|
190
|
+
@app.post("/webhooks/sms/voicemeup")
|
|
191
|
+
async def webhook(payload: dict):
|
|
192
|
+
message = parse_voicemeup_webhook(payload, channel_id="sms")
|
|
193
|
+
if message:
|
|
194
|
+
await kit.process_inbound(message)
|
|
195
|
+
return {"ok": True}
|
|
196
|
+
"""
|
|
197
|
+
attachment_url = payload.get("attachment") or payload.get("attachment_url")
|
|
198
|
+
attachment_mime = payload.get("attachment_mime_type") or payload.get("attachment_type")
|
|
199
|
+
|
|
200
|
+
# Check if this is a metadata wrapper (first part of split MMS)
|
|
201
|
+
if _is_mms_metadata_wrapper(attachment_url, attachment_mime):
|
|
202
|
+
key = _make_correlation_key(payload)
|
|
203
|
+
text = payload.get("message", "")
|
|
204
|
+
|
|
205
|
+
_mms_buffer[key] = _PendingMMS(
|
|
206
|
+
payload=payload,
|
|
207
|
+
text=text,
|
|
208
|
+
timestamp=time.time(),
|
|
209
|
+
channel_id=channel_id,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Schedule timeout (only if event loop is running)
|
|
213
|
+
try:
|
|
214
|
+
loop = asyncio.get_running_loop()
|
|
215
|
+
loop.create_task(_handle_mms_timeout(key))
|
|
216
|
+
except RuntimeError:
|
|
217
|
+
# No event loop running — timeout won't fire, but in real usage
|
|
218
|
+
# (FastAPI/Starlette) there always will be one
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
logger.debug("VoiceMeUp MMS: buffered first part for %s", key)
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Check if we have a buffered first part to merge with
|
|
225
|
+
key = _make_correlation_key(payload)
|
|
226
|
+
if key in _mms_buffer:
|
|
227
|
+
pending = _mms_buffer.pop(key)
|
|
228
|
+
|
|
229
|
+
# Build merged payload: text from first, media from second
|
|
230
|
+
merged_payload = dict(payload)
|
|
231
|
+
merged_payload["message"] = pending.text
|
|
232
|
+
|
|
233
|
+
# Combine sms_hash for traceability
|
|
234
|
+
first_hash = pending.payload.get("sms_hash", "")
|
|
235
|
+
second_hash = payload.get("sms_hash", "")
|
|
236
|
+
merged_payload["sms_hash"] = f"{first_hash}+{second_hash}"
|
|
237
|
+
|
|
238
|
+
logger.debug("VoiceMeUp MMS: merged text + media for %s", key)
|
|
239
|
+
return _build_inbound_message(merged_payload, channel_id)
|
|
240
|
+
|
|
241
|
+
# Regular SMS or standalone MMS — return directly
|
|
242
|
+
return _build_inbound_message(payload, channel_id)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# SMS Provider
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _strip_plus(number: str) -> str:
|
|
251
|
+
"""Strip leading '+' from an E.164 phone number."""
|
|
252
|
+
return number.lstrip("+")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class VoiceMeUpSMSProvider(SMSProvider):
|
|
256
|
+
"""SMS provider using the VoiceMeUp REST API."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, config: VoiceMeUpConfig) -> None:
|
|
259
|
+
try:
|
|
260
|
+
import httpx as _httpx
|
|
261
|
+
except ImportError as exc:
|
|
262
|
+
raise ImportError(
|
|
263
|
+
"httpx is required for VoiceMeUpSMSProvider. "
|
|
264
|
+
"Install it with: pip install roomkit[httpx]"
|
|
265
|
+
) from exc
|
|
266
|
+
self._config = config
|
|
267
|
+
self._httpx = _httpx
|
|
268
|
+
self._client: httpx.AsyncClient = _httpx.AsyncClient(timeout=config.timeout)
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def from_number(self) -> str:
|
|
272
|
+
return self._config.from_number
|
|
273
|
+
|
|
274
|
+
async def send(self, event: RoomEvent, to: str, from_: str | None = None) -> ProviderResult:
|
|
275
|
+
body = extract_text_body(event.content)
|
|
276
|
+
media_urls = extract_media_urls(event.content)
|
|
277
|
+
|
|
278
|
+
if not body and not media_urls:
|
|
279
|
+
return ProviderResult(success=False, error="empty_message")
|
|
280
|
+
|
|
281
|
+
from_number = _strip_plus(from_ or self._config.from_number)
|
|
282
|
+
to_number = _strip_plus(to)
|
|
283
|
+
|
|
284
|
+
# MMS: VoiceMeUp supports one attachment per message
|
|
285
|
+
if media_urls:
|
|
286
|
+
return await self._send_message(
|
|
287
|
+
body or "", to_number, from_number, attachment=media_urls[0]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# SMS: split long messages into segments
|
|
291
|
+
segments = self._split_message(body)
|
|
292
|
+
last_result: ProviderResult | None = None
|
|
293
|
+
|
|
294
|
+
for segment in segments:
|
|
295
|
+
last_result = await self._send_message(segment, to_number, from_number)
|
|
296
|
+
if not last_result.success:
|
|
297
|
+
return last_result
|
|
298
|
+
|
|
299
|
+
assert last_result is not None
|
|
300
|
+
return last_result
|
|
301
|
+
|
|
302
|
+
async def _send_message(
|
|
303
|
+
self, message: str, to: str, from_: str, *, attachment: str | None = None
|
|
304
|
+
) -> ProviderResult:
|
|
305
|
+
url = f"{self._config.base_url}queue_sms"
|
|
306
|
+
auth_params: dict[str, str] = {
|
|
307
|
+
"username": self._config.username,
|
|
308
|
+
"auth_token": self._config.auth_token.get_secret_value(),
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
form_data: dict[str, str] = {
|
|
312
|
+
"source_number": from_,
|
|
313
|
+
"destination_number": to,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if message:
|
|
317
|
+
form_data["message"] = message
|
|
318
|
+
|
|
319
|
+
if attachment:
|
|
320
|
+
form_data["attachment"] = attachment
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
resp = await self._client.post(url, params=auth_params, data=form_data)
|
|
324
|
+
resp.raise_for_status()
|
|
325
|
+
data = resp.json()
|
|
326
|
+
except self._httpx.TimeoutException:
|
|
327
|
+
return ProviderResult(success=False, error="timeout")
|
|
328
|
+
except self._httpx.HTTPStatusError as exc:
|
|
329
|
+
return ProviderResult(
|
|
330
|
+
success=False,
|
|
331
|
+
error=f"http_{exc.response.status_code}",
|
|
332
|
+
)
|
|
333
|
+
except self._httpx.HTTPError as exc:
|
|
334
|
+
return ProviderResult(success=False, error=str(exc))
|
|
335
|
+
|
|
336
|
+
return self._parse_response(data)
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _parse_response(data: dict[str, Any]) -> ProviderResult:
|
|
340
|
+
details = data.get("response_details", {})
|
|
341
|
+
status = details.get("response_status", "")
|
|
342
|
+
|
|
343
|
+
if status == "error":
|
|
344
|
+
messages = details.get("response_messages", {}).get("message", [])
|
|
345
|
+
if isinstance(messages, dict):
|
|
346
|
+
messages = [messages]
|
|
347
|
+
code = messages[0].get("code", "unknown_error") if messages else "unknown_error"
|
|
348
|
+
description = messages[0].get("_content", code) if messages else code
|
|
349
|
+
return ProviderResult(success=False, error=code, metadata={"description": description})
|
|
350
|
+
|
|
351
|
+
messages = details.get("response_messages", {}).get("message", [])
|
|
352
|
+
if isinstance(messages, dict):
|
|
353
|
+
messages = [messages]
|
|
354
|
+
|
|
355
|
+
sms_id: str | None = None
|
|
356
|
+
for msg in messages:
|
|
357
|
+
if msg.get("code") == "queued_sms_hash":
|
|
358
|
+
sms_id = msg.get("_content")
|
|
359
|
+
break
|
|
360
|
+
|
|
361
|
+
return ProviderResult(success=True, provider_message_id=sms_id)
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _split_message(text: str) -> list[str]:
|
|
365
|
+
if len(text) <= _MAX_SEGMENT_LENGTH:
|
|
366
|
+
return [text]
|
|
367
|
+
segments: list[str] = []
|
|
368
|
+
while text:
|
|
369
|
+
segments.append(text[:_MAX_SEGMENT_LENGTH])
|
|
370
|
+
text = text[_MAX_SEGMENT_LENGTH:]
|
|
371
|
+
return segments
|
|
372
|
+
|
|
373
|
+
async def close(self) -> None:
|
|
374
|
+
await self._client.aclose()
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Abstract base class for WhatsApp 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 WhatsAppProvider(ABC):
|
|
13
|
+
"""WhatsApp delivery provider."""
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
"""Provider name (e.g. 'meta', 'twilio_wa')."""
|
|
18
|
+
return self.__class__.__name__
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def send(self, event: RoomEvent, to: str) -> ProviderResult:
|
|
22
|
+
"""Send a WhatsApp message.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
event: The room event containing the message content.
|
|
26
|
+
to: Recipient WhatsApp ID or phone number.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Result with provider-specific delivery metadata.
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def parse_webhook(self, payload: dict[str, Any]) -> InboundMessage:
|
|
34
|
+
"""Parse an inbound webhook payload into an InboundMessage."""
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
async def send_template(
|
|
38
|
+
self, to: str, template_name: str, params: dict[str, Any] | None = None
|
|
39
|
+
) -> ProviderResult:
|
|
40
|
+
"""Send a template message."""
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
|
|
43
|
+
async def close(self) -> None: # noqa: B027
|
|
44
|
+
"""Release resources. Override in subclasses that hold connections."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Mock WhatsApp provider for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from roomkit.models.delivery import ProviderResult
|
|
9
|
+
from roomkit.models.event import RoomEvent
|
|
10
|
+
from roomkit.providers.whatsapp.base import WhatsAppProvider
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MockWhatsAppProvider(WhatsAppProvider):
|
|
14
|
+
"""Records sent messages for verification in tests."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self.sent: list[dict[str, Any]] = []
|
|
18
|
+
|
|
19
|
+
async def send(self, event: RoomEvent, to: str) -> ProviderResult:
|
|
20
|
+
self.sent.append({"event": event, "to": to})
|
|
21
|
+
return ProviderResult(success=True, provider_message_id=uuid4().hex)
|
roomkit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Realtime backend for ephemeral events."""
|
|
2
|
+
|
|
3
|
+
from roomkit.realtime.base import (
|
|
4
|
+
EphemeralCallback,
|
|
5
|
+
EphemeralEvent,
|
|
6
|
+
EphemeralEventType,
|
|
7
|
+
RealtimeBackend,
|
|
8
|
+
)
|
|
9
|
+
from roomkit.realtime.memory import InMemoryRealtime
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"EphemeralCallback",
|
|
13
|
+
"EphemeralEvent",
|
|
14
|
+
"EphemeralEventType",
|
|
15
|
+
"InMemoryRealtime",
|
|
16
|
+
"RealtimeBackend",
|
|
17
|
+
]
|
roomkit/realtime/base.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Abstract base class and types for realtime backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EphemeralEventType(StrEnum):
|
|
15
|
+
"""Types of ephemeral events."""
|
|
16
|
+
|
|
17
|
+
TYPING_START = "typing_start"
|
|
18
|
+
TYPING_STOP = "typing_stop"
|
|
19
|
+
PRESENCE_ONLINE = "presence_online"
|
|
20
|
+
PRESENCE_AWAY = "presence_away"
|
|
21
|
+
PRESENCE_OFFLINE = "presence_offline"
|
|
22
|
+
READ_RECEIPT = "read_receipt"
|
|
23
|
+
CUSTOM = "custom"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class EphemeralEvent:
|
|
28
|
+
"""An ephemeral event that doesn't require persistence."""
|
|
29
|
+
|
|
30
|
+
room_id: str
|
|
31
|
+
type: EphemeralEventType
|
|
32
|
+
user_id: str
|
|
33
|
+
id: str = field(default_factory=lambda: uuid4().hex)
|
|
34
|
+
channel_id: str | None = None
|
|
35
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
"""Convert to a JSON-serializable dictionary."""
|
|
40
|
+
return {
|
|
41
|
+
"id": self.id,
|
|
42
|
+
"room_id": self.room_id,
|
|
43
|
+
"type": self.type.value,
|
|
44
|
+
"user_id": self.user_id,
|
|
45
|
+
"channel_id": self.channel_id,
|
|
46
|
+
"data": self.data,
|
|
47
|
+
"timestamp": self.timestamp.isoformat(),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, data: dict[str, Any]) -> EphemeralEvent:
|
|
52
|
+
"""Create an EphemeralEvent from a dictionary."""
|
|
53
|
+
return cls(
|
|
54
|
+
id=data["id"],
|
|
55
|
+
room_id=data["room_id"],
|
|
56
|
+
type=EphemeralEventType(data["type"]),
|
|
57
|
+
user_id=data["user_id"],
|
|
58
|
+
channel_id=data.get("channel_id"),
|
|
59
|
+
data=data.get("data", {}),
|
|
60
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
EphemeralCallback = Callable[[EphemeralEvent], Coroutine[Any, Any, None]]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RealtimeBackend(ABC):
|
|
68
|
+
"""Abstract base for realtime pub/sub backends.
|
|
69
|
+
|
|
70
|
+
Implement this to plug in any realtime backend (Redis pub/sub, NATS, etc.).
|
|
71
|
+
The library ships with ``InMemoryRealtime`` for single-process deployments.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def publish(self, channel: str, event: EphemeralEvent) -> None:
|
|
76
|
+
"""Publish an event to a channel."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
async def subscribe(self, channel: str, callback: EphemeralCallback) -> str:
|
|
81
|
+
"""Subscribe to a channel.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
A subscription ID that can be used to unsubscribe.
|
|
85
|
+
"""
|
|
86
|
+
...
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def unsubscribe(self, subscription_id: str) -> bool:
|
|
90
|
+
"""Unsubscribe from a channel.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if the subscription existed and was removed.
|
|
94
|
+
"""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
async def publish_to_room(self, room_id: str, event: EphemeralEvent) -> None:
|
|
98
|
+
"""Convenience method to publish an event to a room channel."""
|
|
99
|
+
await self.publish(f"room:{room_id}", event)
|
|
100
|
+
|
|
101
|
+
async def subscribe_to_room(self, room_id: str, callback: EphemeralCallback) -> str:
|
|
102
|
+
"""Convenience method to subscribe to a room channel."""
|
|
103
|
+
return await self.subscribe(f"room:{room_id}", callback)
|
|
104
|
+
|
|
105
|
+
async def close(self) -> None:
|
|
106
|
+
"""Clean up resources.
|
|
107
|
+
|
|
108
|
+
Override this method in subclasses that need cleanup.
|
|
109
|
+
The default implementation does nothing.
|
|
110
|
+
"""
|
|
111
|
+
return None
|