tiphys.channels

Tiphys channel abstraction.

Provides the foundation for multi-platform messaging support.

 1"""
 2Tiphys channel abstraction.
 3
 4Provides the foundation for multi-platform messaging support.
 5"""
 6
 7from tiphys.channels.base import (
 8    Attachment,
 9    ChannelCapabilities,
10    ChannelContext,
11    ChannelDock,
12    ChatContext,
13    InboundMessage,
14    OutboundMessage,
15    Peer,
16)
17from tiphys.channels.bridge import (
18    BridgeManager,
19    ChannelBridge,
20    create_bridge,
21)
22from tiphys.channels.registry import (
23    ChannelRegistry,
24    get_channel_registry,
25    register_builtin_channels,
26)
27from tiphys.channels.webchat import WebChatChannel, create_webchat_channel
28
29__all__ = [
30    # Base models
31    "Peer",
32    "Attachment",
33    "InboundMessage",
34    "OutboundMessage",
35    "ChannelCapabilities",
36    "ChannelContext",
37    "ChatContext",
38    # Protocol
39    "ChannelDock",
40    # Bridge
41    "ChannelBridge",
42    "BridgeManager",
43    "create_bridge",
44    # Registry
45    "ChannelRegistry",
46    "get_channel_registry",
47    "register_builtin_channels",
48    # WebChat
49    "WebChatChannel",
50    "create_webchat_channel",
51]
class Peer(pydantic.main.BaseModel):
31class Peer(BaseModel):
32    """Represents a user or entity on a messaging platform."""
33
34    id: str
35    kind: PeerKind = PeerKind.USER
36    username: str | None = None
37    display_name: str | None = None
38    is_bot: bool = False
39    raw: dict[str, Any] = Field(default_factory=dict)

Represents a user or entity on a messaging platform.

id: str = PydanticUndefined
kind: tiphys.channels.base.PeerKind = <PeerKind.USER: 'user'>
username: str | None = None
display_name: str | None = None
is_bot: bool = False
raw: dict[str, typing.Any] = PydanticUndefined
class Attachment(pydantic.main.BaseModel):
42class Attachment(BaseModel):
43    """File attachment in a message."""
44
45    url: str
46    mime_type: str
47    filename: str | None = None
48    size_bytes: int | None = None

File attachment in a message.

url: str = PydanticUndefined
mime_type: str = PydanticUndefined
filename: str | None = None
size_bytes: int | None = None
class InboundMessage(pydantic.main.BaseModel):
51class InboundMessage(BaseModel):
52    """Normalized message from any channel."""
53
54    id: str
55    channel: str  # "telegram", "discord", "webchat"
56    sender: Peer
57    content: str  # Markdown text
58    attachments: list[Attachment] = Field(default_factory=list)
59    reply_to: str | None = None  # Parent message ID
60    thread_id: str | None = None  # Thread context
61    timestamp: datetime = Field(default_factory=datetime.utcnow)
62    raw: dict[str, Any] = Field(default_factory=dict)  # Original platform payload

Normalized message from any channel.

id: str = PydanticUndefined
channel: str = PydanticUndefined
sender: Peer = PydanticUndefined
content: str = PydanticUndefined
attachments: list[Attachment] = PydanticUndefined
reply_to: str | None = None
thread_id: str | None = None
timestamp: datetime.datetime = PydanticUndefined
raw: dict[str, typing.Any] = PydanticUndefined
class OutboundMessage(pydantic.main.BaseModel):
65class OutboundMessage(BaseModel):
66    """Message to send to a channel."""
67
68    content: str  # Markdown text
69    attachments: list[Attachment] = Field(default_factory=list)
70    reply_to: str | None = None
71    thread_id: str | None = None
72    metadata: dict[str, Any] = Field(default_factory=dict)

Message to send to a channel.

content: str = PydanticUndefined
attachments: list[Attachment] = PydanticUndefined
reply_to: str | None = None
thread_id: str | None = None
metadata: dict[str, typing.Any] = PydanticUndefined
class ChannelCapabilities(pydantic.main.BaseModel):
75class ChannelCapabilities(BaseModel):
76    """What a channel can do."""
77
78    chat_types: list[str] = Field(default_factory=lambda: ["direct"])
79    supports_streaming: bool = False
80    supports_reactions: bool = False
81    supports_media: bool = True
82    supports_threads: bool = False
83    supports_editing: bool = False
84    text_chunk_limit: int = 4000  # Max characters per message

What a channel can do.

chat_types: list[str] = PydanticUndefined
supports_streaming: bool = False
supports_reactions: bool = False
supports_media: bool = True
supports_threads: bool = False
supports_editing: bool = False
text_chunk_limit: int = 4000
class ChannelContext(pydantic.main.BaseModel):
87class ChannelContext(BaseModel):
88    """Context for sending messages."""
89
90    channel_id: str
91    thread_id: str | None = None
92    reply_to: str | None = None
93    metadata: dict[str, Any] = Field(default_factory=dict)

Context for sending messages.

channel_id: str = PydanticUndefined
thread_id: str | None = None
reply_to: str | None = None
metadata: dict[str, typing.Any] = PydanticUndefined
class ChatContext(pydantic.main.BaseModel):
 96class ChatContext(BaseModel):
 97    """Context for permission resolution."""
 98
 99    channel: str
100    peer_id: str
101    group_id: str | None = None
102    thread_id: str | None = None
103    is_dm: bool = True

Context for permission resolution.

channel: str = PydanticUndefined
peer_id: str = PydanticUndefined
group_id: str | None = None
thread_id: str | None = None
is_dm: bool = True
@runtime_checkable
class ChannelDock(typing.Protocol):
106@runtime_checkable
107class ChannelDock(Protocol):
108    """Abstract interface for messaging channel adapters."""
109
110    @property
111    def name(self) -> str:
112        """Channel identifier (e.g., 'telegram', 'discord')."""
113        ...
114
115    @property
116    def capabilities(self) -> ChannelCapabilities:
117        """What this channel supports."""
118        ...
119
120    async def start(self) -> None:
121        """Start listening for messages."""
122        ...
123
124    async def stop(self) -> None:
125        """Stop the channel adapter."""
126        ...
127
128    async def normalize_inbound(self, raw_event: Any) -> InboundMessage:
129        """Convert platform-specific event to standard format."""
130        ...
131
132    async def send_outbound(
133        self,
134        message: OutboundMessage,
135        context: ChannelContext,
136    ) -> None:
137        """Deliver message to the platform."""
138        ...
139
140    def build_session_key(self, message: InboundMessage) -> str:
141        """Build a session key for the message context."""
142        ...
143
144    def resolve_allow_from(self, context: ChatContext) -> list[str]:
145        """Determine allowed user IDs for this context."""
146        ...

