Skip to main content

Channel Traits

ClawDesk's channel system uses a layered trait hierarchy that follows the Interface Segregation Principle (ISP). Channel adapters implement only the traits their platform supports, enabling the system to work with platforms ranging from simple IRC to full-featured Slack.

Trait Hierarchy Overview

Layer 0: Base Channel

Every channel adapter must implement the Channel trait. This is the minimum viable interface:

// crates/clawdesk-channel/src/lib.rs

/// Layer 0: Base channel capability.
/// This is the only required trait for a channel adapter.
#[async_trait]
pub trait Channel: Send + Sync + 'static {
/// Unique identifier for this channel instance.
fn id(&self) -> ChannelId;

/// Channel metadata (kind, display name, capabilities).
fn meta(&self) -> &ChannelMeta;

/// Start listening for inbound messages.
/// Called once during channel initialization.
async fn start(&mut self) -> Result<(), ChannelError>;

/// Send a message to the channel.
async fn send(&self, message: &OutboundMessage) -> Result<(), ChannelError>;

/// Stop the channel and release resources.
/// Called during graceful shutdown.
async fn stop(&mut self) -> Result<(), ChannelError>;

/// Report the health status of the channel connection.
fn health(&self) -> HealthStatus {
HealthStatus::Unknown // Default implementation
}
}
Minimal Contract

A channel adapter that implements only Layer 0 can still fully participate in the ClawDesk ecosystem. It can receive messages, process them through the pipeline, and send responses. All advanced features (threading, streaming, reactions) are optional.

Layer 1: Optional Capabilities

Layer 1 traits each represent a single capability that a platform may or may not support:

Threaded

/// Channels that support threaded conversations.
/// Implemented by: Slack, Discord, Matrix
#[async_trait]
pub trait Threaded: Channel {
/// Reply within an existing thread.
async fn reply_in_thread(
&self,
thread_id: &ThreadId,
message: &OutboundMessage,
) -> Result<(), ChannelError>;

/// Retrieve all messages in a thread.
async fn get_thread(
&self,
thread_id: &ThreadId,
) -> Result<Thread, ChannelError>;

/// List recent threads in the channel.
async fn list_threads(
&self,
limit: usize,
) -> Result<Vec<ThreadSummary>, ChannelError>;
}

Streaming

/// Channels that support progressive message updates.
/// Implemented by: Slack, Discord, Web API, Desktop
#[async_trait]
pub trait Streaming: Channel {
/// Send a message that updates progressively as tokens arrive.
async fn send_stream(
&self,
stream: MessageStream,
) -> Result<StreamHandle, ChannelError>;

/// Update an already-sent message's content.
async fn update_message(
&self,
message_id: &MessageId,
content: &str,
) -> Result<(), ChannelError>;

/// Delete a previously sent message.
async fn delete_message(
&self,
message_id: &MessageId,
) -> Result<(), ChannelError>;
}

Reactions

/// Channels that support emoji reactions.
/// Implemented by: Slack, Discord, Matrix
#[async_trait]
pub trait Reactions: Channel {
/// Add a reaction to a message.
async fn add_reaction(
&self,
message_id: &MessageId,
emoji: &str,
) -> Result<(), ChannelError>;

/// Remove a reaction from a message.
async fn remove_reaction(
&self,
message_id: &MessageId,
emoji: &str,
) -> Result<(), ChannelError>;

/// Get all reactions on a message.
async fn get_reactions(
&self,
message_id: &MessageId,
) -> Result<Vec<Reaction>, ChannelError>;
}

GroupManagement

/// Channels that support group/room management.
/// Implemented by: Slack, Discord, Matrix, Telegram
#[async_trait]
pub trait GroupManagement: Channel {
/// Create a new group or channel.
async fn create_group(
&self,
name: &str,
members: &[UserId],
) -> Result<GroupId, ChannelError>;

/// Add a member to a group.
async fn add_member(
&self,
group: &GroupId,
user: &UserId,
) -> Result<(), ChannelError>;

/// Remove a member from a group.
async fn remove_member(
&self,
group: &GroupId,
user: &UserId,
) -> Result<(), ChannelError>;

/// List all members in a group.
async fn list_members(
&self,
group: &GroupId,
) -> Result<Vec<UserId>, ChannelError>;
}

