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/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 ""
|
roomkit/channels/base.py
ADDED
|
@@ -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()
|
roomkit/core/__init__.py
ADDED
|
File without changes
|