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}