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 std::path::PathBuf;
5
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use tokio::sync::mpsc;
9
10/// Incoming message from a channel.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct IncomingMessage {
13    pub id: String,
14    pub sender_id: String,
15    pub sender_name: Option<String>,
16    pub chat_id: String,
17    pub text: String,
18    pub is_group: bool,
19    pub reply_to: Option<String>,
20    pub timestamp: chrono::DateTime<chrono::Utc>,
21}
22
23/// Outgoing message to a channel.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct OutgoingMessage {
26    pub chat_id: String,
27    pub text: String,
28    pub reply_to: Option<String>,
29}
30
31/// What kind of attachment is being sent. Channels map this onto their own
32/// endpoint or MIME handling.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum MediaKind {
36    Image,
37    Document,
38    Voice,
39    Video,
40    Animation,
41}
42
43impl MediaKind {
44    /// The field name most upload APIs expect for this kind of attachment.
45    pub fn field(self) -> &'static str {
46        match self {
47            MediaKind::Image => "photo",
48            MediaKind::Document => "document",
49            MediaKind::Voice => "voice",
50            MediaKind::Video => "video",
51            MediaKind::Animation => "animation",
52        }
53    }
54}
55
56/// Where the bytes come from. A remote URL is handed to the platform to fetch;
57/// a local path is uploaded.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub enum MediaSource {
60    Url(String),
61    Path(std::path::PathBuf),
62}
63
64/// An outgoing attachment.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct OutgoingMedia {
67    pub chat_id: String,
68    pub kind: MediaKind,
69    pub source: MediaSource,
70    pub caption: Option<String>,
71    pub reply_to: Option<String>,
72}
73
74impl OutgoingMedia {
75    /// An image from a URL — the common case for provider-generated pictures.
76    pub fn image_url(chat_id: impl Into<String>, url: impl Into<String>) -> Self {
77        Self {
78            chat_id: chat_id.into(),
79            kind: MediaKind::Image,
80            source: MediaSource::Url(url.into()),
81            caption: None,
82            reply_to: None,
83        }
84    }
85
86    /// A file on disk — the common case for locally rendered audio or images.
87    pub fn file(chat_id: impl Into<String>, kind: MediaKind, path: impl Into<PathBuf>) -> Self {
88        Self {
89            chat_id: chat_id.into(),
90            kind,
91            source: MediaSource::Path(path.into()),
92            caption: None,
93            reply_to: None,
94        }
95    }
96
97    pub fn with_caption(mut self, caption: impl Into<String>) -> Self {
98        self.caption = Some(caption.into());
99        self
100    }
101}
102
103/// The core Channel trait.
104/// Implement for each messaging platform (Telegram, Discord, CLI, WebSocket, etc.)
105#[async_trait]
106pub trait Channel: Send + Sync {
107    /// Channel name (e.g., "telegram", "discord", "cli")
108    fn name(&self) -> &str;
109
110    /// Start receiving messages. Returns a receiver for incoming messages.
111    async fn start(&mut self) -> anyhow::Result<mpsc::Receiver<IncomingMessage>>;
112
113    /// Send a message through this channel. Returns an optional message ID for tracking/editing.
114    async fn send(&self, message: OutgoingMessage) -> anyhow::Result<Option<String>>;
115
116    /// Send a typing indicator (or equivalent) to the user.
117    async fn send_typing(&self, _chat_id: &str) -> anyhow::Result<()> {
118        Ok(())
119    }
120
121    /// Send an attachment.
122    ///
123    /// The default errors rather than silently degrading to a text message
124    /// with a URL in it: a caller that asked for a picture and got a link back
125    /// has been lied to. Channels that can carry media override this.
126    async fn send_media(&self, media: OutgoingMedia) -> anyhow::Result<Option<String>> {
127        anyhow::bail!("{} cannot send {:?} attachments", self.name(), media.kind)
128    }
129
130    /// Whether `send_media` will do anything for this channel. Callers that can
131    /// fall back to text should check this instead of sending and catching.
132    fn supports_media(&self) -> bool {
133        false
134    }
135
136    /// Edit a previously sent message if the channel supports it.
137    async fn edit(&self, _chat_id: &str, _message_id: &str, _new_text: &str) -> anyhow::Result<()> {
138        Ok(())
139    }
140
141    /// Live draft support, if this channel implements it.
142    ///
143    /// Returning `Some` is the *only* way to opt into draft delivery, and
144    /// `DraftChannel` has no default methods, so opting in forces a real
145    /// `finalize_draft`.
146    fn as_draft(&self) -> Option<&dyn DraftChannel> {
147        None
148    }
149
150    /// Stop the channel gracefully.
151    async fn stop(&mut self) -> anyhow::Result<()>;
152}
153
154/// Live draft updates: send a placeholder → edit with progress → finalize with
155/// the answer.
156///
157/// Deliberately has no default implementations: a channel that claims draft
158/// support must actually be able to put the final text on screen, because the
159/// agent loop will not also return it to the caller.
160#[async_trait]
161pub trait DraftChannel: Send + Sync {
162    /// Send an initial draft placeholder (e.g. "⏳"). Returns message ID for edits.
163    async fn send_draft(&self, chat_id: &str, text: &str) -> anyhow::Result<Option<String>>;
164
165    /// Edit the draft with a live progress update (tool start/end). Rate-limited by impl.
166    async fn update_draft_progress(
167        &self,
168        chat_id: &str,
169        message_id: &str,
170        text: &str,
171    ) -> anyhow::Result<()>;
172
173    /// Finalize the draft with the full formatted response, replacing any progress text.
174    async fn finalize_draft(
175        &self,
176        chat_id: &str,
177        message_id: &str,
178        text: &str,
179    ) -> anyhow::Result<()>;
180}
181
182/// Who is responsible for putting the final reply in front of the user.
183///
184/// Constructed once per turn and matched exhaustively at the end of it, so
185/// "the caller gets the text" and "the channel already showed the text" are
186/// two arms of one decision instead of two independent `if` statements that
187/// can drift apart.
188pub enum Delivery<'a> {
189    /// A live draft message owns the reply; `deliver` edits it in place.
190    Draft {
191        channel: &'a dyn DraftChannel,
192        message_id: String,
193    },
194    /// Nobody has shown anything; `deliver` hands the text back to the caller.
195    Return,
196}
197
198impl<'a> Delivery<'a> {
199    /// Open a draft on `channel` if it supports drafts and the placeholder
200    /// send succeeds; otherwise the reply is returned to the caller.
201    pub async fn open(channel: &'a dyn Channel, chat_id: &str, placeholder: &str) -> Self {
202        let Some(draft) = channel.as_draft() else {
203            return Delivery::Return;
204        };
205        match draft.send_draft(chat_id, placeholder).await {
206            Ok(Some(message_id)) => Delivery::Draft {
207                channel: draft,
208                message_id,
209            },
210            Ok(None) => Delivery::Return,
211            Err(e) => {
212                tracing::warn!("send_draft failed, falling back to returned reply: {e}");
213                Delivery::Return
214            }
215        }
216    }
217
218    /// The live draft, if one is open.
219    pub fn draft(&self) -> Option<(&dyn DraftChannel, &str)> {
220        match self {
221            Delivery::Draft {
222                channel,
223                message_id,
224            } => Some((*channel, message_id.as_str())),
225            Delivery::Return => None,
226        }
227    }
228
229    /// Deliver `text`. Returns the string `handle_message` should yield:
230    /// empty when the draft already carries it, the text itself otherwise.
231    pub async fn deliver(&self, chat_id: &str, text: &str) -> anyhow::Result<String> {
232        match self {
233            Delivery::Draft {
234                channel,
235                message_id,
236            } => {
237                channel.finalize_draft(chat_id, message_id, text).await?;
238                Ok(String::new())
239            }
240            Delivery::Return => Ok(text.to_string()),
241        }
242    }
243}