Abstract interface for messaging channel adapters.

ChannelDock(*args, **kwargs)
1771def _no_init_or_replace_init(self, *args, **kwargs):
1772    cls = type(self)
1773
1774    if cls._is_protocol:
1775        raise TypeError('Protocols cannot be instantiated')
1776
1777    # Already using a custom `__init__`. No need to calculate correct
1778    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1779    if cls.__init__ is not _no_init_or_replace_init:
1780        return
1781
1782    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1783    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1784    # searches for a proper new `__init__` in the MRO. The new `__init__`
1785    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1786    # instantiation of the protocol subclass will thus use the new
1787    # `__init__` and no longer call `_no_init_or_replace_init`.
1788    for base in cls.__mro__:
1789        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1790        if init is not _no_init_or_replace_init:
1791            cls.__init__ = init
1792            break
1793    else:
1794        # should not happen
1795        cls.__init__ = object.__init__
1796
1797    cls.__init__(self, *args, **kwargs)
name: str
110    @property
111    def name(self) -> str:
112        """Channel identifier (e.g., 'telegram', 'discord')."""
113        ...

Channel identifier (e.g., 'telegram', 'discord').

capabilities: ChannelCapabilities
115    @property
116    def capabilities(self) -> ChannelCapabilities:
117        """What this channel supports."""
118        ...

What this channel supports.

async def start(self) -> None:
120    async def start(self) -> None:
121        """Start listening for messages."""
122        ...

Start listening for messages.

async def stop(self) -> None:
124    async def stop(self) -> None:
125        """Stop the channel adapter."""
126        ...

Stop the channel adapter.

async def normalize_inbound(self, raw_event: Any) -> InboundMessage:
128    async def normalize_inbound(self, raw_event: Any) -> InboundMessage:
129        """Convert platform-specific event to standard format."""
130        ...

Convert platform-specific event to standard format.

async def send_outbound( self, message: OutboundMessage, context: ChannelContext) -> None:
132    async def send_outbound(
133        self,
134        message: OutboundMessage,
135        context: ChannelContext,
136    ) -> None:
137        """Deliver message to the platform."""
138        ...

Deliver message to the platform.

def build_session_key(self, message: InboundMessage) -> str:
140    def build_session_key(self, message: InboundMessage) -> str:
141        """Build a session key for the message context."""
142        ...

Build a session key for the message context.

def resolve_allow_from(self, context: ChatContext) -> list[str]:
144    def resolve_allow_from(self, context: ChatContext) -> list[str]:
145        """Determine allowed user IDs for this context."""
146        ...

Determine allowed user IDs for this context.

class ChannelBridge:
 30class ChannelBridge:
 31    """
 32    Bridges a channel adapter to the core message pipeline.
 33
 34    Responsibilities:
 35    - Receives raw events from channel adapter
 36    - Normalizes to pipeline InboundMessage
 37    - Routes through InboundPipeline
 38    - Handles outbound delivery through OutboundPipeline
 39
 40    Each channel gets its own bridge instance, allowing channel-specific
 41    middleware configuration.
 42    """
 43
 44    def __init__(
 45        self,
 46        channel: ChannelDock,
 47        context: AppContext,
 48        inbound_pipeline: InboundPipeline | None = None,
 49        outbound_pipeline: OutboundPipeline | None = None,
 50    ) -> None:
 51        """
 52        Initialize the channel bridge.
 53
 54        Args:
 55            channel: The channel adapter to bridge.
 56            context: Application context.
 57            inbound_pipeline: Optional custom inbound pipeline.
 58            outbound_pipeline: Optional custom outbound pipeline.
 59        """
 60        self.channel = channel
 61        self.context = context
 62        self._channel_name = channel.name
 63
 64        # Use provided pipelines or create defaults
 65        self.inbound = inbound_pipeline or InboundPipeline()
 66        self.outbound = outbound_pipeline or OutboundPipeline()
 67
 68        # Set default handlers if not configured
 69        if self.inbound._handler is None:
 70            self.inbound.set_handler(self._default_inbound_handler)
 71        if self.outbound._handler is None:
 72            self.outbound.set_handler(self._default_outbound_handler)
 73
 74    @property
 75    def name(self) -> str:
 76        """Channel name."""
 77        return self._channel_name
 78
 79    @property
 80    def capabilities(self) -> ChannelCapabilities:
 81        """Channel capabilities."""
 82        return self.channel.capabilities
 83
 84    async def on_raw_event(self, raw_event: dict[str, Any]) -> None:
 85        """
 86        Handle a raw event from the channel adapter.
 87
 88        This is the entry point for all inbound messages from the channel.
 89        Normalizes the event and routes through the inbound pipeline.
 90
 91        Args:
 92            raw_event: Raw event data from the channel.
 93        """
 94        try:
 95            # Normalize using channel adapter
 96            channel_msg = await self.channel.normalize_inbound(raw_event)
 97
 98            # Convert to pipeline message format
 99            message = InboundMessage(
100                content=channel_msg.content,
101                peer_id=channel_msg.sender.id,
102                channel_name=self._channel_name,
103                message_id=channel_msg.id,
104                group_id=channel_msg.thread_id,
105                reply_to=channel_msg.reply_to,
106                attachments=[a.url for a in channel_msg.attachments],
107                raw=channel_msg.raw,
108            )
109
110            # Build session key using channel's logic
111            session_key = self.channel.build_session_key(channel_msg)
112
113            # Create pipeline context
114            ctx = PipelineContext(
115                app=self.context,
116                channel_name=self._channel_name,
117                peer_id=channel_msg.sender.id,
118                group_id=channel_msg.thread_id,
119                session_key=session_key,
120                metadata={
121                    "chunk_size": self.channel.capabilities.text_chunk_limit,
122                },
123            )
124
125            # Process through inbound pipeline
126            await self.inbound.process(message, ctx)
127
128        except Exception as e:
129            await logger.aerror(
130                "Failed to process inbound event",
131                channel=self._channel_name,
132                error=str(e),
133            )
134            raise
135
136    async def send(
137        self,
138        message: OutboundMessage,
139        context: PipelineContext | None = None,
140    ) -> None:
141        """
142        Send a message through the outbound pipeline.
143
144        The pipeline will handle chunking, rate limiting, etc.
145
146        Args:
147            message: The message to send.
148            context: Optional pipeline context. Creates one if not provided.
149        """
150        if context is None:
151            context = PipelineContext(
152                app=self.context,
153                channel_name=self._channel_name,
154                peer_id=message.peer_id,
155                metadata={
156                    "chunk_size": self.channel.capabilities.text_chunk_limit,
157                },
158            )
159
160        await self.outbound.process(message, context)
161
162    async def deliver(
163        self,
164        message: OutboundMessage,
165        context: PipelineContext,
166    ) -> None:
167        """
168        Deliver message directly to channel (bypassing pipeline).
169
170        Use send() instead for normal message sending.
171
172        Args:
173            message: The message to deliver.
174            context: Pipeline context.
175        """
176        await self._default_outbound_handler(message, context)
177
178    async def _default_inbound_handler(
179        self,
180        message: InboundMessage,
181        context: PipelineContext,
182    ) -> None:
183        """
184        Default inbound message handler.
185
186        Routes the message to the agent runner for processing.
187        """
188        from tiphys.agent.runner import AgentRunner
189
190        runner = AgentRunner(context=self.context)
191
192        # Stream response (blocks until complete)
193        async for _block in runner.stream(
194            message=message.content,
195            session_key=context.session_key or f"{self._channel_name}:default",
196        ):
197            pass  # Blocks are emitted via event_bus
198
199    async def _default_outbound_handler(
200        self,
201        message: OutboundMessage,
202        context: PipelineContext,
203    ) -> None:
204        """
205        Default outbound message handler.
206
207        Delivers the message to the channel adapter.
208        """
209        from tiphys.channels.base import ChannelContext
210        from tiphys.channels.base import OutboundMessage as ChannelOutboundMessage
211
212        # Convert pipeline message to channel format
213        channel_msg = ChannelOutboundMessage(
214            content=message.content,
215            reply_to=message.reply_to,
216            thread_id=message.thread_id,
217            metadata=message.metadata,
218        )
219
220        # Build channel context
221        channel_ctx = ChannelContext(
222            channel_id=message.peer_id or "",
223            thread_id=message.thread_id,
224            reply_to=message.reply_to,
225        )
226
227        await self.channel.send_outbound(channel_msg, channel_ctx)
228
229    async def start(self) -> None:
230        """Start the underlying channel adapter."""
231        await self.channel.start()
232
233    async def stop(self) -> None:
234        """Stop the underlying channel adapter."""
235        await self.channel.stop()

