use anyhow::Result;
use async_trait::async_trait;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct IncomingMessage {
pub channel_id: String,
pub sender_id: String,
pub sender_display: String,
pub content: String,
pub is_dm: bool,
pub attachments: Vec<String>,
pub platform_metadata: serde_json::Value,
}
impl IncomingMessage {
pub fn platform(&self) -> &str {
self.channel_id.split(':').next().unwrap_or("unknown")
}
}
#[derive(Debug, Clone)]
pub struct OutgoingMessage {
pub text: String,
pub attachments: Vec<std::path::PathBuf>,
pub in_reply_to: Option<String>,
}
impl OutgoingMessage {
pub fn text(s: impl Into<String>) -> Self {
Self {
text: s.into(),
attachments: vec![],
in_reply_to: None,
}
}
}
#[async_trait]
pub trait MessagingAdapter: Send + Sync {
fn platform_name(&self) -> &str;
async fn start(&self) -> Result<()>;
async fn send_reply(&self, incoming: &IncomingMessage, msg: OutgoingMessage) -> Result<()>;
async fn send_dm(&self, user_id: &str, msg: OutgoingMessage) -> Result<()>;
async fn request_approval(
&self,
user_id: &str,
prompt: &str,
_timeout_secs: u64,
) -> Result<ApprovalResponse> {
self.send_dm(user_id, OutgoingMessage::text(prompt)).await?;
Ok(ApprovalResponse::Pending)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ApprovalResponse {
Approved,
Rejected,
Pending,
Timeout,
}
#[derive(Default)]
pub struct MessagingHub {
adapters: Vec<Arc<dyn MessagingAdapter>>,
}
impl MessagingHub {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, adapter: Arc<dyn MessagingAdapter>) {
self.adapters.push(adapter);
}
pub fn adapter_for(&self, channel_id: &str) -> Option<&Arc<dyn MessagingAdapter>> {
let prefix = channel_id.split(':').next()?;
self.adapters.iter().find(|a| a.platform_name() == prefix)
}
pub fn registered_platforms(&self) -> Vec<&str> {
self.adapters.iter().map(|a| a.platform_name()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn incoming_message_platform_extracts_prefix() {
let m = IncomingMessage {
channel_id: "telegram:chat-123".into(),
sender_id: "user-456".into(),
sender_display: "Jeff".into(),
content: "hi".into(),
is_dm: true,
attachments: vec![],
platform_metadata: serde_json::Value::Null,
};
assert_eq!(m.platform(), "telegram");
}
#[test]
fn incoming_message_platform_unknown_when_no_prefix() {
let m = IncomingMessage {
channel_id: "raw-id-no-prefix".into(),
sender_id: "user".into(),
sender_display: "".into(),
content: "".into(),
is_dm: false,
attachments: vec![],
platform_metadata: serde_json::Value::Null,
};
assert_eq!(m.platform(), "raw-id-no-prefix");
}
#[test]
fn outgoing_text_helper() {
let m = OutgoingMessage::text("hello");
assert_eq!(m.text, "hello");
assert!(m.attachments.is_empty());
assert!(m.in_reply_to.is_none());
}
struct FakeAdapter(String);
#[async_trait]
impl MessagingAdapter for FakeAdapter {
fn platform_name(&self) -> &str {
&self.0
}
async fn start(&self) -> Result<()> {
Ok(())
}
async fn send_reply(
&self,
_incoming: &IncomingMessage,
_msg: OutgoingMessage,
) -> Result<()> {
Ok(())
}
async fn send_dm(&self, _user_id: &str, _msg: OutgoingMessage) -> Result<()> {
Ok(())
}
}
#[test]
fn hub_registers_and_routes_by_prefix() {
let mut hub = MessagingHub::new();
hub.register(Arc::new(FakeAdapter("discord".into())));
assert_eq!(hub.registered_platforms(), vec!["discord"]);
let dis = hub.adapter_for("discord:guild-1:ch-2");
assert!(dis.is_some());
let unknown = hub.adapter_for("telegram:chat-9");
assert!(unknown.is_none());
}
}