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
roomkit/core/hooks.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Hook engine for sync and async hook pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Coroutine
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from roomkit.models.context import RoomContext
|
|
12
|
+
from roomkit.models.enums import ChannelDirection, ChannelType, HookExecution, HookTrigger
|
|
13
|
+
from roomkit.models.event import RoomEvent
|
|
14
|
+
from roomkit.models.hook import HookResult, InjectedEvent
|
|
15
|
+
from roomkit.models.task import Observation, Task
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("roomkit.hooks")
|
|
18
|
+
|
|
19
|
+
SyncHookFn = Callable[[RoomEvent, RoomContext], Coroutine[Any, Any, HookResult]]
|
|
20
|
+
AsyncHookFn = Callable[[RoomEvent, RoomContext], Coroutine[Any, Any, None]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class HookRegistration:
|
|
25
|
+
"""A registered hook function.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
trigger: When the hook fires (BEFORE_BROADCAST, AFTER_BROADCAST, etc.)
|
|
29
|
+
execution: SYNC (can block/modify) or ASYNC (fire-and-forget)
|
|
30
|
+
fn: The hook function
|
|
31
|
+
priority: Lower numbers run first (default: 0)
|
|
32
|
+
name: Optional name for logging and removal
|
|
33
|
+
timeout: Max execution time in seconds (default: 30.0)
|
|
34
|
+
channel_types: Only run for events from these channel types (None = all)
|
|
35
|
+
channel_ids: Only run for events from these channel IDs (None = all)
|
|
36
|
+
directions: Only run for events with these directions (None = all)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
trigger: HookTrigger
|
|
40
|
+
execution: HookExecution
|
|
41
|
+
fn: SyncHookFn | AsyncHookFn
|
|
42
|
+
priority: int = 0
|
|
43
|
+
name: str = ""
|
|
44
|
+
timeout: float = 30.0
|
|
45
|
+
# Filters (None = match all)
|
|
46
|
+
channel_types: set[ChannelType] | None = None
|
|
47
|
+
channel_ids: set[str] | None = None
|
|
48
|
+
directions: set[ChannelDirection] | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class IdentityHookRegistration:
|
|
53
|
+
"""A registered identity hook function.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
trigger: When the hook fires (ON_IDENTITY_AMBIGUOUS, ON_IDENTITY_UNKNOWN)
|
|
57
|
+
fn: The hook function
|
|
58
|
+
channel_types: Only run for events from these channel types (None = all)
|
|
59
|
+
channel_ids: Only run for events from these channel IDs (None = all)
|
|
60
|
+
directions: Only run for events with these directions (None = all)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
trigger: HookTrigger
|
|
64
|
+
fn: Any # IdentityHookFn - using Any to avoid circular import
|
|
65
|
+
channel_types: set[ChannelType] | None = None
|
|
66
|
+
channel_ids: set[str] | None = None
|
|
67
|
+
directions: set[ChannelDirection] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class SyncPipelineResult:
|
|
72
|
+
"""Result of running the sync hook pipeline."""
|
|
73
|
+
|
|
74
|
+
allowed: bool = True
|
|
75
|
+
event: RoomEvent | None = None
|
|
76
|
+
reason: str | None = None
|
|
77
|
+
blocked_by: str | None = None
|
|
78
|
+
injected_events: list[InjectedEvent] = field(default_factory=list)
|
|
79
|
+
tasks: list[Task] = field(default_factory=list)
|
|
80
|
+
observations: list[Observation] = field(default_factory=list)
|
|
81
|
+
hook_errors: list[dict[str, str]] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class HookEngine:
|
|
85
|
+
"""Manages global and per-room hook registration and execution."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._global_hooks: list[HookRegistration] = []
|
|
89
|
+
self._room_hooks: dict[str, list[HookRegistration]] = {}
|
|
90
|
+
|
|
91
|
+
def register(self, hook: HookRegistration) -> None:
|
|
92
|
+
"""Register a global hook."""
|
|
93
|
+
self._global_hooks.append(hook)
|
|
94
|
+
|
|
95
|
+
def add_room_hook(self, room_id: str, hook: HookRegistration) -> None:
|
|
96
|
+
"""Register a hook for a specific room."""
|
|
97
|
+
self._room_hooks.setdefault(room_id, []).append(hook)
|
|
98
|
+
|
|
99
|
+
def remove_room_hook(self, room_id: str, name: str) -> bool:
|
|
100
|
+
"""Remove a room hook by name."""
|
|
101
|
+
hooks = self._room_hooks.get(room_id, [])
|
|
102
|
+
for i, h in enumerate(hooks):
|
|
103
|
+
if h.name == name:
|
|
104
|
+
hooks.pop(i)
|
|
105
|
+
return True
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
def _hook_matches_event(self, hook: HookRegistration, event: RoomEvent) -> bool:
|
|
109
|
+
"""Check if a hook's filters match the given event."""
|
|
110
|
+
source = event.source
|
|
111
|
+
|
|
112
|
+
# All filters must pass (None means "match all")
|
|
113
|
+
type_ok = hook.channel_types is None or source.channel_type in hook.channel_types
|
|
114
|
+
id_ok = hook.channel_ids is None or source.channel_id in hook.channel_ids
|
|
115
|
+
dir_ok = hook.directions is None or source.direction in hook.directions
|
|
116
|
+
|
|
117
|
+
return type_ok and id_ok and dir_ok
|
|
118
|
+
|
|
119
|
+
def _get_hooks(
|
|
120
|
+
self,
|
|
121
|
+
room_id: str,
|
|
122
|
+
trigger: HookTrigger,
|
|
123
|
+
execution: HookExecution,
|
|
124
|
+
event: RoomEvent | None = None,
|
|
125
|
+
) -> list[HookRegistration]:
|
|
126
|
+
"""Get merged global + room hooks filtered and sorted by priority.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
room_id: The room ID to get hooks for
|
|
130
|
+
trigger: The hook trigger to filter by
|
|
131
|
+
execution: The execution mode to filter by
|
|
132
|
+
event: Optional event to filter hooks by channel_type/id/direction
|
|
133
|
+
"""
|
|
134
|
+
all_hooks = [
|
|
135
|
+
h for h in self._global_hooks if h.trigger == trigger and h.execution == execution
|
|
136
|
+
]
|
|
137
|
+
room_hooks = [
|
|
138
|
+
h
|
|
139
|
+
for h in self._room_hooks.get(room_id, [])
|
|
140
|
+
if h.trigger == trigger and h.execution == execution
|
|
141
|
+
]
|
|
142
|
+
all_hooks.extend(room_hooks)
|
|
143
|
+
|
|
144
|
+
# Apply event-based filters if event is provided
|
|
145
|
+
if event is not None:
|
|
146
|
+
all_hooks = [h for h in all_hooks if self._hook_matches_event(h, event)]
|
|
147
|
+
|
|
148
|
+
all_hooks.sort(key=lambda h: h.priority)
|
|
149
|
+
return all_hooks
|
|
150
|
+
|
|
151
|
+
async def run_sync_hooks(
|
|
152
|
+
self,
|
|
153
|
+
room_id: str,
|
|
154
|
+
trigger: HookTrigger,
|
|
155
|
+
event: RoomEvent,
|
|
156
|
+
context: RoomContext,
|
|
157
|
+
) -> SyncPipelineResult:
|
|
158
|
+
"""Run sync hooks sequentially. Stops on block, passes modified events."""
|
|
159
|
+
hooks = self._get_hooks(room_id, trigger, HookExecution.SYNC, event=event)
|
|
160
|
+
result = SyncPipelineResult(event=event)
|
|
161
|
+
|
|
162
|
+
for hook in hooks:
|
|
163
|
+
try:
|
|
164
|
+
current_event = result.event or event
|
|
165
|
+
fn = cast(SyncHookFn, hook.fn)
|
|
166
|
+
hook_result: HookResult = await asyncio.wait_for(
|
|
167
|
+
fn(current_event, context), timeout=hook.timeout
|
|
168
|
+
)
|
|
169
|
+
except TimeoutError:
|
|
170
|
+
logger.warning(
|
|
171
|
+
"Sync hook %s timed out after %.1fs",
|
|
172
|
+
hook.name,
|
|
173
|
+
hook.timeout,
|
|
174
|
+
extra={"room_id": room_id},
|
|
175
|
+
)
|
|
176
|
+
result.hook_errors.append(
|
|
177
|
+
{"hook": hook.name, "error": f"timeout ({hook.timeout}s)"}
|
|
178
|
+
)
|
|
179
|
+
continue
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
logger.exception("Sync hook %s failed", hook.name, extra={"room_id": room_id})
|
|
182
|
+
result.hook_errors.append({"hook": hook.name, "error": str(exc)})
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
result.injected_events.extend(hook_result.injected_events)
|
|
186
|
+
result.tasks.extend(hook_result.tasks)
|
|
187
|
+
result.observations.extend(hook_result.observations)
|
|
188
|
+
|
|
189
|
+
if hook_result.action == "block":
|
|
190
|
+
result.allowed = False
|
|
191
|
+
result.reason = hook_result.reason
|
|
192
|
+
result.blocked_by = hook.name
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
if hook_result.action == "modify" and hook_result.event is not None:
|
|
196
|
+
result.event = hook_result.event
|
|
197
|
+
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
async def run_async_hooks(
|
|
201
|
+
self,
|
|
202
|
+
room_id: str,
|
|
203
|
+
trigger: HookTrigger,
|
|
204
|
+
event: RoomEvent,
|
|
205
|
+
context: RoomContext,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Run async hooks concurrently. Errors are logged, never raised."""
|
|
208
|
+
hooks = self._get_hooks(room_id, trigger, HookExecution.ASYNC, event=event)
|
|
209
|
+
if not hooks:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
async def _run_one(hook: HookRegistration) -> None:
|
|
213
|
+
try:
|
|
214
|
+
await asyncio.wait_for(
|
|
215
|
+
hook.fn(event, context),
|
|
216
|
+
timeout=hook.timeout,
|
|
217
|
+
)
|
|
218
|
+
except TimeoutError:
|
|
219
|
+
logger.warning(
|
|
220
|
+
"Async hook %s timed out after %.1fs",
|
|
221
|
+
hook.name,
|
|
222
|
+
hook.timeout,
|
|
223
|
+
extra={"room_id": room_id},
|
|
224
|
+
)
|
|
225
|
+
except Exception:
|
|
226
|
+
logger.exception(
|
|
227
|
+
"Async hook %s failed",
|
|
228
|
+
hook.name,
|
|
229
|
+
extra={"room_id": room_id},
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
await asyncio.gather(*[_run_one(hook) for hook in hooks], return_exceptions=True)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Inbound room router — determines which room an inbound message belongs to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from roomkit.models.enums import ChannelType, RoomStatus
|
|
9
|
+
from roomkit.store.base import ConversationStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InboundRoomRouter(ABC):
|
|
13
|
+
"""Route an inbound message to a room (or ``None`` for auto-create)."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def route(
|
|
17
|
+
self,
|
|
18
|
+
channel_id: str,
|
|
19
|
+
channel_type: ChannelType,
|
|
20
|
+
participant_id: str | None = None,
|
|
21
|
+
channel_data: dict[str, Any] | None = None,
|
|
22
|
+
) -> str | None:
|
|
23
|
+
"""Return room_id for the message, or ``None`` to create a new room."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DefaultInboundRoomRouter(InboundRoomRouter):
|
|
28
|
+
"""Default router: find room by channel binding, then by participant."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, store: ConversationStore) -> None:
|
|
31
|
+
self._store = store
|
|
32
|
+
|
|
33
|
+
async def route(
|
|
34
|
+
self,
|
|
35
|
+
channel_id: str,
|
|
36
|
+
channel_type: ChannelType,
|
|
37
|
+
participant_id: str | None = None,
|
|
38
|
+
channel_data: dict[str, Any] | None = None,
|
|
39
|
+
) -> str | None:
|
|
40
|
+
# Strategy 1: Find room by channel binding (current behavior)
|
|
41
|
+
room_id = await self._store.find_room_id_by_channel(
|
|
42
|
+
channel_id, status=str(RoomStatus.ACTIVE)
|
|
43
|
+
)
|
|
44
|
+
if room_id is not None:
|
|
45
|
+
return room_id
|
|
46
|
+
|
|
47
|
+
# Strategy 2: Find room by participant if available
|
|
48
|
+
if participant_id:
|
|
49
|
+
room = await self._store.find_latest_room(
|
|
50
|
+
participant_id=participant_id,
|
|
51
|
+
channel_type=str(channel_type),
|
|
52
|
+
status=str(RoomStatus.ACTIVE),
|
|
53
|
+
)
|
|
54
|
+
if room:
|
|
55
|
+
return room.id
|
|
56
|
+
|
|
57
|
+
return None
|
roomkit/core/locks.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Per-room async locking with LRU eviction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RoomLockManager(ABC):
|
|
13
|
+
"""Abstract base for per-room locking.
|
|
14
|
+
|
|
15
|
+
Implement this to plug in any locking backend (Redis, Postgres
|
|
16
|
+
advisory locks, etc.). The library ships with ``InMemoryLockManager``
|
|
17
|
+
for single-process deployments.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
@asynccontextmanager
|
|
22
|
+
async def locked(self, room_id: str) -> AsyncIterator[None]:
|
|
23
|
+
"""Acquire an exclusive lock for *room_id*."""
|
|
24
|
+
yield # pragma: no cover
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InMemoryLockManager(RoomLockManager):
|
|
28
|
+
"""In-process per-room asyncio locks with LRU eviction.
|
|
29
|
+
|
|
30
|
+
Suitable for single-process deployments. For multi-process or
|
|
31
|
+
distributed setups, provide a custom ``RoomLockManager`` backed by
|
|
32
|
+
Redis, Postgres advisory locks, or similar.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, max_locks: int = 1024) -> None:
|
|
36
|
+
self._locks: OrderedDict[str, asyncio.Lock] = OrderedDict()
|
|
37
|
+
self._max_locks = max_locks
|
|
38
|
+
|
|
39
|
+
def _get_lock(self, room_id: str) -> asyncio.Lock:
|
|
40
|
+
if room_id in self._locks:
|
|
41
|
+
self._locks.move_to_end(room_id)
|
|
42
|
+
return self._locks[room_id]
|
|
43
|
+
|
|
44
|
+
lock = asyncio.Lock()
|
|
45
|
+
self._locks[room_id] = lock
|
|
46
|
+
self._evict()
|
|
47
|
+
return lock
|
|
48
|
+
|
|
49
|
+
def _evict(self) -> None:
|
|
50
|
+
while len(self._locks) > self._max_locks:
|
|
51
|
+
oldest_key, oldest_lock = next(iter(self._locks.items()))
|
|
52
|
+
if oldest_lock.locked():
|
|
53
|
+
break
|
|
54
|
+
self._locks.pop(oldest_key)
|
|
55
|
+
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def locked(self, room_id: str) -> AsyncIterator[None]:
|
|
58
|
+
"""Acquire the lock for a room."""
|
|
59
|
+
lock = self._get_lock(room_id)
|
|
60
|
+
async with lock:
|
|
61
|
+
yield
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def size(self) -> int:
|
|
65
|
+
"""Return the number of locks currently held."""
|
|
66
|
+
return len(self._locks)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Token-bucket rate limiter for channel delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from roomkit.models.channel import RateLimit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TokenBucketRateLimiter:
|
|
12
|
+
"""Per-channel token bucket rate limiter.
|
|
13
|
+
|
|
14
|
+
Uses ``RateLimit.max_per_second`` for the bucket refill rate.
|
|
15
|
+
Falls back to ``max_per_minute / 60`` or ``max_per_hour / 3600``.
|
|
16
|
+
|
|
17
|
+
**Concurrency note:** This implementation relies on the CPython single-threaded
|
|
18
|
+
asyncio model. The ``acquire`` method reads and writes ``_buckets`` with no
|
|
19
|
+
``await`` between the check and the set, making the token update atomic within
|
|
20
|
+
a single event-loop iteration. The ``wait`` method uses ``asyncio.sleep``
|
|
21
|
+
between acquire attempts, which is safe because each iteration re-reads the
|
|
22
|
+
bucket state after yielding control.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
# channel_id -> (tokens, last_refill_time)
|
|
27
|
+
self._buckets: dict[str, tuple[float, float]] = {}
|
|
28
|
+
|
|
29
|
+
def _rate_per_second(self, rate_limit: RateLimit) -> float:
|
|
30
|
+
if rate_limit.max_per_second is not None:
|
|
31
|
+
return rate_limit.max_per_second
|
|
32
|
+
if rate_limit.max_per_minute is not None:
|
|
33
|
+
return rate_limit.max_per_minute / 60.0
|
|
34
|
+
if rate_limit.max_per_hour is not None:
|
|
35
|
+
return rate_limit.max_per_hour / 3600.0
|
|
36
|
+
return float("inf")
|
|
37
|
+
|
|
38
|
+
def _refill(self, channel_id: str, rate: float) -> float:
|
|
39
|
+
"""Refill tokens and return current count."""
|
|
40
|
+
now = time.monotonic()
|
|
41
|
+
tokens, last_refill = self._buckets.get(channel_id, (rate, now))
|
|
42
|
+
elapsed = now - last_refill
|
|
43
|
+
tokens = min(tokens + elapsed * rate, rate) # cap at rate (burst = 1s)
|
|
44
|
+
self._buckets[channel_id] = (tokens, now)
|
|
45
|
+
return tokens
|
|
46
|
+
|
|
47
|
+
def acquire(self, channel_id: str, rate_limit: RateLimit) -> bool:
|
|
48
|
+
"""Try to acquire a token. Returns True if allowed, False if rate limited."""
|
|
49
|
+
rate = self._rate_per_second(rate_limit)
|
|
50
|
+
if rate == float("inf"):
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
tokens = self._refill(channel_id, rate)
|
|
54
|
+
if tokens >= 1.0:
|
|
55
|
+
self._buckets[channel_id] = (tokens - 1.0, time.monotonic())
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
async def wait(self, channel_id: str, rate_limit: RateLimit) -> None:
|
|
60
|
+
"""Wait until a token is available (queue instead of drop)."""
|
|
61
|
+
rate = self._rate_per_second(rate_limit)
|
|
62
|
+
if rate == float("inf"):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
while not self.acquire(channel_id, rate_limit):
|
|
66
|
+
# Wait for one token to refill
|
|
67
|
+
await asyncio.sleep(1.0 / rate)
|
roomkit/core/retry.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Retry with exponential backoff for delivery operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Coroutine
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from roomkit.models.channel import RetryPolicy
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("roomkit.retry")
|
|
13
|
+
|
|
14
|
+
__all__ = ["RetryPolicy", "retry_with_backoff"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def retry_with_backoff[T](
|
|
18
|
+
fn: Callable[..., Coroutine[Any, Any, T]],
|
|
19
|
+
policy: RetryPolicy,
|
|
20
|
+
*args: Any,
|
|
21
|
+
**kwargs: Any,
|
|
22
|
+
) -> T:
|
|
23
|
+
"""Execute *fn* with exponential backoff retry.
|
|
24
|
+
|
|
25
|
+
Raises the last exception if all retries are exhausted.
|
|
26
|
+
"""
|
|
27
|
+
last_exc: Exception | None = None
|
|
28
|
+
for attempt in range(1 + policy.max_retries):
|
|
29
|
+
try:
|
|
30
|
+
return await fn(*args, **kwargs)
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
last_exc = exc
|
|
33
|
+
if attempt >= policy.max_retries:
|
|
34
|
+
break
|
|
35
|
+
delay = min(
|
|
36
|
+
policy.base_delay_seconds * (policy.exponential_base**attempt),
|
|
37
|
+
policy.max_delay_seconds,
|
|
38
|
+
)
|
|
39
|
+
logger.warning(
|
|
40
|
+
"Attempt %d/%d failed, retrying in %.1fs",
|
|
41
|
+
attempt + 1,
|
|
42
|
+
policy.max_retries + 1,
|
|
43
|
+
delay,
|
|
44
|
+
extra={"attempt": attempt + 1, "delay": delay},
|
|
45
|
+
)
|
|
46
|
+
await asyncio.sleep(delay)
|
|
47
|
+
|
|
48
|
+
assert last_exc is not None
|
|
49
|
+
raise last_exc
|
roomkit/core/router.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Abstract base classes for routing and transcoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from roomkit.models.channel import ChannelBinding
|
|
8
|
+
from roomkit.models.event import EventContent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ContentTranscoder(ABC):
|
|
12
|
+
"""Transcodes event content between channel capabilities."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def transcode(
|
|
16
|
+
self,
|
|
17
|
+
content: EventContent,
|
|
18
|
+
source_binding: ChannelBinding,
|
|
19
|
+
target_binding: ChannelBinding,
|
|
20
|
+
) -> EventContent | None:
|
|
21
|
+
"""Transcode content for the target channel's capabilities.
|
|
22
|
+
|
|
23
|
+
Return ``None`` to signal that the content cannot be represented.
|
|
24
|
+
"""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Default content transcoder with fallback conversions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from roomkit.core.router import ContentTranscoder
|
|
6
|
+
from roomkit.models.channel import ChannelBinding
|
|
7
|
+
from roomkit.models.enums import ChannelMediaType
|
|
8
|
+
from roomkit.models.event import (
|
|
9
|
+
AudioContent,
|
|
10
|
+
CompositeContent,
|
|
11
|
+
EventContent,
|
|
12
|
+
LocationContent,
|
|
13
|
+
MediaContent,
|
|
14
|
+
RichContent,
|
|
15
|
+
TextContent,
|
|
16
|
+
VideoContent,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DefaultContentTranscoder(ContentTranscoder):
|
|
21
|
+
"""Transcodes content using simple fallback rules."""
|
|
22
|
+
|
|
23
|
+
async def transcode(
|
|
24
|
+
self,
|
|
25
|
+
content: EventContent,
|
|
26
|
+
source_binding: ChannelBinding,
|
|
27
|
+
target_binding: ChannelBinding,
|
|
28
|
+
) -> EventContent | None:
|
|
29
|
+
"""Transcode content for the target channel's capabilities.
|
|
30
|
+
|
|
31
|
+
Returns ``None`` if the content cannot be represented at all
|
|
32
|
+
on the target channel (signalling a transcoding failure).
|
|
33
|
+
"""
|
|
34
|
+
target_types = target_binding.capabilities.media_types
|
|
35
|
+
|
|
36
|
+
if isinstance(content, TextContent):
|
|
37
|
+
return content
|
|
38
|
+
|
|
39
|
+
if isinstance(content, RichContent):
|
|
40
|
+
if ChannelMediaType.RICH in target_types:
|
|
41
|
+
return content
|
|
42
|
+
return TextContent(body=content.plain_text or content.body)
|
|
43
|
+
|
|
44
|
+
if isinstance(content, MediaContent):
|
|
45
|
+
if ChannelMediaType.MEDIA in target_types:
|
|
46
|
+
return content
|
|
47
|
+
caption = content.caption or content.filename or content.url
|
|
48
|
+
return TextContent(body=f"[Media: {caption}]")
|
|
49
|
+
|
|
50
|
+
if isinstance(content, AudioContent):
|
|
51
|
+
if ChannelMediaType.AUDIO in target_types:
|
|
52
|
+
return content
|
|
53
|
+
if content.transcript:
|
|
54
|
+
return TextContent(body=content.transcript)
|
|
55
|
+
return TextContent(body=f"[Voice message: {content.url}]")
|
|
56
|
+
|
|
57
|
+
if isinstance(content, VideoContent):
|
|
58
|
+
if ChannelMediaType.VIDEO in target_types:
|
|
59
|
+
return content
|
|
60
|
+
return TextContent(body=f"[Video: {content.url}]")
|
|
61
|
+
|
|
62
|
+
if isinstance(content, LocationContent):
|
|
63
|
+
if ChannelMediaType.LOCATION in target_types:
|
|
64
|
+
return content
|
|
65
|
+
label = content.label or content.address or ""
|
|
66
|
+
return TextContent(
|
|
67
|
+
body=f"[Location: {label} ({content.latitude}, {content.longitude})]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if isinstance(content, CompositeContent):
|
|
71
|
+
parts: list[EventContent] = []
|
|
72
|
+
for part in content.parts:
|
|
73
|
+
transcoded = await self.transcode(part, source_binding, target_binding)
|
|
74
|
+
if transcoded is None:
|
|
75
|
+
continue
|
|
76
|
+
parts.append(transcoded)
|
|
77
|
+
if not parts:
|
|
78
|
+
return None
|
|
79
|
+
# If all parts are text, flatten to single TextContent
|
|
80
|
+
text_parts = [p for p in parts if isinstance(p, TextContent)]
|
|
81
|
+
if len(text_parts) == len(parts):
|
|
82
|
+
return TextContent(body="\n".join(p.body for p in text_parts))
|
|
83
|
+
return CompositeContent(parts=parts)
|
|
84
|
+
|
|
85
|
+
return content
|
|
File without changes
|
roomkit/identity/base.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Abstract base class for identity resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from roomkit.models.context import RoomContext
|
|
8
|
+
from roomkit.models.delivery import InboundMessage
|
|
9
|
+
from roomkit.models.identity import IdentityResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IdentityResolver(ABC):
|
|
13
|
+
"""Resolves user identity from inbound messages."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
async def resolve(self, message: InboundMessage, context: RoomContext) -> IdentityResult:
|
|
17
|
+
"""Resolve the identity of an inbound message sender.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
message: The inbound message with sender information.
|
|
21
|
+
context: Current room context (room, bindings, participants).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
An identity result indicating identified, ambiguous, pending,
|
|
25
|
+
unknown, or rejected status.
|
|
26
|
+
"""
|
|
27
|
+
...
|
roomkit/identity/mock.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Mock identity resolver for testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from roomkit.identity.base import IdentityResolver
|
|
6
|
+
from roomkit.models.context import RoomContext
|
|
7
|
+
from roomkit.models.delivery import InboundMessage
|
|
8
|
+
from roomkit.models.enums import IdentificationStatus
|
|
9
|
+
from roomkit.models.identity import Identity, IdentityResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MockIdentityResolver(IdentityResolver):
|
|
13
|
+
"""Resolves identity from a pre-configured mapping.
|
|
14
|
+
|
|
15
|
+
Supports three resolution outcomes:
|
|
16
|
+
- **Identified**: sender_id found in ``mapping`` → single identity.
|
|
17
|
+
- **Ambiguous**: sender_id found in ``ambiguous`` → multiple candidates.
|
|
18
|
+
- **Unknown/Pending**: no match → status controlled by ``unknown_status``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
mapping: dict[str, Identity] | None = None,
|
|
24
|
+
ambiguous: dict[str, list[Identity]] | None = None,
|
|
25
|
+
unknown_status: IdentificationStatus = IdentificationStatus.UNKNOWN,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._mapping = mapping or {}
|
|
28
|
+
self._ambiguous = ambiguous or {}
|
|
29
|
+
self._unknown_status = unknown_status
|
|
30
|
+
|
|
31
|
+
async def resolve(self, message: InboundMessage, context: RoomContext) -> IdentityResult:
|
|
32
|
+
# Exact match → identified
|
|
33
|
+
identity = self._mapping.get(message.sender_id)
|
|
34
|
+
if identity:
|
|
35
|
+
return IdentityResult(
|
|
36
|
+
status=IdentificationStatus.IDENTIFIED,
|
|
37
|
+
identity=identity,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Multiple candidates → ambiguous
|
|
41
|
+
candidates = self._ambiguous.get(message.sender_id)
|
|
42
|
+
if candidates:
|
|
43
|
+
return IdentityResult(
|
|
44
|
+
status=IdentificationStatus.AMBIGUOUS,
|
|
45
|
+
candidates=candidates,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# No match
|
|
49
|
+
return IdentityResult(status=self._unknown_status)
|