Bridges a channel adapter to the core message pipeline.

Responsibilities:

  • Receives raw events from channel adapter
  • Normalizes to pipeline InboundMessage
  • Routes through InboundPipeline
  • Handles outbound delivery through OutboundPipeline

Each channel gets its own bridge instance, allowing channel-specific middleware configuration.

ChannelBridge( channel: ChannelDock, context: tiphys.context.AppContext, inbound_pipeline: tiphys.pipeline.InboundPipeline | None = None, outbound_pipeline: tiphys.pipeline.OutboundPipeline | None = None)
44    def __init__(
45        self,
46        channel: ChannelDock,
47        context: AppContext,
48        inbound_pipeline: InboundPipeline | None = None,
49        outbound_pipeline: OutboundPipeline | None = None,
50    ) -> None:
51        """
52        Initialize the channel bridge.
53
54        Args:
55            channel: The channel adapter to bridge.
56            context: Application context.
57            inbound_pipeline: Optional custom inbound pipeline.
58            outbound_pipeline: Optional custom outbound pipeline.
59        """
60        self.channel = channel
61        self.context = context
62        self._channel_name = channel.name
63
64        # Use provided pipelines or create defaults
65        self.inbound = inbound_pipeline or InboundPipeline()
66        self.outbound = outbound_pipeline or OutboundPipeline()
67
68        # Set default handlers if not configured
69        if self.inbound._handler is None:
70            self.inbound.set_handler(self._default_inbound_handler)
71        if self.outbound._handler is None:
72            self.outbound.set_handler(self._default_outbound_handler)

Initialize the channel bridge.

Args: channel: The channel adapter to bridge. context: Application context. inbound_pipeline: Optional custom inbound pipeline. outbound_pipeline: Optional custom outbound pipeline.

channel
context
inbound
outbound
name: str
74    @property
75    def name(self) -> str:
76        """Channel name."""
77        return self._channel_name

Channel name.

capabilities: ChannelCapabilities
79    @property
80    def capabilities(self) -> ChannelCapabilities:
81        """Channel capabilities."""
82        return self.channel.capabilities

Channel capabilities.

async def on_raw_event(self, raw_event: dict[str, typing.Any]) -> None:
 84    async def on_raw_event(self, raw_event: dict[str, Any]) -> None:
 85        """
 86        Handle a raw event from the channel adapter.
 87
 88        This is the entry point for all inbound messages from the channel.
 89        Normalizes the event and routes through the inbound pipeline.
 90
 91        Args:
 92            raw_event: Raw event data from the channel.
 93        """
 94        try:
 95            # Normalize using channel adapter
 96            channel_msg = await self.channel.normalize_inbound(raw_event)
 97
 98            # Convert to pipeline message format
 99            message = InboundMessage(
100                content=channel_msg.content,
101                peer_id=channel_msg.sender.id,
102                channel_name=self._channel_name,
103                message_id=channel_msg.id,
104                group_id=channel_msg.thread_id,
105                reply_to=channel_msg.reply_to,
106                attachments=[a.url for a in channel_msg.attachments],
107                raw=channel_msg.raw,
108            )
109
110            # Build session key using channel's logic
111            session_key = self.channel.build_session_key(channel_msg)
112
113            # Create pipeline context
114            ctx = PipelineContext(
115                app=self.context,
116                channel_name=self._channel_name,
117                peer_id=channel_msg.sender.id,
118                group_id=channel_msg.thread_id,
119                session_key=session_key,
120                metadata={
121                    "chunk_size": self.channel.capabilities.text_chunk_limit,
122                },
123            )
124
125            # Process through inbound pipeline
126            await self.inbound.process(message, ctx)
127
128        except Exception as e:
129            await logger.aerror(
130                "Failed to process inbound event",
131                channel=self._channel_name,
132                error=str(e),
133            )
134            raise

