Skip to main content

microclaw_engine/internal/channels/
channel_adapter.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use async_trait::async_trait;
6
7use crate::internal::channels::channel::ConversationKind;
8
9#[async_trait]
10pub trait ChannelAdapter: Send + Sync {
11    /// Unique name: "telegram", "discord", "slack", "web"
12    fn name(&self) -> &str;
13
14    /// DB chat_type strings this adapter handles + whether each is private/group.
15    fn chat_type_routes(&self) -> Vec<(&str, ConversationKind)>;
16
17    /// Whether this channel stores messages only (no external delivery). Web = true.
18    fn is_local_only(&self) -> bool {
19        false
20    }
21
22    /// Whether chats on this channel can operate on other chats. Web = false.
23    fn allows_cross_chat(&self) -> bool {
24        true
25    }
26
27    /// Send text to external chat. Called by deliver_and_store_bot_message.
28    async fn send_text(&self, external_chat_id: &str, text: &str) -> Result<(), String>;
29
30    /// Maximum UTF-8 byte length for one native text item. When present, the
31    /// shared delivery pipeline persists and sends chunks independently.
32    fn text_chunk_limit_bytes(&self) -> Option<usize> {
33        None
34    }
35
36    /// Optional pacing between consecutive chunks of one logical message.
37    fn text_chunk_delay(&self) -> Option<std::time::Duration> {
38        None
39    }
40
41    /// Send one already-split chunk. Channels with native idempotency support
42    /// should use `idempotency_key` as their request/client message id.
43    async fn send_text_chunk(
44        &self,
45        external_chat_id: &str,
46        text: &str,
47        _idempotency_key: &str,
48    ) -> Result<(), String> {
49        self.send_text(external_chat_id, text).await
50    }
51
52    /// Send file attachment. Default: not supported.
53    async fn send_attachment(
54        &self,
55        _external_chat_id: &str,
56        _file_path: &Path,
57        _caption: Option<&str>,
58    ) -> Result<String, String> {
59        Err(format!("attachments not supported for {}", self.name()))
60    }
61}
62
63#[derive(Default)]
64pub struct ChannelRegistry {
65    adapters: HashMap<String, Arc<dyn ChannelAdapter>>,
66    /// "slack_dm" -> "slack", "telegram_private" -> "telegram", etc.
67    type_to_channel: HashMap<String, String>,
68    /// "slack_dm" -> Private, "group" -> Group, etc.
69    type_to_conversation: HashMap<String, ConversationKind>,
70    /// Outbound message guardrail mode, applied in
71    /// `deliver_and_store_bot_message`. Set once at startup; defaults to `Off`.
72    output_guardrail: microclaw_core::redact::OutputGuardrailMode,
73}
74
75impl ChannelRegistry {
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// Set the outbound message guardrail mode (applied at delivery).
81    pub fn set_output_guardrail(&mut self, mode: microclaw_core::redact::OutputGuardrailMode) {
82        self.output_guardrail = mode;
83    }
84
85    /// The configured outbound guardrail mode.
86    pub fn output_guardrail(&self) -> microclaw_core::redact::OutputGuardrailMode {
87        self.output_guardrail
88    }
89
90    pub fn register(&mut self, adapter: Arc<dyn ChannelAdapter>) {
91        let name = adapter.name().to_string();
92        for (chat_type, kind) in adapter.chat_type_routes() {
93            self.type_to_channel
94                .insert(chat_type.to_string(), name.clone());
95            self.type_to_conversation
96                .insert(chat_type.to_string(), kind);
97        }
98        self.adapters.insert(name, adapter);
99    }
100
101    pub fn get(&self, name: &str) -> Option<&Arc<dyn ChannelAdapter>> {
102        self.adapters.get(name)
103    }
104
105    /// Resolve a DB chat_type string to the adapter and conversation kind.
106    pub fn resolve(
107        &self,
108        db_chat_type: &str,
109    ) -> Option<(&Arc<dyn ChannelAdapter>, ConversationKind)> {
110        let channel_name = self.type_to_channel.get(db_chat_type)?;
111        let adapter = self.adapters.get(channel_name)?;
112        let kind = self.type_to_conversation.get(db_chat_type)?;
113        Some((adapter, *kind))
114    }
115
116    /// Resolve only the channel name and conversation kind (without needing the adapter).
117    pub fn resolve_routing(&self, db_chat_type: &str) -> Option<(&str, ConversationKind)> {
118        let channel_name = self.type_to_channel.get(db_chat_type)?;
119        let kind = self.type_to_conversation.get(db_chat_type)?;
120        Some((channel_name.as_str(), *kind))
121    }
122
123    pub fn has_any(&self) -> bool {
124        !self.adapters.is_empty()
125    }
126}