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}