Handle a raw event from the channel adapter.

This is the entry point for all inbound messages from the channel. Normalizes the event and routes through the inbound pipeline.

Args: raw_event: Raw event data from the channel.

async def send( self, message: OutboundMessage, context: tiphys.pipeline.PipelineContext | None = None) -> None:
136    async def send(
137        self,
138        message: OutboundMessage,
139        context: PipelineContext | None = None,
140    ) -> None:
141        """
142        Send a message through the outbound pipeline.
143
144        The pipeline will handle chunking, rate limiting, etc.
145
146        Args:
147            message: The message to send.
148            context: Optional pipeline context. Creates one if not provided.
149        """
150        if context is None:
151            context = PipelineContext(
152                app=self.context,
153                channel_name=self._channel_name,
154                peer_id=message.peer_id,
155                metadata={
156                    "chunk_size": self.channel.capabilities.text_chunk_limit,
157                },
158            )
159
160        await self.outbound.process(message, context)

Send a message through the outbound pipeline.

The pipeline will handle chunking, rate limiting, etc.

Args: message: The message to send. context: Optional pipeline context. Creates one if not provided.

async def deliver( self, message: OutboundMessage, context: tiphys.pipeline.PipelineContext) -> None:
162    async def deliver(
163        self,
164        message: OutboundMessage,
165        context: PipelineContext,
166    ) -> None:
167        """
168        Deliver message directly to channel (bypassing pipeline).
169
170        Use send() instead for normal message sending.
171
172        Args:
173            message: The message to deliver.
174            context: Pipeline context.
175        """
176        await self._default_outbound_handler(message, context)

Deliver message directly to channel (bypassing pipeline).

Use send() instead for normal message sending.

Args: message: The message to deliver. context: Pipeline context.

async def start(self) -> None:
229    async def start(self) -> None:
230        """Start the underlying channel adapter."""
231        await self.channel.start()

Start the underlying channel adapter.

async def stop(self) -> None:
233    async def stop(self) -> None:
234        """Stop the underlying channel adapter."""
235        await self.channel.stop()

Stop the underlying channel adapter.

class BridgeManager:
238class BridgeManager:
239    """
240    Manages all channel bridges.
241
242    Provides a central registry for channel bridges and coordinates
243    lifecycle and message routing across channels.
244    """
245
246    def __init__(self, context: AppContext) -> None:
247        """
248        Initialize the bridge manager.
249
250        Args:
251            context: Application context.
252        """
253        self.context = context
254        self._bridges: dict[str, ChannelBridge] = {}
255
256    def register(self, bridge: ChannelBridge) -> None:
257        """
258        Register a channel bridge.
259
260        Args:
261            bridge: The bridge to register.
262
263        Raises:
264            ValueError: If a bridge with the same name is already registered.
265        """
266        if bridge.name in self._bridges:
267            raise ValueError(f"Bridge '{bridge.name}' is already registered")
268        self._bridges[bridge.name] = bridge
269
270    def unregister(self, channel_name: str) -> bool:
271        """
272        Unregister a channel bridge.
273
274        Args:
275            channel_name: Name of the channel to unregister.
276
277        Returns:
278            True if unregistered, False if not found.
279        """
280        if channel_name in self._bridges:
281            del self._bridges[channel_name]
282            return True
283        return False
284
285    def get(self, channel_name: str) -> ChannelBridge | None:
286        """
287        Get a bridge by channel name.
288
289        Args:
290            channel_name: The channel name.
291
292        Returns:
293            The bridge, or None if not found.
294        """
295        return self._bridges.get(channel_name)
296
297    def list_channels(self) -> list[str]:
298        """List all registered channel names."""
299        return list(self._bridges.keys())
300
301    def get_all(self) -> dict[str, ChannelBridge]:
302        """Get all registered bridges."""
303        return self._bridges.copy()
304
305    async def broadcast(self, message: OutboundMessage) -> dict[str, bool]:
306        """
307        Send a message to all channels.
308
309        Args:
310            message: The message to broadcast.
311
312        Returns:
313            Dict mapping channel names to success status.
314        """
315        results: dict[str, bool] = {}
316
317        for name, bridge in self._bridges.items():
318            try:
319                await bridge.send(message)
320                results[name] = True
321            except Exception as e:
322                await logger.aerror(
323                    "Broadcast failed",
324                    channel=name,
325                    error=str(e),
326                )
327                results[name] = False
328
329        return results
330
331    async def start_all(self) -> None:
332        """Start all registered channel bridges."""
333        for name, bridge in self._bridges.items():
334            try:
335                await bridge.start()
336                await logger.ainfo("Bridge started", channel=name)
337            except Exception as e:
338                await logger.aerror(
339                    "Failed to start bridge",
340                    channel=name,
341                    error=str(e),
342                )
343
344    async def stop_all(self) -> None:
345        """Stop all registered channel bridges."""
346        for name, bridge in self._bridges.items():
347            try:
348                await bridge.stop()
349                await logger.ainfo("Bridge stopped", channel=name)
350            except Exception as e:
351                await logger.aerror(
352                    "Failed to stop bridge",
353                    channel=name,
354                    error=str(e),
355                )
356
357    def clear(self) -> int:
358        """
359        Clear all bridges.
360
361        Returns:
362            Number of bridges cleared.
363        """
364        count = len(self._bridges)
365        self._bridges.clear()
366        return count

Manages all channel bridges.

Provides a central registry for channel bridges and coordinates lifecycle and message routing across channels.

BridgeManager(context: tiphys.context.AppContext)
246    def __init__(self, context: AppContext) -> None:
247        """
248        Initialize the bridge manager.
249
250        Args:
251            context: Application context.
252        """
253        self.context = context
254        self._bridges: dict[str, ChannelBridge] = {}

Initialize the bridge manager.

Args: context: Application context.

context
def register(self, bridge: ChannelBridge) -> None:
256    def register(self, bridge: ChannelBridge) -> None:
257        """
258        Register a channel bridge.
259
260        Args:
261            bridge: The bridge to register.
262
263        Raises:
264            ValueError: If a bridge with the same name is already registered.
265        """
266        if bridge.name in self._bridges:
267            raise ValueError(f"Bridge '{bridge.name}' is already registered")
268        self._bridges[bridge.name] = bridge

