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.
Files changed (114) hide show
  1. roomkit/AGENTS.md +362 -0
  2. roomkit/__init__.py +372 -0
  3. roomkit/_version.py +1 -0
  4. roomkit/ai_docs.py +93 -0
  5. roomkit/channels/__init__.py +194 -0
  6. roomkit/channels/ai.py +238 -0
  7. roomkit/channels/base.py +66 -0
  8. roomkit/channels/transport.py +115 -0
  9. roomkit/channels/websocket.py +85 -0
  10. roomkit/core/__init__.py +0 -0
  11. roomkit/core/_channel_ops.py +252 -0
  12. roomkit/core/_helpers.py +296 -0
  13. roomkit/core/_inbound.py +435 -0
  14. roomkit/core/_room_lifecycle.py +275 -0
  15. roomkit/core/circuit_breaker.py +84 -0
  16. roomkit/core/event_router.py +401 -0
  17. roomkit/core/framework.py +793 -0
  18. roomkit/core/hooks.py +232 -0
  19. roomkit/core/inbound_router.py +57 -0
  20. roomkit/core/locks.py +66 -0
  21. roomkit/core/rate_limiter.py +67 -0
  22. roomkit/core/retry.py +49 -0
  23. roomkit/core/router.py +24 -0
  24. roomkit/core/transcoder.py +85 -0
  25. roomkit/identity/__init__.py +0 -0
  26. roomkit/identity/base.py +27 -0
  27. roomkit/identity/mock.py +49 -0
  28. roomkit/llms.txt +52 -0
  29. roomkit/models/__init__.py +104 -0
  30. roomkit/models/channel.py +99 -0
  31. roomkit/models/context.py +35 -0
  32. roomkit/models/delivery.py +76 -0
  33. roomkit/models/enums.py +170 -0
  34. roomkit/models/event.py +203 -0
  35. roomkit/models/framework_event.py +19 -0
  36. roomkit/models/hook.py +68 -0
  37. roomkit/models/identity.py +81 -0
  38. roomkit/models/participant.py +34 -0
  39. roomkit/models/room.py +33 -0
  40. roomkit/models/task.py +36 -0
  41. roomkit/providers/__init__.py +0 -0
  42. roomkit/providers/ai/__init__.py +0 -0
  43. roomkit/providers/ai/base.py +140 -0
  44. roomkit/providers/ai/mock.py +33 -0
  45. roomkit/providers/anthropic/__init__.py +6 -0
  46. roomkit/providers/anthropic/ai.py +145 -0
  47. roomkit/providers/anthropic/config.py +14 -0
  48. roomkit/providers/elasticemail/__init__.py +6 -0
  49. roomkit/providers/elasticemail/config.py +16 -0
  50. roomkit/providers/elasticemail/email.py +97 -0
  51. roomkit/providers/email/__init__.py +0 -0
  52. roomkit/providers/email/base.py +46 -0
  53. roomkit/providers/email/mock.py +34 -0
  54. roomkit/providers/gemini/__init__.py +6 -0
  55. roomkit/providers/gemini/ai.py +153 -0
  56. roomkit/providers/gemini/config.py +14 -0
  57. roomkit/providers/http/__init__.py +15 -0
  58. roomkit/providers/http/base.py +33 -0
  59. roomkit/providers/http/config.py +14 -0
  60. roomkit/providers/http/mock.py +21 -0
  61. roomkit/providers/http/provider.py +105 -0
  62. roomkit/providers/http/webhook.py +33 -0
  63. roomkit/providers/messenger/__init__.py +15 -0
  64. roomkit/providers/messenger/base.py +33 -0
  65. roomkit/providers/messenger/config.py +17 -0
  66. roomkit/providers/messenger/facebook.py +95 -0
  67. roomkit/providers/messenger/mock.py +21 -0
  68. roomkit/providers/messenger/webhook.py +42 -0
  69. roomkit/providers/openai/__init__.py +6 -0
  70. roomkit/providers/openai/ai.py +155 -0
  71. roomkit/providers/openai/config.py +24 -0
  72. roomkit/providers/pydantic_ai/__init__.py +5 -0
  73. roomkit/providers/pydantic_ai/config.py +14 -0
  74. roomkit/providers/rcs/__init__.py +9 -0
  75. roomkit/providers/rcs/base.py +95 -0
  76. roomkit/providers/rcs/mock.py +78 -0
  77. roomkit/providers/sendgrid/__init__.py +5 -0
  78. roomkit/providers/sendgrid/config.py +13 -0
  79. roomkit/providers/sinch/__init__.py +6 -0
  80. roomkit/providers/sinch/config.py +22 -0
  81. roomkit/providers/sinch/sms.py +192 -0
  82. roomkit/providers/sms/__init__.py +15 -0
  83. roomkit/providers/sms/base.py +67 -0
  84. roomkit/providers/sms/meta.py +401 -0
  85. roomkit/providers/sms/mock.py +24 -0
  86. roomkit/providers/sms/phone.py +77 -0
  87. roomkit/providers/telnyx/__init__.py +21 -0
  88. roomkit/providers/telnyx/config.py +14 -0
  89. roomkit/providers/telnyx/rcs.py +352 -0
  90. roomkit/providers/telnyx/sms.py +231 -0
  91. roomkit/providers/twilio/__init__.py +18 -0
  92. roomkit/providers/twilio/config.py +19 -0
  93. roomkit/providers/twilio/rcs.py +183 -0
  94. roomkit/providers/twilio/sms.py +200 -0
  95. roomkit/providers/voicemeup/__init__.py +15 -0
  96. roomkit/providers/voicemeup/config.py +21 -0
  97. roomkit/providers/voicemeup/sms.py +374 -0
  98. roomkit/providers/whatsapp/__init__.py +0 -0
  99. roomkit/providers/whatsapp/base.py +44 -0
  100. roomkit/providers/whatsapp/mock.py +21 -0
  101. roomkit/py.typed +0 -0
  102. roomkit/realtime/__init__.py +17 -0
  103. roomkit/realtime/base.py +111 -0
  104. roomkit/realtime/memory.py +158 -0
  105. roomkit/sources/__init__.py +35 -0
  106. roomkit/sources/base.py +207 -0
  107. roomkit/sources/websocket.py +260 -0
  108. roomkit/store/__init__.py +0 -0
  109. roomkit/store/base.py +230 -0
  110. roomkit/store/memory.py +293 -0
  111. roomkit-0.1.0.dist-info/METADATA +567 -0
  112. roomkit-0.1.0.dist-info/RECORD +114 -0
  113. roomkit-0.1.0.dist-info/WHEEL +4 -0
  114. 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
@@ -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
+ ...
@@ -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)