roomkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roomkit/AGENTS.md +362 -0
- roomkit/__init__.py +372 -0
- roomkit/_version.py +1 -0
- roomkit/ai_docs.py +93 -0
- roomkit/channels/__init__.py +194 -0
- roomkit/channels/ai.py +238 -0
- roomkit/channels/base.py +66 -0
- roomkit/channels/transport.py +115 -0
- roomkit/channels/websocket.py +85 -0
- roomkit/core/__init__.py +0 -0
- roomkit/core/_channel_ops.py +252 -0
- roomkit/core/_helpers.py +296 -0
- roomkit/core/_inbound.py +435 -0
- roomkit/core/_room_lifecycle.py +275 -0
- roomkit/core/circuit_breaker.py +84 -0
- roomkit/core/event_router.py +401 -0
- roomkit/core/framework.py +793 -0
- roomkit/core/hooks.py +232 -0
- roomkit/core/inbound_router.py +57 -0
- roomkit/core/locks.py +66 -0
- roomkit/core/rate_limiter.py +67 -0
- roomkit/core/retry.py +49 -0
- roomkit/core/router.py +24 -0
- roomkit/core/transcoder.py +85 -0
- roomkit/identity/__init__.py +0 -0
- roomkit/identity/base.py +27 -0
- roomkit/identity/mock.py +49 -0
- roomkit/llms.txt +52 -0
- roomkit/models/__init__.py +104 -0
- roomkit/models/channel.py +99 -0
- roomkit/models/context.py +35 -0
- roomkit/models/delivery.py +76 -0
- roomkit/models/enums.py +170 -0
- roomkit/models/event.py +203 -0
- roomkit/models/framework_event.py +19 -0
- roomkit/models/hook.py +68 -0
- roomkit/models/identity.py +81 -0
- roomkit/models/participant.py +34 -0
- roomkit/models/room.py +33 -0
- roomkit/models/task.py +36 -0
- roomkit/providers/__init__.py +0 -0
- roomkit/providers/ai/__init__.py +0 -0
- roomkit/providers/ai/base.py +140 -0
- roomkit/providers/ai/mock.py +33 -0
- roomkit/providers/anthropic/__init__.py +6 -0
- roomkit/providers/anthropic/ai.py +145 -0
- roomkit/providers/anthropic/config.py +14 -0
- roomkit/providers/elasticemail/__init__.py +6 -0
- roomkit/providers/elasticemail/config.py +16 -0
- roomkit/providers/elasticemail/email.py +97 -0
- roomkit/providers/email/__init__.py +0 -0
- roomkit/providers/email/base.py +46 -0
- roomkit/providers/email/mock.py +34 -0
- roomkit/providers/gemini/__init__.py +6 -0
- roomkit/providers/gemini/ai.py +153 -0
- roomkit/providers/gemini/config.py +14 -0
- roomkit/providers/http/__init__.py +15 -0
- roomkit/providers/http/base.py +33 -0
- roomkit/providers/http/config.py +14 -0
- roomkit/providers/http/mock.py +21 -0
- roomkit/providers/http/provider.py +105 -0
- roomkit/providers/http/webhook.py +33 -0
- roomkit/providers/messenger/__init__.py +15 -0
- roomkit/providers/messenger/base.py +33 -0
- roomkit/providers/messenger/config.py +17 -0
- roomkit/providers/messenger/facebook.py +95 -0
- roomkit/providers/messenger/mock.py +21 -0
- roomkit/providers/messenger/webhook.py +42 -0
- roomkit/providers/openai/__init__.py +6 -0
- roomkit/providers/openai/ai.py +155 -0
- roomkit/providers/openai/config.py +24 -0
- roomkit/providers/pydantic_ai/__init__.py +5 -0
- roomkit/providers/pydantic_ai/config.py +14 -0
- roomkit/providers/rcs/__init__.py +9 -0
- roomkit/providers/rcs/base.py +95 -0
- roomkit/providers/rcs/mock.py +78 -0
- roomkit/providers/sendgrid/__init__.py +5 -0
- roomkit/providers/sendgrid/config.py +13 -0
- roomkit/providers/sinch/__init__.py +6 -0
- roomkit/providers/sinch/config.py +22 -0
- roomkit/providers/sinch/sms.py +192 -0
- roomkit/providers/sms/__init__.py +15 -0
- roomkit/providers/sms/base.py +67 -0
- roomkit/providers/sms/meta.py +401 -0
- roomkit/providers/sms/mock.py +24 -0
- roomkit/providers/sms/phone.py +77 -0
- roomkit/providers/telnyx/__init__.py +21 -0
- roomkit/providers/telnyx/config.py +14 -0
- roomkit/providers/telnyx/rcs.py +352 -0
- roomkit/providers/telnyx/sms.py +231 -0
- roomkit/providers/twilio/__init__.py +18 -0
- roomkit/providers/twilio/config.py +19 -0
- roomkit/providers/twilio/rcs.py +183 -0
- roomkit/providers/twilio/sms.py +200 -0
- roomkit/providers/voicemeup/__init__.py +15 -0
- roomkit/providers/voicemeup/config.py +21 -0
- roomkit/providers/voicemeup/sms.py +374 -0
- roomkit/providers/whatsapp/__init__.py +0 -0
- roomkit/providers/whatsapp/base.py +44 -0
- roomkit/providers/whatsapp/mock.py +21 -0
- roomkit/py.typed +0 -0
- roomkit/realtime/__init__.py +17 -0
- roomkit/realtime/base.py +111 -0
- roomkit/realtime/memory.py +158 -0
- roomkit/sources/__init__.py +35 -0
- roomkit/sources/base.py +207 -0
- roomkit/sources/websocket.py +260 -0
- roomkit/store/__init__.py +0 -0
- roomkit/store/base.py +230 -0
- roomkit/store/memory.py +293 -0
- roomkit-0.1.0.dist-info/METADATA +567 -0
- roomkit-0.1.0.dist-info/RECORD +114 -0
- roomkit-0.1.0.dist-info/WHEEL +4 -0
- roomkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Event routing with permission enforcement and transcoding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from roomkit.channels.base import Channel
|
|
11
|
+
from roomkit.core.circuit_breaker import CircuitBreaker
|
|
12
|
+
from roomkit.core.rate_limiter import TokenBucketRateLimiter
|
|
13
|
+
from roomkit.core.retry import retry_with_backoff
|
|
14
|
+
from roomkit.core.transcoder import DefaultContentTranscoder
|
|
15
|
+
from roomkit.models.channel import ChannelBinding, ChannelOutput
|
|
16
|
+
from roomkit.models.context import RoomContext
|
|
17
|
+
from roomkit.models.enums import Access, ChannelCategory, ChannelDirection, EventStatus
|
|
18
|
+
from roomkit.models.event import RoomEvent, TextContent
|
|
19
|
+
from roomkit.models.task import Observation, Task
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("roomkit.event_router")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BroadcastResult:
|
|
26
|
+
"""Result of broadcasting an event to channels."""
|
|
27
|
+
|
|
28
|
+
outputs: dict[str, ChannelOutput] = field(default_factory=dict)
|
|
29
|
+
delivery_outputs: dict[str, ChannelOutput] = field(default_factory=dict)
|
|
30
|
+
reentry_events: list[RoomEvent] = field(default_factory=list)
|
|
31
|
+
tasks: list[Task] = field(default_factory=list)
|
|
32
|
+
observations: list[Observation] = field(default_factory=list)
|
|
33
|
+
metadata_updates: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
blocked_events: list[RoomEvent] = field(default_factory=list)
|
|
35
|
+
errors: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _TargetResult:
|
|
40
|
+
"""Per-target result collected during concurrent broadcast."""
|
|
41
|
+
|
|
42
|
+
channel_id: str
|
|
43
|
+
output: ChannelOutput | None = None
|
|
44
|
+
delivery_output: ChannelOutput | None = None
|
|
45
|
+
error: str | None = None
|
|
46
|
+
reentry_events: list[RoomEvent] = field(default_factory=list)
|
|
47
|
+
blocked_events: list[RoomEvent] = field(default_factory=list)
|
|
48
|
+
observations: list[Observation] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EventRouter:
|
|
52
|
+
"""Routes events to target channels with access control and transcoding."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
channels: dict[str, Channel],
|
|
57
|
+
transcoder: DefaultContentTranscoder | None = None,
|
|
58
|
+
max_chain_depth: int = 5,
|
|
59
|
+
rate_limiter: TokenBucketRateLimiter | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._channels = channels
|
|
62
|
+
self._transcoder = transcoder or DefaultContentTranscoder()
|
|
63
|
+
self._max_chain_depth = max_chain_depth
|
|
64
|
+
self._rate_limiter = rate_limiter or TokenBucketRateLimiter()
|
|
65
|
+
self._circuit_breakers: dict[str, CircuitBreaker] = {}
|
|
66
|
+
|
|
67
|
+
def _get_breaker(self, channel_id: str) -> CircuitBreaker:
|
|
68
|
+
"""Get or create a circuit breaker for a channel."""
|
|
69
|
+
if channel_id not in self._circuit_breakers:
|
|
70
|
+
self._circuit_breakers[channel_id] = CircuitBreaker()
|
|
71
|
+
return self._circuit_breakers[channel_id]
|
|
72
|
+
|
|
73
|
+
async def broadcast(
|
|
74
|
+
self,
|
|
75
|
+
event: RoomEvent,
|
|
76
|
+
source_binding: ChannelBinding,
|
|
77
|
+
context: RoomContext,
|
|
78
|
+
) -> BroadcastResult:
|
|
79
|
+
"""Broadcast an event to all eligible channels in the room.
|
|
80
|
+
|
|
81
|
+
RFC §3.8: For each target channel:
|
|
82
|
+
- on_event(): all channels react (intelligence generates, observers analyze)
|
|
83
|
+
- deliver(): only transport channels push to external recipients
|
|
84
|
+
"""
|
|
85
|
+
result = BroadcastResult()
|
|
86
|
+
|
|
87
|
+
# Check source can write
|
|
88
|
+
if source_binding.access in (Access.READ_ONLY, Access.NONE):
|
|
89
|
+
logger.debug(
|
|
90
|
+
"Source %s has no write access",
|
|
91
|
+
source_binding.channel_id,
|
|
92
|
+
extra={"room_id": event.room_id, "channel_id": source_binding.channel_id},
|
|
93
|
+
)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
# Check source is not muted
|
|
97
|
+
if source_binding.muted:
|
|
98
|
+
logger.debug(
|
|
99
|
+
"Source %s is muted",
|
|
100
|
+
source_binding.channel_id,
|
|
101
|
+
extra={"room_id": event.room_id, "channel_id": source_binding.channel_id},
|
|
102
|
+
)
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
# Stamp visibility from source binding
|
|
106
|
+
if event.visibility == "all" and source_binding.visibility != "all":
|
|
107
|
+
event = event.model_copy(update={"visibility": source_binding.visibility})
|
|
108
|
+
|
|
109
|
+
# Determine target bindings (includes muted channels — they can still read)
|
|
110
|
+
targets = self._filter_targets(event, source_binding, context.bindings)
|
|
111
|
+
|
|
112
|
+
if not targets:
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
# Collect per-target results to avoid concurrent mutation
|
|
116
|
+
target_results: list[_TargetResult] = []
|
|
117
|
+
|
|
118
|
+
async def _process_target(binding: ChannelBinding) -> None:
|
|
119
|
+
channel = self._channels.get(binding.channel_id)
|
|
120
|
+
if channel is None:
|
|
121
|
+
logger.warning(
|
|
122
|
+
"Channel %s not found in registry, skipping. Available: %s",
|
|
123
|
+
binding.channel_id,
|
|
124
|
+
list(self._channels.keys()),
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
tr = _TargetResult(channel_id=binding.channel_id)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
# Transcode content if needed
|
|
132
|
+
transcoded_event = await self._maybe_transcode(event, source_binding, binding)
|
|
133
|
+
if transcoded_event is None:
|
|
134
|
+
tr.error = "transcoding_failed"
|
|
135
|
+
logger.warning(
|
|
136
|
+
"Transcoding failed for channel %s — skipping delivery",
|
|
137
|
+
binding.channel_id,
|
|
138
|
+
extra={
|
|
139
|
+
"room_id": event.room_id,
|
|
140
|
+
"channel_id": binding.channel_id,
|
|
141
|
+
"event_id": event.id,
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
target_results.append(tr)
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
# Enforce max_length on text content
|
|
148
|
+
if binding.capabilities.max_length is not None:
|
|
149
|
+
transcoded_event = self._enforce_max_length(
|
|
150
|
+
transcoded_event, binding.capabilities.max_length
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Step 1: on_event — all channels react
|
|
154
|
+
output = await channel.on_event(transcoded_event, binding, context)
|
|
155
|
+
tr.output = output
|
|
156
|
+
|
|
157
|
+
# Step 2: deliver — only transport channels push to external
|
|
158
|
+
if binding.category == ChannelCategory.TRANSPORT:
|
|
159
|
+
breaker = self._get_breaker(binding.channel_id)
|
|
160
|
+
|
|
161
|
+
if not breaker.allow_request():
|
|
162
|
+
tr.error = "circuit_breaker_open"
|
|
163
|
+
logger.warning(
|
|
164
|
+
"Circuit breaker open for %s — skipping delivery",
|
|
165
|
+
binding.channel_id,
|
|
166
|
+
extra={
|
|
167
|
+
"room_id": transcoded_event.room_id,
|
|
168
|
+
"channel_id": binding.channel_id,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
# Rate limit
|
|
173
|
+
if binding.rate_limit is not None:
|
|
174
|
+
await self._rate_limiter.wait(binding.channel_id, binding.rate_limit)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
if binding.retry_policy is not None:
|
|
178
|
+
delivery_output = await retry_with_backoff(
|
|
179
|
+
channel.deliver,
|
|
180
|
+
binding.retry_policy,
|
|
181
|
+
transcoded_event,
|
|
182
|
+
binding,
|
|
183
|
+
context,
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
delivery_output = await channel.deliver(
|
|
187
|
+
transcoded_event, binding, context
|
|
188
|
+
)
|
|
189
|
+
tr.delivery_output = delivery_output
|
|
190
|
+
breaker.record_success()
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
breaker.record_failure()
|
|
193
|
+
tr.error = str(exc)
|
|
194
|
+
logger.exception(
|
|
195
|
+
"Delivery to %s failed",
|
|
196
|
+
binding.channel_id,
|
|
197
|
+
extra={
|
|
198
|
+
"room_id": event.room_id,
|
|
199
|
+
"channel_id": binding.channel_id,
|
|
200
|
+
"event_id": event.id,
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Always collect side effects (tasks, observations, metadata)
|
|
205
|
+
# regardless of mute status — RFC: "muting silences the voice,
|
|
206
|
+
# not the brain"
|
|
207
|
+
tr.observations.extend(output.observations)
|
|
208
|
+
|
|
209
|
+
# Muted channels: suppress response_events (the "voice")
|
|
210
|
+
if binding.muted:
|
|
211
|
+
if output.responded:
|
|
212
|
+
logger.debug(
|
|
213
|
+
"Channel %s is muted — suppressing %d response events, "
|
|
214
|
+
"keeping %d tasks, %d observations",
|
|
215
|
+
binding.channel_id,
|
|
216
|
+
len(output.response_events),
|
|
217
|
+
len(output.tasks),
|
|
218
|
+
len(output.observations),
|
|
219
|
+
)
|
|
220
|
+
target_results.append(tr)
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Collect reentry events with chain depth enforcement
|
|
224
|
+
if output.responded:
|
|
225
|
+
for resp in output.response_events:
|
|
226
|
+
if resp.chain_depth < self._max_chain_depth:
|
|
227
|
+
tr.reentry_events.append(resp)
|
|
228
|
+
else:
|
|
229
|
+
blocked = resp.model_copy(
|
|
230
|
+
update={
|
|
231
|
+
"status": EventStatus.BLOCKED,
|
|
232
|
+
"blocked_by": "event_chain_depth_limit",
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
tr.blocked_events.append(blocked)
|
|
236
|
+
tr.observations.append(
|
|
237
|
+
Observation(
|
|
238
|
+
id=f"obs_{blocked.id}",
|
|
239
|
+
room_id=event.room_id,
|
|
240
|
+
channel_id=binding.channel_id,
|
|
241
|
+
content=(
|
|
242
|
+
f"Event chain depth {resp.chain_depth}"
|
|
243
|
+
f" exceeded limit {self._max_chain_depth}"
|
|
244
|
+
),
|
|
245
|
+
category="event_chain_depth_exceeded",
|
|
246
|
+
metadata={
|
|
247
|
+
"chain_depth": resp.chain_depth,
|
|
248
|
+
"max_chain_depth": self._max_chain_depth,
|
|
249
|
+
"source_channel": binding.channel_id,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
logger.warning(
|
|
254
|
+
"Chain depth %d exceeded limit %d for channel %s — event blocked",
|
|
255
|
+
resp.chain_depth,
|
|
256
|
+
self._max_chain_depth,
|
|
257
|
+
binding.channel_id,
|
|
258
|
+
extra={
|
|
259
|
+
"room_id": event.room_id,
|
|
260
|
+
"channel_id": binding.channel_id,
|
|
261
|
+
"chain_depth": resp.chain_depth,
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
tr.error = str(exc)
|
|
267
|
+
logger.exception(
|
|
268
|
+
"Processing target %s failed",
|
|
269
|
+
binding.channel_id,
|
|
270
|
+
extra={
|
|
271
|
+
"room_id": event.room_id,
|
|
272
|
+
"channel_id": binding.channel_id,
|
|
273
|
+
"event_id": event.id,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
target_results.append(tr)
|
|
278
|
+
|
|
279
|
+
await asyncio.gather(*[_process_target(t) for t in targets], return_exceptions=True)
|
|
280
|
+
|
|
281
|
+
# Merge per-target results into BroadcastResult (single-threaded)
|
|
282
|
+
for tr in target_results:
|
|
283
|
+
if tr.output is not None:
|
|
284
|
+
result.outputs[tr.channel_id] = tr.output
|
|
285
|
+
result.tasks.extend(tr.output.tasks)
|
|
286
|
+
result.metadata_updates.update(tr.output.metadata_updates)
|
|
287
|
+
if tr.delivery_output is not None:
|
|
288
|
+
result.delivery_outputs[tr.channel_id] = tr.delivery_output
|
|
289
|
+
if tr.error is not None:
|
|
290
|
+
result.errors[tr.channel_id] = tr.error
|
|
291
|
+
result.reentry_events.extend(tr.reentry_events)
|
|
292
|
+
result.blocked_events.extend(tr.blocked_events)
|
|
293
|
+
result.observations.extend(tr.observations)
|
|
294
|
+
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
def _filter_targets(
|
|
298
|
+
self,
|
|
299
|
+
event: RoomEvent,
|
|
300
|
+
source_binding: ChannelBinding,
|
|
301
|
+
all_bindings: list[ChannelBinding],
|
|
302
|
+
) -> list[ChannelBinding]:
|
|
303
|
+
"""Filter bindings to find valid delivery targets.
|
|
304
|
+
|
|
305
|
+
Muted channels ARE included — they can still receive events via on_event()
|
|
306
|
+
and produce side effects (tasks, observations). Their response_events are
|
|
307
|
+
suppressed in broadcast().
|
|
308
|
+
"""
|
|
309
|
+
targets: list[ChannelBinding] = []
|
|
310
|
+
|
|
311
|
+
for binding in all_bindings:
|
|
312
|
+
# Skip source channel
|
|
313
|
+
if binding.channel_id == source_binding.channel_id:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Check access - must be able to read
|
|
317
|
+
if binding.access in (Access.WRITE_ONLY, Access.NONE):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Check direction - must accept inbound delivery
|
|
321
|
+
if binding.direction == ChannelDirection.OUTBOUND:
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
# NOTE: muted channels are NOT skipped here — they receive events
|
|
325
|
+
# but their response_events are suppressed in broadcast()
|
|
326
|
+
|
|
327
|
+
# Check visibility
|
|
328
|
+
if not self._check_visibility(event, source_binding, binding):
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
targets.append(binding)
|
|
332
|
+
|
|
333
|
+
return targets
|
|
334
|
+
|
|
335
|
+
def _check_visibility(
|
|
336
|
+
self,
|
|
337
|
+
event: RoomEvent,
|
|
338
|
+
source_binding: ChannelBinding,
|
|
339
|
+
target_binding: ChannelBinding,
|
|
340
|
+
) -> bool:
|
|
341
|
+
"""Check if source is visible to target based on visibility rules."""
|
|
342
|
+
vis = source_binding.visibility
|
|
343
|
+
|
|
344
|
+
if vis == "all":
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
if vis == "none":
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
# "transport" - only visible to transport channels
|
|
351
|
+
if vis == "transport":
|
|
352
|
+
return target_binding.category == ChannelCategory.TRANSPORT
|
|
353
|
+
|
|
354
|
+
# "intelligence" - only visible to intelligence channels
|
|
355
|
+
if vis == "intelligence":
|
|
356
|
+
return target_binding.category == ChannelCategory.INTELLIGENCE
|
|
357
|
+
|
|
358
|
+
# Comma-separated list of channel IDs
|
|
359
|
+
if "," in vis:
|
|
360
|
+
allowed = {cid.strip() for cid in vis.split(",")}
|
|
361
|
+
return target_binding.channel_id in allowed
|
|
362
|
+
|
|
363
|
+
# Single channel ID
|
|
364
|
+
return target_binding.channel_id == vis
|
|
365
|
+
|
|
366
|
+
async def _maybe_transcode(
|
|
367
|
+
self,
|
|
368
|
+
event: RoomEvent,
|
|
369
|
+
source_binding: ChannelBinding,
|
|
370
|
+
target_binding: ChannelBinding,
|
|
371
|
+
) -> RoomEvent | None:
|
|
372
|
+
"""Transcode event content if the target doesn't support it.
|
|
373
|
+
|
|
374
|
+
Returns ``None`` if the content cannot be transcoded for the target.
|
|
375
|
+
"""
|
|
376
|
+
source_types = set(source_binding.capabilities.media_types)
|
|
377
|
+
target_types = set(target_binding.capabilities.media_types)
|
|
378
|
+
|
|
379
|
+
if source_types <= target_types:
|
|
380
|
+
return event
|
|
381
|
+
|
|
382
|
+
transcoded_content = await self._transcoder.transcode(
|
|
383
|
+
event.content, source_binding, target_binding
|
|
384
|
+
)
|
|
385
|
+
if transcoded_content is None:
|
|
386
|
+
return None
|
|
387
|
+
if transcoded_content is event.content:
|
|
388
|
+
return event
|
|
389
|
+
|
|
390
|
+
return event.model_copy(update={"content": transcoded_content})
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _enforce_max_length(event: RoomEvent, max_length: int) -> RoomEvent:
|
|
394
|
+
"""Truncate text content if it exceeds the channel's max_length."""
|
|
395
|
+
content = event.content
|
|
396
|
+
if isinstance(content, TextContent) and len(content.body) > max_length:
|
|
397
|
+
truncated = content.body[: max_length - 3] + "..."
|
|
398
|
+
return event.model_copy(
|
|
399
|
+
update={"content": TextContent(body=truncated, language=content.language)}
|
|
400
|
+
)
|
|
401
|
+
return event
|