Register a channel bridge.

Args: bridge: The bridge to register.

Raises: ValueError: If a bridge with the same name is already registered.

def unregister(self, channel_name: str) -> bool:
270    def unregister(self, channel_name: str) -> bool:
271        """
272        Unregister a channel bridge.
273
274        Args:
275            channel_name: Name of the channel to unregister.
276
277        Returns:
278            True if unregistered, False if not found.
279        """
280        if channel_name in self._bridges:
281            del self._bridges[channel_name]
282            return True
283        return False

Unregister a channel bridge.

Args: channel_name: Name of the channel to unregister.

Returns: True if unregistered, False if not found.

def get(self, channel_name: str) -> ChannelBridge | None:
285    def get(self, channel_name: str) -> ChannelBridge | None:
286        """
287        Get a bridge by channel name.
288
289        Args:
290            channel_name: The channel name.
291
292        Returns:
293            The bridge, or None if not found.
294        """
295        return self._bridges.get(channel_name)

Get a bridge by channel name.

Args: channel_name: The channel name.

Returns: The bridge, or None if not found.

def list_channels(self) -> list[str]:
297    def list_channels(self) -> list[str]:
298        """List all registered channel names."""
299        return list(self._bridges.keys())

List all registered channel names.

def get_all(self) -> dict[str, ChannelBridge]:
301    def get_all(self) -> dict[str, ChannelBridge]:
302        """Get all registered bridges."""
303        return self._bridges.copy()

Get all registered bridges.

async def broadcast(self, message: OutboundMessage) -> dict[str, bool]:
305    async def broadcast(self, message: OutboundMessage) -> dict[str, bool]:
306        """
307        Send a message to all channels.
308
309        Args:
310            message: The message to broadcast.
311
312        Returns:
313            Dict mapping channel names to success status.
314        """
315        results: dict[str, bool] = {}
316
317        for name, bridge in self._bridges.items():
318            try:
319                await bridge.send(message)
320                results[name] = True
321            except Exception as e:
322                await logger.aerror(
323                    "Broadcast failed",
324                    channel=name,
325                    error=str(e),
326                )
327                results[name] = False
328
329        return results

Send a message to all channels.

Args: message: The message to broadcast.

Returns: Dict mapping channel names to success status.

async def start_all(self) -> None:
331    async def start_all(self) -> None:
332        """Start all registered channel bridges."""
333        for name, bridge in self._bridges.items():
334            try:
335                await bridge.start()
336                await logger.ainfo("Bridge started", channel=name)
337            except Exception as e:
338                await logger.aerror(
339                    "Failed to start bridge",
340                    channel=name,
341                    error=str(e),
342                )

Start all registered channel bridges.

async def stop_all(self) -> None:
344    async def stop_all(self) -> None:
345        """Stop all registered channel bridges."""
346        for name, bridge in self._bridges.items():
347            try:
348                await bridge.stop()
349                await logger.ainfo("Bridge stopped", channel=name)
350            except Exception as e:
351                await logger.aerror(
352                    "Failed to stop bridge",
353                    channel=name,
354                    error=str(e),
355                )

Stop all registered channel bridges.

def clear(self) -> int:
357    def clear(self) -> int:
358        """
359        Clear all bridges.
360
361        Returns:
362            Number of bridges cleared.
363        """
364        count = len(self._bridges)
365        self._bridges.clear()
366        return count

Clear all bridges.

Returns: Number of bridges cleared.

def create_bridge( channel: ChannelDock, context: tiphys.context.AppContext, *, use_default_middleware: bool = True) -> ChannelBridge:
369def create_bridge(
370    channel: ChannelDock,
371    context: AppContext,
372    *,
373    use_default_middleware: bool = True,
374) -> ChannelBridge:
375    """
376    Factory function to create a channel bridge with default configuration.
377
378    Args:
379        channel: The channel adapter.
380        context: Application context.
381        use_default_middleware: Whether to add default middleware.
382
383    Returns:
384        Configured ChannelBridge.
385    """
386    from tiphys.pipeline import (
387        ChunkingMiddleware,
388        LoggingMiddleware,
389        RateLimitMiddleware,
390        TransformMiddleware,
391        ValidationMiddleware,
392    )
393
394    # Create pipelines
395    inbound = InboundPipeline()
396    outbound = OutboundPipeline()
397
398    if use_default_middleware:
399        # Add default inbound middleware
400        inbound.use(LoggingMiddleware())
401        inbound.use(TransformMiddleware())
402        inbound.use(ValidationMiddleware())
403        inbound.use(RateLimitMiddleware())
404
405        # Add default outbound middleware
406        outbound.use(LoggingMiddleware())
407        outbound.use(ChunkingMiddleware(default_chunk_size=channel.capabilities.text_chunk_limit))
408
409    return ChannelBridge(
410        channel=channel,
411        context=context,
412        inbound_pipeline=inbound,
413        outbound_pipeline=outbound,
414    )

Factory function to create a channel bridge with default configuration.

Args: channel: The channel adapter. context: Application context. use_default_middleware: Whether to add default middleware.

Returns: Configured ChannelBridge.

