Add a Channel¶
This guide walks you through creating a new channel adapter so CianaParrot can communicate via a new messaging platform (Discord, Slack, a webhook endpoint, etc.).
Introduction¶
Channels are the entry and exit points for user messages. Each channel implements AbstractChannel, which defines a standard interface for starting/stopping the listener, sending messages, and registering a callback for incoming messages. The router doesn't know or care which channel a message came from -- it receives a normalized IncomingMessage dataclass.
Prerequisites¶
- A working CianaParrot installation (Installation Guide)
- Python 3.13+
- The client library for your target platform (e.g.,
discord.py,slack-sdk)
Step 1: Understand the Base Classes¶
The channel system is built on three dataclasses and one abstract class in src/channels/base.py:
@dataclass
class SendResult:
"""Result of a send operation."""
message_id: Optional[str] = None
@dataclass
class IncomingMessage:
"""Normalized incoming message from any channel."""
channel: str # channel name (e.g. "telegram", "discord")
chat_id: str # unique chat/conversation identifier
user_id: str # unique user identifier
user_name: str # display name
text: str # message text
is_private: bool = False # True for DMs
reply_to: Optional[str] = None # message ID being replied to
file_path: Optional[str] = None # path to an attached file
reset_session: bool = False # True for /new-style resets
message_id: Optional[str] = None # platform message ID
image_base64: Optional[str] = None # base64-encoded photo for vision LLMs
image_mime_type: str = "image/jpeg" # MIME type for the image
class AbstractChannel(ABC):
"""Base class for all channel adapters."""
name: str = "base"
@abstractmethod
async def start(self) -> None: ...
@abstractmethod
async def stop(self) -> None: ...
@abstractmethod
async def send(self, chat_id: str, text: str, *,
reply_to_message_id: Optional[str] = None,
disable_notification: bool = False) -> Optional[SendResult]: ...
@abstractmethod
async def send_file(self, chat_id: str, path: str, caption: str = "") -> None: ...
def on_message(self, callback): ... # registers self._callback
Key contract
Your channel must normalize every incoming message into an IncomingMessage and call self._callback(msg). The callback returns an AgentResponse (or None) that your channel sends back to the user.
Step 2: Create the Channel Package¶
Create a new directory under src/channels/:
"""Discord channel package."""
from .channel import DiscordChannel
__all__ = ["DiscordChannel"]
Step 3: Implement the Channel Adapter¶
"""Discord channel adapter."""
import asyncio
import logging
from typing import Optional
from ..base import AbstractChannel, IncomingMessage, SendResult
logger = logging.getLogger(__name__)
class DiscordChannel(AbstractChannel):
"""Discord channel adapter."""
name = "discord"
def __init__(self, config):
self._token = config.token
self._callback = None
self._client = None # your Discord client instance
async def start(self) -> None:
"""Start the Discord bot (non-blocking)."""
import discord
intents = discord.Intents.default()
intents.message_content = True
self._client = discord.Client(intents=intents)
@self._client.event
async def on_message(message):
if message.author.bot:
return
await self._handle_message(message)
# Start in background so it doesn't block the event loop
asyncio.create_task(self._client.start(self._token))
logger.info("Discord channel started")
async def stop(self) -> None:
"""Gracefully disconnect."""
if self._client:
await self._client.close()
logger.info("Discord channel stopped")
async def send(self, chat_id: str, text: str, *,
reply_to_message_id: Optional[str] = None,
disable_notification: bool = False) -> Optional[SendResult]:
"""Send a text message to a Discord channel."""
if not self._client:
return None
channel = self._client.get_channel(int(chat_id))
if not channel:
logger.warning("Discord channel %s not found", chat_id)
return None
msg = await channel.send(text)
return SendResult(message_id=str(msg.id))
async def send_file(self, chat_id: str, path: str, caption: str = "") -> None:
"""Send a file to a Discord channel."""
import discord
if not self._client:
return
channel = self._client.get_channel(int(chat_id))
if channel:
await channel.send(content=caption, file=discord.File(path))
async def _handle_message(self, message) -> None:
"""Normalize a Discord message and pass to the callback."""
if not self._callback:
return
is_private = message.guild is None
msg = IncomingMessage(
channel=self.name,
chat_id=str(message.channel.id),
user_id=str(message.author.id),
user_name=message.author.display_name,
text=message.content,
is_private=is_private,
message_id=str(message.id),
)
agent_resp = await self._callback(msg)
if agent_resp and agent_resp.text:
await self.send(str(message.channel.id), agent_resp.text)
Event loop sharing
CianaParrot runs a single asyncio event loop for all channels. Your channel must not call blocking APIs on the main loop. Use asyncio.create_task() or asyncio.to_thread() for blocking operations.
Step 4: Add Configuration¶
You need a config model for your channel. See Add a Config Section for details. In brief:
Step 5: Wire It Up in main.py¶
Follow the same pattern used for the Telegram channel:
from .channels.discord import DiscordChannel
# ... inside main(), after the Telegram channel block:
if config.channels.discord.enabled:
discord_config = config.channels.discord
discord_ch = DiscordChannel(discord_config)
async def discord_callback(msg):
return await router.handle_message(msg, discord_config)
discord_ch.on_message(discord_callback)
channels.append(discord_ch)
logger.info("Discord channel configured")
Channel config type
The router.handle_message() second argument is currently typed as TelegramChannelConfig. If your channel config has the same fields (trigger, allowed_users), it will work. Otherwise, consider refactoring the router to accept a protocol or base channel config type.
Step 6: Test It¶
Unit test for message normalization¶
import pytest
from src.channels.base import IncomingMessage
def test_incoming_message_fields():
"""Verify IncomingMessage can be constructed with Discord-specific values."""
msg = IncomingMessage(
channel="discord",
chat_id="123456789",
user_id="987654321",
user_name="TestUser",
text="Hello, Ciana!",
is_private=True,
message_id="111222333",
)
assert msg.channel == "discord"
assert msg.is_private is True
assert msg.image_base64 is None
Integration test¶
- Set your Discord bot token in
.env - Enable the channel in
config.yaml - Run
make upand send a message to your Discord bot - Check logs with
make logs
Summary¶
| Step | What You Did |
|---|---|
| 1 | Reviewed AbstractChannel, IncomingMessage, and SendResult |
| 2 | Created src/channels/discord/ package |
| 3 | Implemented all abstract methods |
| 4 | Added config model and YAML section |
| 5 | Wired the channel in main.py |
| 6 | Wrote tests and verified end-to-end |