Skip to main content

apollo/channels/
traits.rs

1//! Core Channel trait — messaging interface.
2//! Inspired by ZeroClaw's channel abstraction + NanoClaw's group isolation.
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use tokio::sync::mpsc;
7
8/// Incoming message from a channel.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct IncomingMessage {
11    pub id: String,
12    pub sender_id: String,
13    pub sender_name: Option<String>,
14    pub chat_id: String,
15    pub text: String,
16    pub is_group: bool,
17    pub reply_to: Option<String>,
18    pub timestamp: chrono::DateTime<chrono::Utc>,
19}
20
21/// Outgoing message to a channel.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct OutgoingMessage {
24    pub chat_id: String,
25    pub text: String,
26    pub reply_to: Option<String>,
27}
28
29/// The core Channel trait.
30/// Implement for each messaging platform (Telegram, Discord, CLI, WebSocket, etc.)
31#[async_trait]
32pub trait Channel: Send + Sync {
33    /// Channel name (e.g., "telegram", "discord", "cli")
34    fn name(&self) -> &str;
35
36    /// Start receiving messages. Returns a receiver for incoming messages.
37    async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>>;
38
39    /// Send a message through this channel. Returns an optional message ID for tracking/editing.
40    async fn send(&self, message: OutgoingMessage) -> anyhow::Result<Option<String>>;
41
42    /// Send a typing indicator (or equivalent) to the user.
43    async fn send_typing(&self, _chat_id: &str) -> anyhow::Result<()> {
44        Ok(())
45    }
46
47    /// Edit a previously sent message if the channel supports it.
48    async fn edit(&self, _chat_id: &str, _message_id: &str, _new_text: &str) -> anyhow::Result<()> {
49        Ok(())
50    }
51
52    /// Live draft support, if this channel implements it.
53    ///
54    /// Returning `Some` is the *only* way to opt into draft delivery, and
55    /// `DraftChannel` has no default methods, so opting in forces a real
56    /// `finalize_draft`.
57    fn as_draft(&self) -> Option<&dyn DraftChannel> {
58        None
59    }
60
61    /// Stop the channel gracefully.
62    async fn stop(&mut self) -> anyhow::Result<()>;
63}
64
65/// Live draft updates: send a placeholder → edit with progress → finalize with
66/// the answer.
67///
68/// Deliberately has no default implementations: a channel that claims draft
69/// support must actually be able to put the final text on screen, because the
70/// agent loop will not also return it to the caller.
71#[async_trait]
72pub trait DraftChannel: Send + Sync {
73    /// Send an initial draft placeholder (e.g. "⏳"). Returns message ID for edits.
74    async fn send_draft(&self, chat_id: &str, text: &str) -> anyhow::Result<Option<String>>;
75
76    /// Edit the draft with a live progress update (tool start/end). Rate-limited by impl.
77    async fn update_draft_progress(
78        &self,
79        chat_id: &str,
80        message_id: &str,
81        text: &str,
82    ) -> anyhow::Result<()>;
83
84    /// Finalize the draft with the full formatted response, replacing any progress text.
85    async fn finalize_draft(
86        &self,
87        chat_id: &str,
88        message_id: &str,
89        text: &str,
90    ) -> anyhow::Result<()>;
91}
92
93/// Who is responsible for putting the final reply in front of the user.
94///
95/// Constructed once per turn and matched exhaustively at the end of it, so
96/// "the caller gets the text" and "the channel already showed the text" are
97/// two arms of one decision instead of two independent `if` statements that
98/// can drift apart.
99pub enum Delivery<'a> {
100    /// A live draft message owns the reply; `deliver` edits it in place.
101    Draft {
102        channel: &'a dyn DraftChannel,
103        message_id: String,
104    },
105    /// Nobody has shown anything; `deliver` hands the text back to the caller.
106    Return,
107}
108
109impl<'a> Delivery<'a> {
110    /// Open a draft on `channel` if it supports drafts and the placeholder
111    /// send succeeds; otherwise the reply is returned to the caller.
112    pub async fn open(channel: &'a dyn Channel, chat_id: &str, placeholder: &str) -> Self {
113        let Some(draft) = channel.as_draft() else {
114            return Delivery::Return;
115        };
116        match draft.send_draft(chat_id, placeholder).await {
117            Ok(Some(message_id)) => Delivery::Draft {
118                channel: draft,
119                message_id,
120            },
121            Ok(None) => Delivery::Return,
122            Err(e) => {
123                tracing::warn!("send_draft failed, falling back to returned reply: {e}");
124                Delivery::Return
125            }
126        }
127    }
128
129    /// The live draft, if one is open.
130    pub fn draft(&self) -> Option<(&dyn DraftChannel, &str)> {
131        match self {
132            Delivery::Draft {
133                channel,
134                message_id,
135            } => Some((*channel, message_id.as_str())),
136            Delivery::Return => None,
137        }
138    }
139
140    /// Deliver `text`. Returns the string `handle_message` should yield:
141    /// empty when the draft already carries it, the text itself otherwise.
142    pub async fn deliver(&self, chat_id: &str, text: &str) -> anyhow::Result<String> {
143        match self {
144            Delivery::Draft {
145                channel,
146                message_id,
147            } => {
148                channel.finalize_draft(chat_id, message_id, text).await?;
149                Ok(String::new())
150            }
151            Delivery::Return => Ok(text.to_string()),
152        }
153    }
154}