class ChannelRegistry:
 15class ChannelRegistry:
 16    """
 17    Registry for channel adapters.
 18
 19    Manages registration, discovery, and lifecycle of channel implementations.
 20    """
 21
 22    def __init__(self) -> None:
 23        """Initialize the channel registry."""
 24        self._channels: dict[str, ChannelDock] = {}
 25        self._started: set[str] = set()
 26
 27    def register(self, channel: ChannelDock) -> None:
 28        """
 29        Register a channel adapter.
 30
 31        Args:
 32            channel: The channel to register.
 33
 34        Raises:
 35            ValueError: If a channel with the same name is already registered.
 36        """
 37        name = channel.name
 38        if name in self._channels:
 39            raise ValueError(f"Channel '{name}' is already registered")
 40
 41        self._channels[name] = channel
 42
 43    def unregister(self, name: str) -> bool:
 44        """
 45        Unregister a channel adapter.
 46
 47        Args:
 48            name: Name of the channel to unregister.
 49
 50        Returns:
 51            True if the channel was unregistered, False if not found.
 52        """
 53        if name in self._channels:
 54            del self._channels[name]
 55            self._started.discard(name)
 56            return True
 57        return False
 58
 59    def get(self, name: str) -> ChannelDock | None:
 60        """
 61        Get a channel by name.
 62
 63        Args:
 64            name: Name of the channel.
 65
 66        Returns:
 67            The channel adapter or None if not found.
 68        """
 69        return self._channels.get(name)
 70
 71    def list_names(self) -> list[str]:
 72        """List all registered channel names."""
 73        return list(self._channels.keys())
 74
 75    def get_all(self) -> dict[str, ChannelDock]:
 76        """Get all registered channels."""
 77        return self._channels.copy()
 78
 79    async def start_all(self) -> None:
 80        """Start all registered channels."""
 81        for name, channel in self._channels.items():
 82            if name not in self._started:
 83                try:
 84                    await channel.start()
 85                    self._started.add(name)
 86                    await logger.ainfo("Channel started", channel=name)
 87                except Exception as e:
 88                    await logger.aerror(
 89                        "Failed to start channel",
 90                        channel=name,
 91                        error=str(e),
 92                    )
 93
 94    async def stop_all(self) -> None:
 95        """Stop all running channels."""
 96        for name in list(self._started):
 97            channel = self._channels.get(name)
 98            if channel:
 99                try:
100                    await channel.stop()
101                    self._started.discard(name)
102                    await logger.ainfo("Channel stopped", channel=name)
103                except Exception as e:
104                    await logger.aerror(
105                        "Failed to stop channel",
106                        channel=name,
107                        error=str(e),
108                    )
109
110    async def start(self, name: str) -> bool:
111        """
112        Start a specific channel.
113
114        Args:
115            name: Name of the channel to start.
116
117        Returns:
118            True if started, False if channel not found.
119        """
120        channel = self._channels.get(name)
121        if not channel:
122            return False
123
124        if name not in self._started:
125            await channel.start()
126            self._started.add(name)
127
128        return True
129
130    async def stop(self, name: str) -> bool:
131        """
132        Stop a specific channel.
133
134        Args:
135            name: Name of the channel to stop.
136
137        Returns:
138            True if stopped, False if channel not found.
139        """
140        channel = self._channels.get(name)
141        if not channel:
142            return False
143
144        if name in self._started:
145            await channel.stop()
146            self._started.discard(name)
147
148        return True
149
150    def is_running(self, name: str) -> bool:
151        """Check if a channel is running."""
152        return name in self._started
153
154    def clear(self) -> int:
155        """
156        Clear all channels.
157
158        Returns:
159            Number of channels cleared.
160        """
161        count = len(self._channels)
162        self._channels.clear()
163        self._started.clear()
164        return count

Registry for channel adapters.

Manages registration, discovery, and lifecycle of channel implementations.

ChannelRegistry()
22    def __init__(self) -> None:
23        """Initialize the channel registry."""
24        self._channels: dict[str, ChannelDock] = {}
25        self._started: set[str] = set()

Initialize the channel registry.

def register(self, channel: ChannelDock) -> None:
27    def register(self, channel: ChannelDock) -> None:
28        """
29        Register a channel adapter.
30
31        Args:
32            channel: The channel to register.
33
34        Raises:
35            ValueError: If a channel with the same name is already registered.
36        """
37        name = channel.name
38        if name in self._channels:
39            raise ValueError(f"Channel '{name}' is already registered")
40
41        self._channels[name] = channel

Register a channel adapter.

Args: channel: The channel to register.

Raises: ValueError: If a channel with the same name is already registered.

def unregister(self, name: str) -> bool:
43    def unregister(self, name: str) -> bool:
44        """
45        Unregister a channel adapter.
46
47        Args:
48            name: Name of the channel to unregister.
49
50        Returns:
51            True if the channel was unregistered, False if not found.
52        """
53        if name in self._channels:
54            del self._channels[name]
55            self._started.discard(name)
56            return True
57        return False

Unregister a channel adapter.

Args: name: Name of the channel to unregister.

Returns: True if the channel was unregistered, False if not found.

def get(self, name: str) -> ChannelDock | None:
59    def get(self, name: str) -> ChannelDock | None:
60        """
61        Get a channel by name.
62
63        Args:
64            name: Name of the channel.
65
66        Returns:
67            The channel adapter or None if not found.
68        """
69        return self._channels.get(name)

Get a channel by name.

Args: name: Name of the channel.

Returns: The channel adapter or None if not found.

def list_names(self) -> list[str]:
71    def list_names(self) -> list[str]:
72        """List all registered channel names."""
73        return list(self._channels.keys())

List all registered channel names.

def get_all(self) -> dict[str, ChannelDock]:
75    def get_all(self) -> dict[str, ChannelDock]:
76        """Get all registered channels."""
77        return self._channels.copy()

Get all registered channels.

async def start_all(self) -> None:
79    async def start_all(self) -> None:
80        """Start all registered channels."""
81        for name, channel in self._channels.items():
82            if name not in self._started:
83                try:
84                    await channel.start()
85                    self._started.add(name)
86                    await logger.ainfo("Channel started", channel=name)
87                except Exception as e:
88                    await logger.aerror(
89                        "Failed to start channel",
90                        channel=name,
91                        error=str(e),
92                    )

Start all registered channels.

async def stop_all(self) -> None:
 94    async def stop_all(self) -> None:
 95        """Stop all running channels."""
 96        for name in list(self._started):
 97            channel = self._channels.get(name)
 98            if channel:
 99                try:
100                    await channel.stop()
101                    self._started.discard(name)
102                    await logger.ainfo("Channel stopped", channel=name)
103                except Exception as e:
104                    await logger.aerror(
105                        "Failed to stop channel",
106                        channel=name,
107                        error=str(e),
108                    )

Stop all running channels.

async def start(self, name: str) -> bool:
110    async def start(self, name: str) -> bool:
111        """
112        Start a specific channel.
113
114        Args:
115            name: Name of the channel to start.
116
117        Returns:
118            True if started, False if channel not found.
119        """
120        channel = self._channels.get(name)
121        if not channel:
122            return False
123
124        if name not in self._started:
125            await channel.start()
126            self._started.add(name)
127
128        return True

Start a specific channel.

Args: name: Name of the channel to start.

Returns: True if started, False if channel not found.

