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/// Escape `&`, `<` and `>` for Telegram's HTML parse_mode, which rejects bare ones
28pub(crate) fn html_escape(s: &str) -> String {
29    s.replace('&', "&amp;")
30        .replace('<', "&lt;")
31        .replace('>', "&gt;")
32}
33
34/// Split content into chunks of at most `max_bytes`, never inside a UTF-8 character
35pub(crate) fn split_utf8(content: &str, max_bytes: usize) -> Vec<String> {
36    if content.len() <= max_bytes {
37        return vec![content.to_string()];
38    }
39
40    let mut chunks = Vec::new();
41    let mut start = 0;
42    let total = content.len();
43
44    while start < total {
45        let mut end = (start + max_bytes).min(total);
46        while end > start && !content.is_char_boundary(end) {
47            end -= 1;
48        }
49        if end == start {
50            // A single char over the limit still goes out whole
51            if let Some(ch) = content[start..].chars().next() {
52                end = start + ch.len_utf8();
53            } else {
54                break;
55            }
56        }
57        chunks.push(content[start..end].to_string());
58        start = end;
59    }
60
61    chunks
62}
63
64/// A message received from any channel.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IncomingMessage {
67    /// Channel that produced this message (e.g. "github", "telegram", "discord")
68    pub channel: String,
69    /// Unique ID within the channel (comment ID, message ID, etc.)
70    pub id: String,
71    /// Thread/conversation context (issue number, chat ID, channel ID)
72    pub thread_id: String,
73    /// Author identifier
74    pub author: String,
75    /// Raw text content
76    pub body: String,
77    /// When the message was created
78    pub timestamp: chrono::DateTime<chrono::Utc>,
79    /// Optional metadata (labels, attachments, etc.)
80    #[serde(default)]
81    pub metadata: serde_json::Value,
82    /// Optional forum topic / thread ID (e.g. Telegram forum topic)
83    #[serde(default)]
84    pub topic_id: Option<String>,
85}
86
87/// A message to send to a channel.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct OutgoingMessage {
90    /// Target thread/conversation
91    pub thread_id: String,
92    /// Message body (markdown)
93    pub body: String,
94    /// Optional: reply to a specific message ID
95    pub reply_to: Option<String>,
96    /// Optional metadata for channel-specific features
97    #[serde(default)]
98    pub metadata: serde_json::Value,
99    /// Optional forum topic / thread ID (e.g. Telegram forum topic)
100    #[serde(default)]
101    pub topic_id: Option<String>,
102}
103
104/// An agent output chunk for streaming.
105#[derive(Debug, Clone)]
106pub struct OutputChunk {
107    pub content: String,
108    pub is_final: bool,
109}
110
111/// The core channel trait. All external interfaces implement this.
112#[allow(clippy::double_must_use)] // async_trait marks its boxed futures must_use twice
113#[async_trait]
114pub trait Channel: Send + Sync {
115    /// Human-readable channel name.
116    fn name(&self) -> &str;
117
118    /// Start listening for incoming messages.
119    /// Returns a receiver that the engine polls.
120    async fn start(&self) -> anyhow::Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
121
122    /// Send a message to a thread/conversation.
123    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()>;
124
125    /// Acknowledge an interactive callback to dismiss the loading indicator.
126    ///
127    /// Telegram requires `answerCallbackQuery` to dismiss the spinner after a button press.
128    /// Discord buttons are already acknowledged in the websocket handler — no-op here.
129    /// Default implementation is a no-op so non-interactive channels need not implement it.
130    async fn ack_interaction(&self, _callback_query_id: &str) -> anyhow::Result<()> {
131        Ok(())
132    }
133
134    /// Check if this channel is healthy/connected.
135    async fn health_check(&self) -> anyhow::Result<()>;
136}
137
138/// Registry of active channels.
139pub struct ChannelRegistry {
140    channels: Vec<Box<dyn Channel>>,
141}
142
143impl ChannelRegistry {
144    pub fn new() -> Self {
145        Self {
146            channels: Vec::new(),
147        }
148    }
149
150    pub fn register(&mut self, channel: Box<dyn Channel>) {
151        tracing::info!(channel = channel.name(), "registered channel");
152        self.channels.push(channel);
153    }
154
155    pub fn iter(&self) -> impl Iterator<Item = &dyn Channel> {
156        self.channels.iter().map(|c| c.as_ref())
157    }
158}