Layer 2: Rich Channel

The RichChannel trait is a convenience super-trait that combines all Layer 1 capabilities:

/// Layer 2: Full-featured channel.
/// Only the richest platforms implement this.
/// Currently: Slack (all capabilities), Discord (most capabilities)
pub trait RichChannel:
Threaded + Streaming + Reactions + GroupManagement + Directory + Pairing
{
}

// Blanket implementation: any type that implements all Layer 1 traits
// automatically implements RichChannel.
impl<T> RichChannel for T where
T: Threaded + Streaming + Reactions + GroupManagement + Directory + Pairing
{
}

Capability Matrix

Not all platforms support all features. The capability matrix documents what each adapter implements:

CapabilitySlackDiscordTelegramWhatsAppMatrixIRCWeb APIDesktop
Channel (L0)
Threaded (L1)
Streaming (L1)
Reactions (L1)
GroupManagement (L1)
Directory (L1)
Pairing (L1)
RichChannel (L2)
ISP Compliance

The Interface Segregation Principle states that "clients should not be forced to depend upon interfaces they do not use." By splitting capabilities into separate traits, IRC adapters don't need to stub out threading or reactions, and the Telegram adapter doesn't need to pretend it supports streaming.

Runtime Capability Detection

The ChannelMeta struct includes a ChannelCapabilities bitfield for runtime capability detection:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMeta {
pub id: ChannelId,
pub kind: ChannelKind,
pub display_name: String,
pub capabilities: ChannelCapabilities,
pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ChannelCapabilities {
pub threading: bool,
pub streaming: bool,
pub reactions: bool,
pub group_management: bool,
pub directory: bool,
pub pairing: bool,
pub rich_text: bool,
pub file_upload: bool,
pub voice: bool,
pub max_message_length: usize,
}

impl ChannelCapabilities {
/// Check if a specific capability is available.
pub fn supports(&self, capability: CapabilityKind) -> bool {
match capability {
CapabilityKind::Threading => self.threading,
CapabilityKind::Streaming => self.streaming,
CapabilityKind::Reactions => self.reactions,
CapabilityKind::GroupManagement => self.group_management,
CapabilityKind::Directory => self.directory,
CapabilityKind::Pairing => self.pairing,
CapabilityKind::RichText => self.rich_text,
CapabilityKind::FileUpload => self.file_upload,
CapabilityKind::Voice => self.voice,
}
}

/// Count the number of supported capabilities.
pub fn capability_count(&self) -> usize {
[
self.threading, self.streaming, self.reactions,
self.group_management, self.directory, self.pairing,
self.rich_text, self.file_upload, self.voice,
].iter().filter(|&&v| v).count()
}
}

Dynamic Downcasting

At runtime, handlers can check for and use optional capabilities:

/// Send a response, using streaming if available, falling back to plain send.
pub async fn send_response(
channel: &dyn Channel,
response: Response,
) -> Result<(), ChannelError> {
// Check if the channel supports streaming
if channel.meta().capabilities.streaming {
// Try to downcast to Streaming trait
if let Some(streaming) = as_streaming(channel) {
let stream = response.into_stream();
streaming.send_stream(stream).await?;
return Ok(());
}
}

// Fallback: send as single message
channel.send(&response.into_message()).await
}

/// Attempt to downcast a Channel to a Streaming channel.
fn as_streaming(channel: &dyn Channel) -> Option<&dyn Streaming> {
// Uses trait object downcasting via Any
// Implementation depends on the concrete type registry
CHANNEL_REGISTRY.downcast_streaming(channel)
}

Channel Registry

The ChannelRegistry manages all active channel adapters:

// crates/clawdesk-channels/src/registry.rs

pub struct ChannelRegistry {
channels: HashMap<ChannelId, Box<dyn Channel>>,
threaded: HashMap<ChannelId, Arc<dyn Threaded>>,
streaming: HashMap<ChannelId, Arc<dyn Streaming>>,
reactions: HashMap<ChannelId, Arc<dyn Reactions>>,
}

impl ChannelRegistry {
/// Register a channel adapter, automatically detecting capabilities.
pub fn register<C: Channel + 'static>(&mut self, channel: C) {
let id = channel.id();
let meta = channel.meta().clone();

// Store the base channel
self.channels.insert(id.clone(), Box::new(channel));

tracing::info!(
channel = %id,
kind = ?meta.kind,
capabilities = meta.capabilities.capability_count(),
"channel registered"
);
}

/// Get a channel by ID.
pub fn get(&self, id: &ChannelId) -> Option<&dyn Channel> {
self.channels.get(id).map(|c| c.as_ref())
}

/// Get all channels of a specific kind.
pub fn by_kind(&self, kind: ChannelKind) -> Vec<&dyn Channel> {
self.channels.values()
.filter(|c| c.meta().kind == kind)
.map(|c| c.as_ref())
.collect()
}

/// Build the registry from configuration.
pub async fn from_config(
configs: &[ChannelConfig],
) -> Result<Self, ChannelError> {
let mut registry = Self::new();

for config in configs {
let channel = match config.kind {
ChannelKind::Slack => {
Box::new(SlackChannel::from_config(config).await?)
as Box<dyn Channel>
}
ChannelKind::Discord => {
Box::new(DiscordChannel::from_config(config).await?)
as Box<dyn Channel>
}
ChannelKind::Telegram => {
Box::new(TelegramChannel::from_config(config).await?)
as Box<dyn Channel>
}
ChannelKind::Irc => {
Box::new(IrcChannel::from_config(config).await?)
as Box<dyn Channel>
}
// ... other channel kinds
};

registry.register_boxed(channel);
}

Ok(registry)
}
}