async def stop(self, name: str) -> bool:
130    async def stop(self, name: str) -> bool:
131        """
132        Stop a specific channel.
133
134        Args:
135            name: Name of the channel to stop.
136
137        Returns:
138            True if stopped, False if channel not found.
139        """
140        channel = self._channels.get(name)
141        if not channel:
142            return False
143
144        if name in self._started:
145            await channel.stop()
146            self._started.discard(name)
147
148        return True

Stop a specific channel.

Args: name: Name of the channel to stop.

Returns: True if stopped, False if channel not found.

def is_running(self, name: str) -> bool:
150    def is_running(self, name: str) -> bool:
151        """Check if a channel is running."""
152        return name in self._started

Check if a channel is running.

def clear(self) -> int:
154    def clear(self) -> int:
155        """
156        Clear all channels.
157
158        Returns:
159            Number of channels cleared.
160        """
161        count = len(self._channels)
162        self._channels.clear()
163        self._started.clear()
164        return count

Clear all channels.

Returns: Number of channels cleared.

def get_channel_registry() -> ChannelRegistry:
171def get_channel_registry() -> ChannelRegistry:
172    """Get the global channel registry."""
173    global _channel_registry
174    if _channel_registry is None:
175        _channel_registry = ChannelRegistry()
176    return _channel_registry

Get the global channel registry.

def register_builtin_channels( registry: ChannelRegistry | None = None) -> ChannelRegistry:
179def register_builtin_channels(registry: ChannelRegistry | None = None) -> ChannelRegistry:
180    """
181    Register built-in channels.
182
183    Args:
184        registry: Optional registry to use. Uses global registry if not provided.
185
186    Returns:
187        The registry with built-in channels registered.
188    """
189    from tiphys.channels.discord import create_discord_channel
190    from tiphys.channels.telegram import create_telegram_channel
191    from tiphys.channels.webchat import create_webchat_channel
192    from tiphys.config import load_config
193
194    if registry is None:
195        registry = get_channel_registry()
196
197    config = load_config()
198
199    # Register WebChat as the default channel
200    webchat = create_webchat_channel(config=config)
201    registry.register(webchat)
202
203    # Register Telegram if token is provided
204    if config.telegram_token:
205        telegram = create_telegram_channel(token=config.telegram_token, config=config)
206        registry.register(telegram)
207
208    # Register Discord if token is provided
209    if config.discord_token:
210        discord = create_discord_channel(token=config.discord_token, config=config)
211        registry.register(discord)
212
213    return registry

Register built-in channels.

Args: registry: Optional registry to use. Uses global registry if not provided.

Returns: The registry with built-in channels registered.

class WebChatChannel(tiphys.channels.base.BaseChannelAdapter):
 30class WebChatChannel(BaseChannelAdapter):
 31    """
 32    WebChat channel implementation.
 33
 34    This is the built-in web chat interface that works with the
 35    WebSocket event bus for real-time messaging.
 36
 37    Inherits automatic reconnection from BaseChannelAdapter.
 38    """
 39
 40    def __init__(
 41        self,
 42        config: TiphysConfig | None = None,
 43        event_bus: EventBus | None = None,
 44    ) -> None:
 45        """
 46        Initialize the WebChat channel.
 47
 48        Args:
 49            config: Configuration. Defaults to loading from environment.
 50            event_bus: Event bus. Uses global bus if not provided.
 51        """
 52        super().__init__()
 53        self._config = config or load_config()
 54        self._event_bus = event_bus or get_event_bus()
 55
 56    @property
 57    def name(self) -> str:
 58        """Channel identifier."""
 59        return "webchat"
 60
 61    @property
 62    def capabilities(self) -> ChannelCapabilities:
 63        """WebChat capabilities."""
 64        return ChannelCapabilities(
 65            chat_types=["direct"],
 66            supports_streaming=True,
 67            supports_reactions=False,
 68            supports_media=True,
 69            supports_threads=False,
 70            supports_editing=False,
 71            text_chunk_limit=10000,  # No practical limit for web
 72        )
 73
 74    async def _do_connect(self) -> None:
 75        """WebChat doesn't need external connection - it's server-side."""
 76        # No external connection needed - WebSocket clients connect to us
 77        pass
 78
 79    async def _do_disconnect(self) -> None:
 80        """WebChat cleanup on disconnect."""
 81        # No cleanup needed for server-side channel
 82        pass
 83
 84    async def normalize_inbound(self, raw_event: dict[str, Any]) -> InboundMessage:
 85        """
 86        Convert WebSocket message to InboundMessage.
 87
 88        Expected raw_event format:
 89        {
 90            "content": "message text",
 91            "client_id": "abc123",
 92            "message_id": "optional",
 93            "attachments": []
 94        }
 95        """
 96        message_id = raw_event.get("message_id") or str(uuid4())
 97        client_id = raw_event.get("client_id", "anonymous")
 98
 99        # Build peer from client info
100        sender = Peer(
101            id=client_id,
102            username=raw_event.get("username"),
103            display_name=raw_event.get("display_name"),
104            is_bot=False,
105            raw=raw_event,
106        )
107
108        # Parse attachments if present
109        attachments: list[Attachment] = []
110        for att in raw_event.get("attachments", []):
111            attachments.append(
112                Attachment(
113                    url=att.get("url", ""),
114                    mime_type=att.get("mime_type", "application/octet-stream"),
115                    filename=att.get("filename"),
116                    size_bytes=att.get("size_bytes"),
117                )
118            )
119
120        return InboundMessage(
121            id=message_id,
122            channel=self.name,
123            sender=sender,
124            content=raw_event.get("content", ""),
125            attachments=attachments,
126            reply_to=raw_event.get("reply_to"),
127            thread_id=raw_event.get("thread_id"),
128            timestamp=datetime.utcnow(),
129            raw=raw_event,
130        )
131
132    async def send_outbound(
133        self,
134        message: OutboundMessage,
135        context: ChannelContext,
136    ) -> None:
137        """
138        Send message via WebSocket event bus.
139
140        The message is broadcast to the connected client.
141        """
142        from tiphys.events import TextEvent
143
144        # Broadcast as text event
145        await self._event_bus.broadcast(
146            TextEvent(
147                content=message.content,
148                agent_id=self._config.agent_id,
149                is_final=True,
150            )
151        )
152
153        await logger.adebug(
154            "Sent outbound message",
155            channel_id=context.channel_id,
156            content_length=len(message.content),
157        )
158
159    def build_session_key(self, message: InboundMessage) -> str:
160        """
161        Build session key based on DM scope configuration.
162        """
163        from tiphys.agent.session import build_session_key
164
165        return build_session_key(
166            agent_id=self._config.agent_id,
167            channel=self.name,
168            account="web",
169            peer=message.sender,
170            scope=self._config.dm_scope,  # type: ignore[arg-type]
171            group_id=message.thread_id,
172        )
173
174    def resolve_allow_from(self, context: ChatContext) -> list[str]:
175        """
176        Determine allowed user IDs for this context.
177
178        For WebChat, we allow all users by default since there's
179        no built-in user authentication.
180        """
181        # Allow the requesting peer
182        allowed = [context.peer_id]
183
184        # In a real implementation, you might check:
185        # - User authentication status
186        # - Access control lists
187        # - Rate limiting
188
189        return allowed

