Skip to main content

microclaw_engine/internal/channels/
channel.rs

1use std::sync::Arc;
2
3use crate::internal::channels::channel_adapter::ChannelRegistry;
4use crate::internal::storage::db::{call_blocking, Database, OutboxMessageRecord, StoredMessage};
5use microclaw_core::text::{sanitize_user_visible_text, split_text};
6
7#[derive(Clone, Debug)]
8struct ToolAuthContext {
9    caller_chat_id: i64,
10}
11
12fn auth_context_from_input(input: &serde_json::Value) -> Option<ToolAuthContext> {
13    let ctx = input.get("__microclaw_auth")?;
14    let caller_chat_id = ctx.get("caller_chat_id")?.as_i64()?;
15    Some(ToolAuthContext { caller_chat_id })
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ConversationKind {
20    Private,
21    Group,
22}
23
24impl ConversationKind {
25    pub fn as_agent_chat_type(self) -> &'static str {
26        match self {
27            ConversationKind::Private => "private",
28            ConversationKind::Group => "group",
29        }
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ChatRouting {
35    pub channel_name: String,
36    pub conversation: ConversationKind,
37}
38
39pub fn parse_chat_routing(registry: &ChannelRegistry, db_chat_type: &str) -> Option<ChatRouting> {
40    let (channel_name, kind) = registry.resolve_routing(db_chat_type)?;
41    Some(ChatRouting {
42        channel_name: channel_name.to_string(),
43        conversation: kind,
44    })
45}
46
47fn infer_channel_from_chat_type(chat_type: &str) -> Option<&'static str> {
48    if chat_type.starts_with("telegram_") {
49        return Some("telegram");
50    }
51    if chat_type.starts_with("discord_") {
52        return Some("discord");
53    }
54    if chat_type.starts_with("slack_") {
55        return Some("slack");
56    }
57    if chat_type.starts_with("feishu_") {
58        return Some("feishu");
59    }
60    if chat_type.starts_with("matrix_") {
61        return Some("matrix");
62    }
63    if chat_type.starts_with("whatsapp_") {
64        return Some("whatsapp");
65    }
66    if chat_type.starts_with("imessage_") {
67        return Some("imessage");
68    }
69    if chat_type.starts_with("email_") {
70        return Some("email");
71    }
72    if chat_type.starts_with("nostr_") {
73        return Some("nostr");
74    }
75    if chat_type.starts_with("signal_") {
76        return Some("signal");
77    }
78    if chat_type.starts_with("dingtalk_") {
79        return Some("dingtalk");
80    }
81    if chat_type.starts_with("qq_") {
82        return Some("qq");
83    }
84    if chat_type.starts_with("weixin_") {
85        return Some("weixin");
86    }
87    if chat_type.starts_with("irc_") {
88        return Some("irc");
89    }
90    None
91}
92
93pub async fn get_chat_type_raw(db: Arc<Database>, chat_id: i64) -> Result<Option<String>, String> {
94    call_blocking(db, move |d| d.get_chat_type(chat_id))
95        .await
96        .map_err(|e| format!("Failed to read chat type for chat {chat_id}: {e}"))
97}
98
99pub async fn get_chat_channel_raw(
100    db: Arc<Database>,
101    chat_id: i64,
102) -> Result<Option<String>, String> {
103    call_blocking(db, move |d| d.get_chat_channel(chat_id))
104        .await
105        .map_err(|e| format!("Failed to read chat channel for chat {chat_id}: {e}"))
106}
107
108pub async fn get_chat_routing(
109    registry: &ChannelRegistry,
110    db: Arc<Database>,
111    chat_id: i64,
112) -> Result<Option<ChatRouting>, String> {
113    let chat_type = get_chat_type_raw(db.clone(), chat_id).await?;
114    let Some(chat_type) = chat_type else {
115        return Ok(None);
116    };
117
118    let kind = registry
119        .resolve_routing(&chat_type)
120        .map(|(_, k)| k)
121        .unwrap_or(ConversationKind::Group);
122
123    if let Some(channel_name) = get_chat_channel_raw(db, chat_id).await? {
124        let normalized = channel_name.trim();
125        if !normalized.is_empty() && registry.get(normalized).is_some() {
126            return Ok(Some(ChatRouting {
127                channel_name: normalized.to_string(),
128                conversation: kind,
129            }));
130        }
131    }
132
133    Ok(parse_chat_routing(registry, &chat_type))
134}
135
136pub async fn get_required_chat_routing(
137    registry: &ChannelRegistry,
138    db: Arc<Database>,
139    chat_id: i64,
140) -> Result<ChatRouting, String> {
141    let chat_type = get_chat_type_raw(db.clone(), chat_id)
142        .await?
143        .ok_or_else(|| format!("target chat {chat_id} not found"))?;
144    if let Some(routing) = get_chat_routing(registry, db.clone(), chat_id).await? {
145        return Ok(routing);
146    }
147
148    let hinted_channel = get_chat_channel_raw(db, chat_id)
149        .await?
150        .map(|v| v.trim().to_string())
151        .filter(|v| !v.is_empty())
152        .or_else(|| infer_channel_from_chat_type(&chat_type).map(ToOwned::to_owned));
153
154    if let Some(channel_name) = hinted_channel {
155        if registry.get(&channel_name).is_none() {
156            return Err(format!(
157                "channel '{channel_name}' is not enabled for chat {chat_id} (chat_type='{chat_type}')"
158            ));
159        }
160    }
161
162    Err(format!(
163        "unsupported chat type '{chat_type}' for chat {chat_id}"
164    ))
165}
166
167pub fn session_source_for_chat(
168    registry: &ChannelRegistry,
169    chat_type: &str,
170    chat_title: Option<&str>,
171) -> String {
172    // Legacy discord detection: some old records have generic types like "private"
173    // but a title starting with "discord-"
174    if matches!(chat_type, "private" | "group" | "supergroup" | "channel")
175        && chat_title.is_some_and(|t| t.starts_with("discord-"))
176    {
177        return "discord".to_string();
178    }
179
180    if let Some((channel_name, _)) = registry.resolve_routing(chat_type) {
181        return channel_name.to_string();
182    }
183
184    chat_type.to_string()
185}
186
187pub async fn is_web_chat(registry: &ChannelRegistry, db: Arc<Database>, chat_id: i64) -> bool {
188    get_chat_routing(registry, db, chat_id)
189        .await
190        .ok()
191        .flatten()
192        .map(|r| r.channel_name == "web")
193        .unwrap_or(false)
194}
195
196pub async fn enforce_channel_policy(
197    registry: &ChannelRegistry,
198    db: Arc<Database>,
199    input: &serde_json::Value,
200    target_chat_id: i64,
201) -> Result<(), String> {
202    let Some(auth) = auth_context_from_input(input) else {
203        return Ok(());
204    };
205
206    // Check if the caller's channel disallows cross-chat operations
207    if let Ok(Some(routing)) = get_chat_routing(registry, db.clone(), auth.caller_chat_id).await {
208        if let Some(adapter) = registry.get(&routing.channel_name) {
209            if !adapter.allows_cross_chat() && auth.caller_chat_id != target_chat_id {
210                return Err(format!(
211                    "Permission denied: {} chats cannot operate on other chats",
212                    routing.channel_name
213                ));
214            }
215        }
216    }
217
218    Ok(())
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub enum DeliveryOutcome {
223    Delivered,
224    Queued {
225        delivery_id: String,
226        failed_chunk: usize,
227        total_chunks: usize,
228    },
229}
230
231pub async fn deliver_and_store_bot_message(
232    registry: &ChannelRegistry,
233    db: Arc<Database>,
234    bot_username: &str,
235    chat_id: i64,
236    text: &str,
237) -> Result<(), String> {
238    deliver_and_store_bot_message_with_status(registry, db, bot_username, chat_id, text)
239        .await
240        .map(|_| ())
241}
242
243pub async fn deliver_and_store_bot_message_with_status(
244    registry: &ChannelRegistry,
245    db: Arc<Database>,
246    bot_username: &str,
247    chat_id: i64,
248    text: &str,
249) -> Result<DeliveryOutcome, String> {
250    let routing = get_required_chat_routing(registry, db.clone(), chat_id).await?;
251    let external_chat_id = call_blocking(db.clone(), move |d| d.get_chat_external_id(chat_id))
252        .await
253        .map_err(|e| format!("Failed to read external chat id for chat {chat_id}: {e}"))?
254        .unwrap_or_else(|| chat_id.to_string());
255
256    // This choke point is the final defense against model/runtime protocol
257    // details leaking into any user-facing channel.
258    let visible = sanitize_user_visible_text(text);
259    if visible.is_empty() && !text.trim().is_empty() {
260        return Err("outbound message contained no user-visible text".to_string());
261    }
262
263    // Outbound guardrail: scan for credential-like strings before delivery.
264    let guarded =
265        microclaw_core::redact::apply_output_guardrail(&visible, registry.output_guardrail());
266    if let Some(outcome) = &guarded {
267        tracing::warn!(
268            target: "output_guardrail",
269            chat_id,
270            blocked = outcome.blocked,
271            categories = ?outcome.categories,
272            "outbound message tripped the output guardrail"
273        );
274    }
275    let text: &str = guarded
276        .as_ref()
277        .map(|o| o.text.as_str())
278        .unwrap_or(&visible);
279
280    if let Some(adapter) = registry.get(&routing.channel_name) {
281        if adapter.is_local_only() {
282            store_bot_message(db, bot_username, chat_id, text).await?;
283            return Ok(DeliveryOutcome::Delivered);
284        }
285
286        let chunks = adapter
287            .text_chunk_limit_bytes()
288            .map(|limit| split_text(text, limit))
289            .unwrap_or_else(|| vec![text.to_string()]);
290        let delivery_id = format!("delivery-{}", uuid::Uuid::new_v4());
291        let delivery_id_for_create = delivery_id.clone();
292        let channel_for_create = routing.channel_name.clone();
293        let full_text = text.to_string();
294        let chunks_for_create = chunks.clone();
295        let chunk_ids = call_blocking(db.clone(), move |d| {
296            d.create_outbound_delivery(
297                &delivery_id_for_create,
298                chat_id,
299                &channel_for_create,
300                &full_text,
301                &chunks_for_create,
302            )
303        })
304        .await
305        .map_err(|e| format!("Failed to persist outbound delivery: {e}"))?;
306
307        for (index, (chunk_id, chunk)) in chunk_ids.iter().zip(chunks.iter()).enumerate() {
308            let chunk_id = *chunk_id;
309            let claimed = call_blocking(db.clone(), move |d| d.mark_outbox_sending(chunk_id))
310                .await
311                .map_err(|e| format!("Failed to claim outbound chunk: {e}"))?;
312            if !claimed {
313                continue;
314            }
315            let idempotency_key = format!("{delivery_id}:{}", index + 1);
316            if let Err(err) = adapter
317                .send_text_chunk(&external_chat_id, chunk, &idempotency_key)
318                .await
319            {
320                let retry_at = chrono::Utc::now().to_rfc3339();
321                let error_for_db = err.clone();
322                call_blocking(db.clone(), move |d| {
323                    d.mark_outbox_retry(chunk_id, 1, Some(&retry_at), &error_for_db, false)
324                })
325                .await
326                .map_err(|e| format!("{err}; failed to queue chunk retry: {e}"))?;
327                tracing::warn!(
328                    delivery_id,
329                    failed_chunk = index + 1,
330                    total_chunks = chunks.len(),
331                    error = %err,
332                    "outbound delivery accepted durably and queued for retry"
333                );
334                return Ok(DeliveryOutcome::Queued {
335                    delivery_id,
336                    failed_chunk: index + 1,
337                    total_chunks: chunks.len(),
338                });
339            }
340            call_blocking(db.clone(), move |d| d.mark_outbox_delivered(chunk_id))
341                .await
342                .map_err(|e| format!("Chunk sent but delivery ledger update failed: {e}"))?;
343            if index + 1 < chunks.len() {
344                if let Some(delay) = adapter.text_chunk_delay() {
345                    tokio::time::sleep(delay).await;
346                }
347            }
348        }
349
350        let delivery_for_finalize = delivery_id.clone();
351        let sender = bot_username.to_string();
352        call_blocking(db.clone(), move |d| {
353            d.finalize_outbound_delivery(&delivery_for_finalize, &sender)
354                .map(|_| ())
355        })
356        .await
357        .map_err(|e| format!("Delivery completed but message persistence failed: {e}"))?;
358        Ok(DeliveryOutcome::Delivered)
359    } else {
360        Err(format!(
361            "No adapter registered for channel '{}'",
362            routing.channel_name
363        ))
364    }
365}
366
367async fn store_bot_message(
368    db: Arc<Database>,
369    bot_username: &str,
370    chat_id: i64,
371    text: &str,
372) -> Result<(), String> {
373    let msg = StoredMessage {
374        id: uuid::Uuid::new_v4().to_string(),
375        chat_id,
376        sender_name: bot_username.to_string(),
377        content: text.to_string(),
378        is_from_bot: true,
379        timestamp: chrono::Utc::now().to_rfc3339(),
380    };
381    call_blocking(db.clone(), move |d| d.store_message(&msg))
382        .await
383        .map_err(|e| format!("Failed to store sent message: {e}"))
384}
385
386/// Send one persisted outbox chunk. The caller owns retry/backoff policy.
387pub async fn send_persisted_outbox_chunk(
388    registry: &ChannelRegistry,
389    db: Arc<Database>,
390    bot_username: &str,
391    row: &OutboxMessageRecord,
392) -> Result<bool, String> {
393    let row_id = row.id;
394    let claimed = call_blocking(db.clone(), move |d| d.mark_outbox_sending(row_id))
395        .await
396        .map_err(|e| format!("Failed to claim outbox chunk: {e}"))?;
397    if !claimed {
398        return Ok(false);
399    }
400    let routing = get_required_chat_routing(registry, db.clone(), row.chat_id).await?;
401    if routing.channel_name != row.channel {
402        return Err(format!(
403            "delivery channel changed from '{}' to '{}'",
404            row.channel, routing.channel_name
405        ));
406    }
407    let chat_id = row.chat_id;
408    let external_chat_id = call_blocking(db.clone(), move |d| d.get_chat_external_id(chat_id))
409        .await
410        .map_err(|e| format!("Failed to read external chat id for chat {chat_id}: {e}"))?
411        .unwrap_or_else(|| chat_id.to_string());
412    let adapter = registry.get(&routing.channel_name).ok_or_else(|| {
413        format!(
414            "No adapter registered for channel '{}'",
415            routing.channel_name
416        )
417    })?;
418    adapter
419        .send_text_chunk(&external_chat_id, &row.payload_text, &row.idempotency_key)
420        .await?;
421    let chunk_id = row.id;
422    call_blocking(db.clone(), move |d| d.mark_outbox_delivered(chunk_id))
423        .await
424        .map_err(|e| format!("Chunk sent but ledger update failed: {e}"))?;
425    let delivery_id = row.delivery_id.clone();
426    let sender = bot_username.to_string();
427    call_blocking(db, move |d| {
428        d.finalize_outbound_delivery(&delivery_id, &sender)
429    })
430    .await
431    .map_err(|e| format!("Failed to finalize outbound delivery: {e}"))
432}
433
434#[cfg(test)]
435mod tests {
436    use super::infer_channel_from_chat_type;
437
438    #[test]
439    fn test_infer_channel_from_prefixed_chat_type() {
440        assert_eq!(
441            infer_channel_from_chat_type("telegram_private"),
442            Some("telegram")
443        );
444        assert_eq!(infer_channel_from_chat_type("discord_dm"), Some("discord"));
445        assert_eq!(infer_channel_from_chat_type("weixin_dm"), Some("weixin"));
446    }
447
448    #[test]
449    fn test_infer_channel_from_unknown_chat_type() {
450        assert_eq!(infer_channel_from_chat_type("private"), None);
451        assert_eq!(infer_channel_from_chat_type("group"), None);
452    }
453}