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 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 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 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 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
386pub 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}