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#[async_trait]
76pub trait Channel: Send + Sync {
77    /// Human-readable channel name.
78    fn name(&self) -> &str;
79
80    /// Start listening for incoming messages.
81    /// Returns a receiver that the engine polls.
82    async fn start(&self) -> anyhow::Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
83
84    /// Send a message to a thread/conversation.
85    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()>;
86
87    /// Acknowledge an interactive callback to dismiss the loading indicator.
88    ///
89    /// Telegram requires `answerCallbackQuery` to dismiss the spinner after a button press.
90    /// Discord buttons are already acknowledged in the websocket handler — no-op here.
91    /// Default implementation is a no-op so non-interactive channels need not implement it.
92    async fn ack_interaction(&self, _callback_query_id: &str) -> anyhow::Result<()> {
93        Ok(())
94    }
95
96    /// Check if this channel is healthy/connected.
97    async fn health_check(&self) -> anyhow::Result<()>;
98}
99
100/// Registry of active channels.
101pub struct ChannelRegistry {
102    channels: Vec<Box<dyn Channel>>,
103}
104
105impl ChannelRegistry {
106    pub fn new() -> Self {
107        Self {
108            channels: Vec::new(),
109        }
110    }
111
112    pub fn register(&mut self, channel: Box<dyn Channel>) {
113        tracing::info!(channel = channel.name(), "registered channel");
114        self.channels.push(channel);
115    }
116
117    pub fn iter(&self) -> impl Iterator<Item = &dyn Channel> {
118        self.channels.iter().map(|c| c.as_ref())
119    }
120}