Implementing a New Channel Adapter

Adding support for a new platform requires implementing the Channel trait (Layer 0) at minimum:

// crates/clawdesk-channels/src/my_platform.rs

pub struct MyPlatformChannel {
id: ChannelId,
meta: ChannelMeta,
client: MyPlatformClient,
rx: mpsc::Receiver<InboundMessage>,
}

#[async_trait]
impl Channel for MyPlatformChannel {
fn id(&self) -> ChannelId {
self.id.clone()
}

fn meta(&self) -> &ChannelMeta {
&self.meta
}

async fn start(&mut self) -> Result<(), ChannelError> {
// Connect to the platform API
self.client.connect().await
.map_err(|e| ChannelError::ConnectionFailed {
channel: self.id.clone(),
reason: e.to_string(),
})?;

// Start listening for messages
let tx = self.tx.clone();
let client = self.client.clone();
tokio::spawn(async move {
while let Some(raw_msg) = client.next_message().await {
let inbound = InboundMessage::MyPlatform(raw_msg.into());
if tx.send(inbound).await.is_err() {
break;
}
}
});

Ok(())
}

async fn send(&self, message: &OutboundMessage) -> Result<(), ChannelError> {
let platform_msg = message.to_my_platform_format();
self.client.send_message(platform_msg).await
.map_err(|e| ChannelError::SendFailed {
channel: self.id.clone(),
reason: e.to_string(),
})
}

async fn stop(&mut self) -> Result<(), ChannelError> {
self.client.disconnect().await
.map_err(|e| ChannelError::ShutdownFailed {
channel: self.id.clone(),
reason: e.to_string(),
})
}

fn health(&self) -> HealthStatus {
if self.client.is_connected() {
HealthStatus::Healthy
} else {
HealthStatus::Unhealthy("disconnected".into())
}
}
}
Adding Optional Capabilities

To add threading support, implement the Threaded trait in addition to Channel. To add streaming, implement Streaming. Each capability is independently addable without affecting existing code.

Channel Message Flow

Summary

LayerTraitRequired?Implementors
L0ChannelYesAll 8 adapters
L1ThreadedNoSlack, Discord, Telegram, Matrix
L1StreamingNoSlack, Discord, Web API, Desktop
L1ReactionsNoSlack, Discord, Telegram, WhatsApp, Matrix
L1GroupManagementNoSlack, Discord, Telegram, WhatsApp, Matrix, IRC
L1DirectoryNoSlack, Discord, Matrix
L1PairingNoSlack, Discord, Telegram, WhatsApp, Matrix
L2RichChannelNoSlack only

The layered trait hierarchy ensures that:

  1. Simple adapters are simple — IRC only needs 5 methods
  2. Rich adapters are rich — Slack can expose all its capabilities
  3. The compiler enforces contracts — No runtime surprises about missing capabilities
  4. New capabilities can be added — Just add a new Layer 1 trait without breaking existing adapters