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}