Skip to main content

chump_messaging/
lib.rs

1//! Platform-agnostic messaging abstraction for COMP-004 (multi-platform
2//! gateway: Telegram, Slack, Matrix, plus the existing Discord/PWA).
3//!
4//! Today the Discord adapter (`src/discord.rs`) and PWA web server
5//! (`src/web_server.rs`) each speak directly to the agent loop with
6//! platform-specific event types. Adding Telegram (COMP-004b) without
7//! a trait would mean copying ~1400 lines of Discord handler glue and
8//! diverging the per-platform behavior over time.
9//!
10//! The `MessagingAdapter` trait is the contract Telegram + Slack +
11//! Matrix adapters implement. The Discord/PWA adapters keep their
12//! current implementations and ship a thin shim that exposes them
13//! through this trait — see [`DiscordShim`] for an example.
14//!
15//! ## Design
16//!
17//! - **`IncomingMessage`** — platform-agnostic representation of a
18//!   user-sent message. Capabilities (DM vs channel, attachments,
19//!   thread context) carried as fields rather than per-platform enums
20//!   so adapters with weaker features just leave fields empty.
21//!
22//! - **`MessagingAdapter`** — the impl-this trait. Three core methods:
23//!   `start()` spins up the platform's event loop;
24//!   `send_reply()` answers in the original channel/thread;
25//!   `send_dm()` reaches the user privately for approval prompts /
26//!   session events. The trait is `Send + Sync` so the agent loop
27//!   can hand a shared reference to multiple tools.
28//!
29//! - **`MessagingHub`** — owns N adapters and routes outbound traffic
30//!   based on the channel-id namespacing (`telegram:chat-123`,
31//!   `discord:guild-456:channel-789`, ...). Wire-only piece — adapters
32//!   don't talk to each other.
33//!
34//! ## Out of scope for COMP-004a (this commit)
35//!
36//! - Migrating `src/discord.rs` internals to use the trait.
37//!   `DiscordShim` is sufficient for the COMP-004a acceptance ("Discord
38//!   implements MessagingAdapter") without rewriting 1400 lines.
39//! - The actual Telegram/Slack adapters — those are COMP-004b/c.
40//! - Approval-flow surface (request_approval) — sketched as a TODO
41//!   here so the API doesn't churn when COMP-004b adds it.
42
43use anyhow::Result;
44use async_trait::async_trait;
45use std::sync::Arc;
46
47/// A user-sent message, normalized across platforms.
48#[derive(Debug, Clone)]
49pub struct IncomingMessage {
50    /// Platform-specific channel id, namespaced. Examples:
51    ///   "discord:guild-X:channel-Y" / "discord:dm:user-Z"
52    ///   "telegram:chat-123"
53    ///   "slack:T01:C02"
54    /// The platform-prefix lets `MessagingHub` route replies back
55    /// without consulting the adapter.
56    pub channel_id: String,
57
58    /// Platform user id of the sender (for DM-back, attribution).
59    pub sender_id: String,
60
61    /// Display name when the platform exposes one ("jeffadkins",
62    /// "@jeff_adkins", "Jeff (Acme corp)"). Empty string is allowed.
63    pub sender_display: String,
64
65    /// Plain-text content. Adapters strip platform markup (Discord's
66    /// <@mention> tags, Telegram's @bot prefix, etc.) before populating.
67    pub content: String,
68
69    /// True when the message was a direct message rather than a public
70    /// channel post. Drives default privacy / approval-prompt routing.
71    pub is_dm: bool,
72
73    /// Attachment URLs the message included (image / file). Empty when
74    /// the platform attachment hasn't been resolved to a URL yet OR
75    /// the message had none.
76    pub attachments: Vec<String>,
77
78    /// Free-form per-platform metadata (Discord guild_id, Telegram
79    /// thread_id, Slack thread_ts). Adapters serialize whatever they
80    /// might need to honor a reply later. Read-only — agent shouldn't
81    /// mutate this.
82    pub platform_metadata: serde_json::Value,
83}
84
85impl IncomingMessage {
86    /// Identify the platform from the channel_id prefix. Returns "unknown"
87    /// when the channel_id doesn't carry a colon-prefix.
88    pub fn platform(&self) -> &str {
89        self.channel_id.split(':').next().unwrap_or("unknown")
90    }
91}
92
93/// What the agent needs to send back to the user. Same shape regardless
94/// of platform — the adapter does the format/layout.
95#[derive(Debug, Clone)]
96pub struct OutgoingMessage {
97    pub text: String,
98    /// Optional file attachments (paths on disk; adapter uploads).
99    pub attachments: Vec<std::path::PathBuf>,
100    /// Optional thread/reply target — channel_id of the message we're
101    /// replying to. None = post as a fresh top-level message.
102    pub in_reply_to: Option<String>,
103}
104
105impl OutgoingMessage {
106    pub fn text(s: impl Into<String>) -> Self {
107        Self {
108            text: s.into(),
109            attachments: vec![],
110            in_reply_to: None,
111        }
112    }
113}
114
115/// The trait every platform adapter implements.
116#[async_trait]
117pub trait MessagingAdapter: Send + Sync {
118    /// Lowercase identifier used as the channel_id prefix. Examples:
119    ///   "discord", "telegram", "slack", "matrix", "pwa"
120    fn platform_name(&self) -> &str;
121
122    /// Spin up the platform-specific event loop. Returns when the
123    /// adapter shuts down (Ctrl+C / signal). The implementation is
124    /// expected to forward `IncomingMessage`s into the agent loop on
125    /// its own thread.
126    async fn start(&self) -> Result<()>;
127
128    /// Send a reply to the channel that originated `incoming`. The
129    /// adapter handles thread targeting, mentions, etc.
130    async fn send_reply(&self, incoming: &IncomingMessage, msg: OutgoingMessage) -> Result<()>;
131
132    /// Send a private message to a user. Used for approval prompts and
133    /// session events ("Mabel restarted at 03:14 UTC"). Returns Err when
134    /// the platform doesn't expose DMs (Slack public-only workspaces).
135    async fn send_dm(&self, user_id: &str, msg: OutgoingMessage) -> Result<()>;
136
137    /// Optional: request a tool-approval response from the user. Default
138    /// impl falls back to send_dm with a Y/N prompt and no inline UI.
139    /// Telegram/Slack adapters override to use inline keyboards.
140    /// Returns the user's response or Err on timeout.
141    ///
142    /// COMP-004b will flesh this out; for now keep the surface stable.
143    async fn request_approval(
144        &self,
145        user_id: &str,
146        prompt: &str,
147        _timeout_secs: u64,
148    ) -> Result<ApprovalResponse> {
149        self.send_dm(user_id, OutgoingMessage::text(prompt)).await?;
150        // Default impl can't actually wait for a reply; that's the
151        // adapter's job. Return Pending so callers know they need to
152        // implement properly.
153        Ok(ApprovalResponse::Pending)
154    }
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum ApprovalResponse {
159    /// User accepted the action.
160    Approved,
161    /// User explicitly rejected.
162    Rejected,
163    /// Adapter sent the prompt but doesn't support a synchronous
164    /// reply read. Caller falls back to its existing approval-resolver.
165    Pending,
166    /// Timed out waiting for the user.
167    Timeout,
168}
169
170/// Routes outbound messages to the right adapter based on channel_id
171/// prefix. Held as an Arc so it can be cloned across tasks.
172#[derive(Default)]
173pub struct MessagingHub {
174    adapters: Vec<Arc<dyn MessagingAdapter>>,
175}
176
177impl MessagingHub {
178    pub fn new() -> Self {
179        Self::default()
180    }
181
182    pub fn register(&mut self, adapter: Arc<dyn MessagingAdapter>) {
183        self.adapters.push(adapter);
184    }
185
186    /// Look up the adapter responsible for a channel_id by its prefix.
187    pub fn adapter_for(&self, channel_id: &str) -> Option<&Arc<dyn MessagingAdapter>> {
188        let prefix = channel_id.split(':').next()?;
189        self.adapters.iter().find(|a| a.platform_name() == prefix)
190    }
191
192    pub fn registered_platforms(&self) -> Vec<&str> {
193        self.adapters.iter().map(|a| a.platform_name()).collect()
194    }
195}
196
197// Thin shim that exposes the existing Discord adapter (src/discord.rs)
198// through the MessagingAdapter trait. Doesn't change Discord internals;
199// just proves the trait is shape-compatible. Real wiring (start() that
200// (DiscordShim — moved out of the standalone crate to keep this lib pure.
201// The shim references chump's internal `crate::discord_dm` module and is
202// chump-specific, not part of the generic trait surface. Find it in the
203// parent chump bin at `src/messaging/discord_shim.rs`.)
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn incoming_message_platform_extracts_prefix() {
211        let m = IncomingMessage {
212            channel_id: "telegram:chat-123".into(),
213            sender_id: "user-456".into(),
214            sender_display: "Jeff".into(),
215            content: "hi".into(),
216            is_dm: true,
217            attachments: vec![],
218            platform_metadata: serde_json::Value::Null,
219        };
220        assert_eq!(m.platform(), "telegram");
221    }
222
223    #[test]
224    fn incoming_message_platform_unknown_when_no_prefix() {
225        let m = IncomingMessage {
226            channel_id: "raw-id-no-prefix".into(),
227            sender_id: "user".into(),
228            sender_display: "".into(),
229            content: "".into(),
230            is_dm: false,
231            attachments: vec![],
232            platform_metadata: serde_json::Value::Null,
233        };
234        // split(':').next() always returns Some, even when there's no
235        // colon — the whole string. So "platform" is the whole id when
236        // unprefixed. We accept that — the unknown-prefix case still
237        // routes nowhere via MessagingHub::adapter_for.
238        assert_eq!(m.platform(), "raw-id-no-prefix");
239    }
240
241    #[test]
242    fn outgoing_text_helper() {
243        let m = OutgoingMessage::text("hello");
244        assert_eq!(m.text, "hello");
245        assert!(m.attachments.is_empty());
246        assert!(m.in_reply_to.is_none());
247    }
248
249    // Hub-routing test uses a minimal local FakeAdapter in lieu of the
250    // bin-side DiscordShim, so the standalone crate stays pure (no
251    // chump-internal dependencies).
252    struct FakeAdapter(String);
253
254    #[async_trait]
255    impl MessagingAdapter for FakeAdapter {
256        fn platform_name(&self) -> &str {
257            &self.0
258        }
259        async fn start(&self) -> Result<()> {
260            Ok(())
261        }
262        async fn send_reply(
263            &self,
264            _incoming: &IncomingMessage,
265            _msg: OutgoingMessage,
266        ) -> Result<()> {
267            Ok(())
268        }
269        async fn send_dm(&self, _user_id: &str, _msg: OutgoingMessage) -> Result<()> {
270            Ok(())
271        }
272    }
273
274    #[test]
275    fn hub_registers_and_routes_by_prefix() {
276        let mut hub = MessagingHub::new();
277        hub.register(Arc::new(FakeAdapter("discord".into())));
278        assert_eq!(hub.registered_platforms(), vec!["discord"]);
279
280        let dis = hub.adapter_for("discord:guild-1:ch-2");
281        assert!(dis.is_some());
282        let unknown = hub.adapter_for("telegram:chat-9");
283        assert!(unknown.is_none());
284    }
285}