WebChat channel implementation.

This is the built-in web chat interface that works with the WebSocket event bus for real-time messaging.

Inherits automatic reconnection from BaseChannelAdapter.

WebChatChannel( config: tiphys.TiphysConfig | None = None, event_bus: tiphys.server.EventBus | None = None)
40    def __init__(
41        self,
42        config: TiphysConfig | None = None,
43        event_bus: EventBus | None = None,
44    ) -> None:
45        """
46        Initialize the WebChat channel.
47
48        Args:
49            config: Configuration. Defaults to loading from environment.
50            event_bus: Event bus. Uses global bus if not provided.
51        """
52        super().__init__()
53        self._config = config or load_config()
54        self._event_bus = event_bus or get_event_bus()

Initialize the WebChat channel.

Args: config: Configuration. Defaults to loading from environment. event_bus: Event bus. Uses global bus if not provided.

name: str
56    @property
57    def name(self) -> str:
58        """Channel identifier."""
59        return "webchat"

Channel identifier.

capabilities: ChannelCapabilities
61    @property
62    def capabilities(self) -> ChannelCapabilities:
63        """WebChat capabilities."""
64        return ChannelCapabilities(
65            chat_types=["direct"],
66            supports_streaming=True,
67            supports_reactions=False,
68            supports_media=True,
69            supports_threads=False,
70            supports_editing=False,
71            text_chunk_limit=10000,  # No practical limit for web
72        )

WebChat capabilities.

async def normalize_inbound( self, raw_event: dict[str, typing.Any]) -> InboundMessage:
 84    async def normalize_inbound(self, raw_event: dict[str, Any]) -> InboundMessage:
 85        """
 86        Convert WebSocket message to InboundMessage.
 87
 88        Expected raw_event format:
 89        {
 90            "content": "message text",
 91            "client_id": "abc123",
 92            "message_id": "optional",
 93            "attachments": []
 94        }
 95        """
 96        message_id = raw_event.get("message_id") or str(uuid4())
 97        client_id = raw_event.get("client_id", "anonymous")
 98
 99        # Build peer from client info
100        sender = Peer(
101            id=client_id,
102            username=raw_event.get("username"),
103            display_name=raw_event.get("display_name"),
104            is_bot=False,
105            raw=raw_event,
106        )
107
108        # Parse attachments if present
109        attachments: list[Attachment] = []
110        for att in raw_event.get("attachments", []):
111            attachments.append(
112                Attachment(
113                    url=att.get("url", ""),
114                    mime_type=att.get("mime_type", "application/octet-stream"),
115                    filename=att.get("filename"),
116                    size_bytes=att.get("size_bytes"),
117                )
118            )
119
120        return InboundMessage(
121            id=message_id,
122            channel=self.name,
123            sender=sender,
124            content=raw_event.get("content", ""),
125            attachments=attachments,
126            reply_to=raw_event.get("reply_to"),
127            thread_id=raw_event.get("thread_id"),
128            timestamp=datetime.utcnow(),
129            raw=raw_event,
130        )

Convert WebSocket message to InboundMessage.

Expected raw_event format: { "content": "message text", "client_id": "abc123", "message_id": "optional", "attachments": [] }

async def send_outbound( self, message: OutboundMessage, context: ChannelContext) -> None:
132    async def send_outbound(
133        self,
134        message: OutboundMessage,
135        context: ChannelContext,
136    ) -> None:
137        """
138        Send message via WebSocket event bus.
139
140        The message is broadcast to the connected client.
141        """
142        from tiphys.events import TextEvent
143
144        # Broadcast as text event
145        await self._event_bus.broadcast(
146            TextEvent(
147                content=message.content,
148                agent_id=self._config.agent_id,
149                is_final=True,
150            )
151        )
152
153        await logger.adebug(
154            "Sent outbound message",
155            channel_id=context.channel_id,
156            content_length=len(message.content),
157        )

Send message via WebSocket event bus.

The message is broadcast to the connected client.

def build_session_key(self, message: InboundMessage) -> str:
159    def build_session_key(self, message: InboundMessage) -> str:
160        """
161        Build session key based on DM scope configuration.
162        """
163        from tiphys.agent.session import build_session_key
164
165        return build_session_key(
166            agent_id=self._config.agent_id,
167            channel=self.name,
168            account="web",
169            peer=message.sender,
170            scope=self._config.dm_scope,  # type: ignore[arg-type]
171            group_id=message.thread_id,
172        )

Build session key based on DM scope configuration.

def resolve_allow_from(self, context: ChatContext) -> list[str]:
174    def resolve_allow_from(self, context: ChatContext) -> list[str]:
175        """
176        Determine allowed user IDs for this context.
177
178        For WebChat, we allow all users by default since there's
179        no built-in user authentication.
180        """
181        # Allow the requesting peer
182        allowed = [context.peer_id]
183
184        # In a real implementation, you might check:
185        # - User authentication status
186        # - Access control lists
187        # - Rate limiting
188
189        return allowed

Determine allowed user IDs for this context.

For WebChat, we allow all users by default since there's no built-in user authentication.

def create_webchat_channel( config: tiphys.TiphysConfig | None = None, event_bus: tiphys.server.EventBus | None = None) -> WebChatChannel:
192def create_webchat_channel(
193    config: TiphysConfig | None = None,
194    event_bus: EventBus | None = None,
195) -> WebChatChannel:
196    """Factory function to create a WebChat channel instance."""
197    return WebChatChannel(config=config, event_bus=event_bus)

Factory function to create a WebChat channel instance.