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}