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/channels/ai.py ADDED
@@ -0,0 +1,238 @@
1
+ """AI channel implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from roomkit.channels.base import Channel
9
+ from roomkit.models.channel import (
10
+ ChannelBinding,
11
+ ChannelCapabilities,
12
+ ChannelOutput,
13
+ )
14
+ from roomkit.models.context import RoomContext
15
+ from roomkit.models.delivery import InboundMessage
16
+ from roomkit.models.enums import (
17
+ ChannelCategory,
18
+ ChannelDirection,
19
+ ChannelMediaType,
20
+ ChannelType,
21
+ )
22
+ from roomkit.models.event import (
23
+ CompositeContent,
24
+ EventSource,
25
+ MediaContent,
26
+ RoomEvent,
27
+ TextContent,
28
+ )
29
+ from roomkit.providers.ai.base import (
30
+ AIContext,
31
+ AIImagePart,
32
+ AIMessage,
33
+ AIProvider,
34
+ AITextPart,
35
+ AITool,
36
+ ProviderError,
37
+ )
38
+
39
+ logger = logging.getLogger("roomkit.channels.ai")
40
+
41
+
42
+ class AIChannel(Channel):
43
+ """AI intelligence channel that generates responses using an AI provider."""
44
+
45
+ channel_type = ChannelType.AI
46
+ category = ChannelCategory.INTELLIGENCE
47
+ direction = ChannelDirection.BIDIRECTIONAL
48
+
49
+ def __init__(
50
+ self,
51
+ channel_id: str,
52
+ provider: AIProvider,
53
+ system_prompt: str | None = None,
54
+ temperature: float = 0.7,
55
+ max_tokens: int = 1024,
56
+ max_context_events: int = 50,
57
+ ) -> None:
58
+ super().__init__(channel_id)
59
+ self._provider = provider
60
+ self._system_prompt = system_prompt
61
+ self._temperature = temperature
62
+ self._max_tokens = max_tokens
63
+ self._max_context_events = max_context_events
64
+
65
+ @property
66
+ def info(self) -> dict[str, Any]:
67
+ return {"provider": type(self._provider).__name__}
68
+
69
+ def capabilities(self) -> ChannelCapabilities:
70
+ media_types = [ChannelMediaType.TEXT, ChannelMediaType.RICH]
71
+ if self._provider.supports_vision:
72
+ media_types.append(ChannelMediaType.MEDIA)
73
+ return ChannelCapabilities(
74
+ media_types=media_types,
75
+ supports_rich_text=True,
76
+ supports_media=self._provider.supports_vision,
77
+ )
78
+
79
+ async def handle_inbound(self, message: InboundMessage, context: RoomContext) -> RoomEvent:
80
+ raise NotImplementedError("AI channel does not accept inbound messages")
81
+
82
+ async def on_event(
83
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
84
+ ) -> ChannelOutput:
85
+ """React to an event by generating an AI response.
86
+
87
+ Skips events from this channel to prevent self-loops.
88
+ """
89
+ if event.source.channel_id == self.channel_id:
90
+ return ChannelOutput.empty()
91
+ return await self._generate_response(event, binding, context)
92
+
93
+ async def deliver(
94
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
95
+ ) -> ChannelOutput:
96
+ """Intelligence channels are not called via deliver by the router."""
97
+ return ChannelOutput.empty()
98
+
99
+ async def _generate_response(
100
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
101
+ ) -> ChannelOutput:
102
+ """Generate an AI response for the given event."""
103
+ ai_context = self._build_context(event, binding, context)
104
+ try:
105
+ response = await self._provider.generate(ai_context)
106
+ except ProviderError as exc:
107
+ logger.exception(
108
+ "AI provider error for channel %s",
109
+ self.channel_id,
110
+ extra={
111
+ "provider": exc.provider,
112
+ "retryable": exc.retryable,
113
+ "status_code": exc.status_code,
114
+ },
115
+ )
116
+ return ChannelOutput.empty()
117
+ except Exception:
118
+ logger.exception("AI provider failed for channel %s", self.channel_id)
119
+ return ChannelOutput.empty()
120
+
121
+ response_event = RoomEvent(
122
+ room_id=event.room_id,
123
+ source=EventSource(
124
+ channel_id=self.channel_id,
125
+ channel_type=self.channel_type,
126
+ ),
127
+ content=TextContent(body=response.content),
128
+ chain_depth=event.chain_depth + 1,
129
+ metadata={"ai_usage": response.usage},
130
+ )
131
+
132
+ return ChannelOutput(
133
+ responded=True,
134
+ response_events=[response_event],
135
+ )
136
+
137
+ def _build_context(
138
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
139
+ ) -> AIContext:
140
+ """Build AI context from room events.
141
+
142
+ Per-room overrides can be set via binding.metadata:
143
+ - system_prompt: Override the channel's default system prompt
144
+ - temperature: Override the channel's default temperature
145
+ - max_tokens: Override the channel's default max_tokens
146
+ - tools: List of tool definitions for function calling
147
+ """
148
+ # Per-room overrides from binding metadata
149
+ system_prompt = binding.metadata.get("system_prompt", self._system_prompt)
150
+ temperature = binding.metadata.get("temperature", self._temperature)
151
+ max_tokens = binding.metadata.get("max_tokens", self._max_tokens)
152
+ raw_tools = binding.metadata.get("tools", [])
153
+
154
+ # Convert raw tool dicts to AITool instances
155
+ tools = [
156
+ AITool(
157
+ name=t["name"],
158
+ description=t.get("description", ""),
159
+ parameters=t.get("parameters", {}),
160
+ )
161
+ for t in raw_tools
162
+ ]
163
+
164
+ messages: list[AIMessage] = []
165
+
166
+ for past_event in context.recent_events[-self._max_context_events :]:
167
+ role = self._determine_role(past_event)
168
+ content = self._extract_content(past_event)
169
+ if content:
170
+ messages.append(AIMessage(role=role, content=content))
171
+
172
+ # Add current event
173
+ content = self._extract_content(event)
174
+ if content:
175
+ messages.append(AIMessage(role="user", content=content))
176
+
177
+ # Determine target channel capabilities for capability-aware generation
178
+ transport_bindings = [
179
+ b
180
+ for b in context.bindings
181
+ if b.category == ChannelCategory.TRANSPORT and b.channel_id != self.channel_id
182
+ ]
183
+ target_caps = transport_bindings[0].capabilities if transport_bindings else None
184
+ target_media = target_caps.media_types if target_caps else []
185
+
186
+ return AIContext(
187
+ messages=messages,
188
+ system_prompt=system_prompt,
189
+ temperature=temperature,
190
+ max_tokens=max_tokens,
191
+ tools=tools,
192
+ room=context,
193
+ target_capabilities=target_caps,
194
+ target_media_types=target_media,
195
+ )
196
+
197
+ def _determine_role(self, event: RoomEvent) -> str:
198
+ if event.source.channel_id == self.channel_id:
199
+ return "assistant"
200
+ return "user"
201
+
202
+ def _extract_content(self, event: RoomEvent) -> str | list[AITextPart | AIImagePart]:
203
+ """Extract content, including images if provider supports vision."""
204
+ content = event.content
205
+
206
+ if not self._provider.supports_vision:
207
+ # Text-only fallback (existing behavior)
208
+ return self._extract_text(event)
209
+
210
+ # Build multimodal content
211
+ if isinstance(content, TextContent):
212
+ return content.body # Simple case: just text
213
+
214
+ if isinstance(content, MediaContent):
215
+ parts: list[AITextPart | AIImagePart] = []
216
+ if content.caption:
217
+ parts.append(AITextPart(text=content.caption))
218
+ parts.append(AIImagePart(url=content.url, mime_type=content.mime_type))
219
+ return parts
220
+
221
+ if isinstance(content, CompositeContent):
222
+ parts = []
223
+ for part in content.parts:
224
+ if isinstance(part, TextContent):
225
+ parts.append(AITextPart(text=part.body))
226
+ elif isinstance(part, MediaContent):
227
+ if part.caption:
228
+ parts.append(AITextPart(text=part.caption))
229
+ parts.append(AIImagePart(url=part.url, mime_type=part.mime_type))
230
+ return parts if parts else ""
231
+
232
+ # Fallback for other types
233
+ return self._extract_text(event)
234
+
235
+ def _extract_text(self, event: RoomEvent) -> str:
236
+ if isinstance(event.content, TextContent):
237
+ return event.content.body
238
+ return ""
@@ -0,0 +1,66 @@
1
+ """Abstract base class for channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from roomkit.models.channel import ChannelBinding, ChannelCapabilities, ChannelOutput
9
+ from roomkit.models.context import RoomContext
10
+ from roomkit.models.delivery import InboundMessage
11
+ from roomkit.models.enums import (
12
+ ChannelCategory,
13
+ ChannelDirection,
14
+ ChannelMediaType,
15
+ ChannelType,
16
+ )
17
+ from roomkit.models.event import RoomEvent, TextContent
18
+
19
+
20
+ class Channel(ABC):
21
+ """Base class for all channels."""
22
+
23
+ channel_type: ChannelType
24
+ category: ChannelCategory = ChannelCategory.TRANSPORT
25
+ direction: ChannelDirection = ChannelDirection.BIDIRECTIONAL
26
+
27
+ def __init__(self, channel_id: str) -> None:
28
+ self.channel_id = channel_id
29
+ self._provider: Any = None
30
+
31
+ @property
32
+ def info(self) -> dict[str, Any]:
33
+ """Return channel metadata. Override in subclasses."""
34
+ return {}
35
+
36
+ @abstractmethod
37
+ async def handle_inbound(self, message: InboundMessage, context: RoomContext) -> RoomEvent:
38
+ """Process an inbound message into a RoomEvent."""
39
+
40
+ @abstractmethod
41
+ async def deliver(
42
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
43
+ ) -> ChannelOutput:
44
+ """Deliver an event to this channel."""
45
+
46
+ async def on_event(
47
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
48
+ ) -> ChannelOutput:
49
+ """React to an event. Default: no-op for transport channels."""
50
+ return ChannelOutput.empty()
51
+
52
+ def capabilities(self) -> ChannelCapabilities:
53
+ """Return channel capabilities."""
54
+ return ChannelCapabilities(media_types=[ChannelMediaType.TEXT])
55
+
56
+ async def close(self) -> None:
57
+ """Close the channel and its provider."""
58
+ if self._provider is not None:
59
+ await self._provider.close()
60
+
61
+ @staticmethod
62
+ def extract_text(event: RoomEvent) -> str:
63
+ """Extract plain text from an event's content."""
64
+ if isinstance(event.content, TextContent):
65
+ return event.content.body
66
+ return ""
@@ -0,0 +1,115 @@
1
+ """Unified transport channel implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from roomkit.channels.base import Channel
9
+ from roomkit.models.channel import ChannelBinding, ChannelCapabilities, ChannelOutput
10
+ from roomkit.models.context import RoomContext
11
+ from roomkit.models.delivery import InboundMessage
12
+ from roomkit.models.enums import ChannelType
13
+ from roomkit.models.event import CompositeContent, EventSource, MediaContent, RoomEvent
14
+
15
+ logger = logging.getLogger("roomkit.channels.transport")
16
+
17
+
18
+ class TransportChannel(Channel):
19
+ """Generic transport channel driven by configuration rather than subclassing.
20
+
21
+ All transport channels (SMS, Email, WhatsApp, Messenger, HTTP) share the
22
+ same inbound/deliver logic. The only differences are data: which
23
+ ``ChannelType``, which ``ChannelCapabilities``, which metadata key holds the
24
+ recipient address, and which extra kwargs to pass to the provider's
25
+ ``send()`` method.
26
+
27
+ Use the factory functions (``SMSChannel``, ``EmailChannel``, …) in
28
+ ``roomkit.channels`` for convenient construction.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ channel_id: str,
34
+ channel_type: ChannelType,
35
+ *,
36
+ provider: Any = None,
37
+ capabilities: ChannelCapabilities | None = None,
38
+ recipient_key: str = "recipient_id",
39
+ defaults: dict[str, Any] | None = None,
40
+ ) -> None:
41
+ """Initialise a transport channel.
42
+
43
+ Args:
44
+ channel_id: Unique identifier for this channel instance.
45
+ channel_type: The channel type (SMS, email, etc.).
46
+ provider: Provider that handles external delivery (e.g. ElasticEmailProvider).
47
+ capabilities: Media and feature capabilities for this channel.
48
+ recipient_key: Binding metadata key that holds the recipient address.
49
+ defaults: Default kwargs passed to ``provider.send()``. If a default
50
+ value is ``None``, the actual value is read from the binding metadata
51
+ at delivery time.
52
+ """
53
+ super().__init__(channel_id)
54
+ self.channel_type = channel_type
55
+ self._provider = provider
56
+ self._capabilities = capabilities or ChannelCapabilities()
57
+ self._recipient_key = recipient_key
58
+ self._defaults: dict[str, Any] = defaults or {}
59
+
60
+ @property
61
+ def info(self) -> dict[str, Any]:
62
+ """Return non-None default values as channel info metadata."""
63
+ return {k: v for k, v in self._defaults.items() if v is not None}
64
+
65
+ def capabilities(self) -> ChannelCapabilities:
66
+ """Return the channel's media and feature capabilities."""
67
+ return self._capabilities
68
+
69
+ async def handle_inbound(self, message: InboundMessage, context: RoomContext) -> RoomEvent:
70
+ """Convert an inbound message into a room event."""
71
+ # Use MMS channel type for SMS with media content
72
+ channel_type = self.channel_type
73
+ if channel_type == ChannelType.SMS and self._has_media(message.content):
74
+ channel_type = ChannelType.MMS
75
+
76
+ return RoomEvent(
77
+ room_id=context.room.id,
78
+ source=EventSource(
79
+ channel_id=self.channel_id,
80
+ channel_type=channel_type,
81
+ external_id=message.external_id,
82
+ ),
83
+ content=message.content,
84
+ idempotency_key=message.idempotency_key,
85
+ metadata=message.metadata,
86
+ )
87
+
88
+ @staticmethod
89
+ def _has_media(content: Any) -> bool:
90
+ """Check if content contains media."""
91
+ if isinstance(content, MediaContent):
92
+ return True
93
+ if isinstance(content, CompositeContent):
94
+ return any(isinstance(part, MediaContent) for part in content.parts)
95
+ return False
96
+
97
+ async def deliver(
98
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
99
+ ) -> ChannelOutput:
100
+ """Deliver an event to the external recipient via the provider.
101
+
102
+ The recipient address is read from ``binding.metadata[recipient_key]``.
103
+ Extra kwargs are built from ``defaults``: fixed values are passed as-is,
104
+ ``None`` defaults are resolved from binding metadata at delivery time.
105
+ """
106
+ if self._provider is None:
107
+ logger.debug("No provider configured for %s, skipping delivery", self.channel_id)
108
+ return ChannelOutput.empty()
109
+
110
+ to = binding.metadata.get(self._recipient_key, "")
111
+ kwargs: dict[str, Any] = {}
112
+ for key, value in self._defaults.items():
113
+ kwargs[key] = binding.metadata.get(key, value) if value is None else value
114
+ await self._provider.send(event, to=to, **kwargs)
115
+ return ChannelOutput.empty()
@@ -0,0 +1,85 @@
1
+ """WebSocket channel implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable, Coroutine
7
+ from typing import Any
8
+
9
+ from roomkit.channels.base import Channel
10
+ from roomkit.models.channel import ChannelBinding, ChannelCapabilities, ChannelOutput
11
+ from roomkit.models.context import RoomContext
12
+ from roomkit.models.delivery import InboundMessage
13
+ from roomkit.models.enums import ChannelMediaType, ChannelType
14
+ from roomkit.models.event import EventSource, RoomEvent
15
+
16
+ logger = logging.getLogger("roomkit.channels.websocket")
17
+
18
+ SendFn = Callable[[str, RoomEvent], Coroutine[Any, Any, None]]
19
+
20
+
21
+ class WebSocketChannel(Channel):
22
+ """WebSocket transport channel with connection registry."""
23
+
24
+ channel_type = ChannelType.WEBSOCKET
25
+
26
+ def __init__(self, channel_id: str) -> None:
27
+ super().__init__(channel_id)
28
+ self._connections: dict[str, SendFn] = {}
29
+
30
+ @property
31
+ def info(self) -> dict[str, Any]:
32
+ return {"connection_count": len(self._connections)}
33
+
34
+ def capabilities(self) -> ChannelCapabilities:
35
+ return ChannelCapabilities(
36
+ media_types=[
37
+ ChannelMediaType.TEXT,
38
+ ChannelMediaType.RICH,
39
+ ChannelMediaType.MEDIA,
40
+ ],
41
+ supports_typing=True,
42
+ supports_read_receipts=True,
43
+ supports_reactions=True,
44
+ supports_rich_text=True,
45
+ supports_media=True,
46
+ supports_buttons=True,
47
+ supports_cards=True,
48
+ supports_quick_replies=True,
49
+ )
50
+
51
+ def register_connection(self, connection_id: str, send_fn: SendFn) -> None:
52
+ """Register a WebSocket connection."""
53
+ self._connections[connection_id] = send_fn
54
+
55
+ def unregister_connection(self, connection_id: str) -> None:
56
+ """Unregister a WebSocket connection."""
57
+ self._connections.pop(connection_id, None)
58
+
59
+ @property
60
+ def connection_count(self) -> int:
61
+ return len(self._connections)
62
+
63
+ async def handle_inbound(self, message: InboundMessage, context: RoomContext) -> RoomEvent:
64
+ return RoomEvent(
65
+ room_id=context.room.id,
66
+ source=EventSource(
67
+ channel_id=self.channel_id,
68
+ channel_type=self.channel_type,
69
+ participant_id=message.sender_id,
70
+ ),
71
+ content=message.content,
72
+ idempotency_key=message.idempotency_key,
73
+ metadata=message.metadata,
74
+ )
75
+
76
+ async def deliver(
77
+ self, event: RoomEvent, binding: ChannelBinding, context: RoomContext
78
+ ) -> ChannelOutput:
79
+ for conn_id, send_fn in list(self._connections.items()):
80
+ try:
81
+ await send_fn(conn_id, event)
82
+ except Exception:
83
+ logger.exception("WebSocket send failed for connection %s", conn_id)
84
+ self._connections.pop(conn_id, None)
85
+ return ChannelOutput.empty()
File without changes