Skip to main content

orch/channels/
mod.rs

1//! Channel abstraction — the core trait for all I/O surfaces.
2//!
3//! Every external interface (GitHub, Telegram, Discord, tmux) implements
4//! the `Channel` trait. This gives the engine a uniform way to:
5//! - Receive commands/messages
6//! - Send task updates and agent output
7//!
8//! Channels are bidirectional and async. The engine doesn't care whether
9//! a message came from a Telegram DM, a GitHub issue comment, or a
10//! Discord thread — it processes the same `IncomingMessage` type.
11
12pub mod capture;
13pub mod discord;
14pub mod discord_ws;
15pub mod github;
16pub mod notification;
17pub mod routing;
18pub mod slack;
19pub mod stream;
20pub mod telegram;
21pub mod tmux;
22pub mod transport;
23
24use async_trait::async_trait;
25use serde::{Deserialize, Serialize};
26
27/// A message received from any channel.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct IncomingMessage {
30    /// Channel that produced this message (e.g. "github", "telegram", "discord")
31    pub channel: String,
32    /// Unique ID within the channel (comment ID, message ID, etc.)
33    pub id: String,
34    /// Thread/conversation context (issue number, chat ID, channel ID)
35    pub thread_id: String,
36    /// Author identifier
37    pub author: String,
38    /// Raw text content
39    pub body: String,
40    /// When the message was created
41    pub timestamp: chrono::DateTime<chrono::Utc>,
42    /// Optional metadata (labels, attachments, etc.)
43    #[serde(default)]
44    pub metadata: serde_json::Value,
45    /// Optional forum topic / thread ID (e.g. Telegram forum topic)
46    #[serde(default)]
47    pub topic_id: Option<String>,
48}
49
50/// A message to send to a channel.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct OutgoingMessage {
53    /// Target thread/conversation
54    pub thread_id: String,
55    /// Message body (markdown)
56    pub body: String,
57    /// Optional: reply to a specific message ID
58    pub reply_to: Option<String>,
59    /// Optional metadata for channel-specific features
60    #[serde(default)]
61    pub metadata: serde_json::Value,
62    /// Optional forum topic / thread ID (e.g. Telegram forum topic)
63    #[serde(default)]
64    pub topic_id: Option<String>,
65}
66
67/// An agent output chunk for streaming.
68#[derive(Debug, Clone)]
69pub struct OutputChunk {
70    pub content: String,
71    pub is_final: bool,
72}
73
74/// The core channel trait. All external interfaces implement this.
75#[allow(clippy::double_must_use)] // async_trait marks its boxed futures must_use twice
76#[async_trait]
77pub trait Channel: Send + Sync {
78    /// Human-readable channel name.
79    fn name(&self) -> &str;
80
81    /// Start listening for incoming messages.
82    /// Returns a receiver that the engine polls.
83    async fn start(&self) -> anyhow::Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
84
85    /// Send a message to a thread/conversation.
86    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()>;
87
88    /// Acknowledge an interactive callback to dismiss the loading indicator.
89    ///
90    /// Telegram requires `answerCallbackQuery` to dismiss the spinner after a button press.
91    /// Discord buttons are already acknowledged in the websocket handler — no-op here.
92    /// Default implementation is a no-op so non-interactive channels need not implement it.
93    async fn ack_interaction(&self, _callback_query_id: &str) -> anyhow::Result<()> {
94        Ok(())
95    }
96
97    /// Check if this channel is healthy/connected.
98    async fn health_check(&self) -> anyhow::Result<()>;
99}
100
101/// Registry of active channels.
102pub struct ChannelRegistry {
103    channels: Vec<Box<dyn Channel>>,
104}
105
106impl ChannelRegistry {
107    pub fn new() -> Self {
108        Self {
109            channels: Vec::new(),
110        }
111    }
112
113    pub fn register(&mut self, channel: Box<dyn Channel>) {
114        tracing::info!(channel = channel.name(), "registered channel");
115        self.channels.push(channel);
116    }
117
118    pub fn iter(&self) -> impl Iterator<Item = &dyn Channel> {
119        self.channels.iter().map(|c| c.as_ref())
120    }
121}