Skip to main content

ai_agent/bridge/
inbound_messages.rs

1//! Process inbound user messages from the bridge.
2//!
3//! Translated from openclaudecode/src/bridge/inboundMessages.ts
4
5use serde::{Deserialize, Serialize};
6
7// =============================================================================
8// TYPES
9// =============================================================================
10
11/// SDK message type for bridge communication.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum SDKMessage {
15    User {
16        message: Option<UserMessageContent>,
17        uuid: Option<String>,
18    },
19    Assistant {
20        message: Option<AssistantMessageContent>,
21        uuid: Option<String>,
22    },
23    ToolUse {
24        message: Option<ToolUseMessageContent>,
25        uuid: Option<String>,
26    },
27    ToolResult {
28        message: Option<ToolResultMessageContent>,
29        uuid: Option<String>,
30    },
31    System {
32        message: Option<SystemMessageContent>,
33        uuid: Option<String>,
34    },
35}
36
37impl SDKMessage {
38    /// Create a placeholder user message with session_id (for bridge internal use).
39    pub fn user_message_with_session(_session_id: String) -> Self {
40        SDKMessage::User {
41            message: None,
42            uuid: None,
43        }
44    }
45}
46
47/// User message content (can be string or content blocks).
48#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(untagged)]
50pub enum UserMessageContent {
51    String(String),
52    Blocks(Vec<ContentBlock>),
53}
54
55/// Assistant message content.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct AssistantMessageContent {
58    pub content: Option<serde_json::Value>,
59}
60
61/// Tool use message content.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ToolUseMessageContent {
64    pub content: Option<serde_json::Value>,
65}
66
67/// Tool result message content.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ToolResultMessageContent {
70    pub content: Option<serde_json::Value>,
71}
72
73/// System message content.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct SystemMessageContent {
76    pub content: Option<serde_json::Value>,
77}
78
79/// Content block parameter (supports text, image, etc.)
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "type", content = "source", rename_all = "snake_case")]
82pub enum ContentBlock {
83    Text {
84        text: String,
85    },
86    Image {
87        #[serde(rename = "media_type")]
88        media_type: Option<String>,
89        data: String,
90    },
91    // Add other variants as needed
92    #[serde(other)]
93    Other,
94}
95
96/// Image block parameter with source.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct ImageBlock {
99    #[serde(rename = "media_type")]
100    pub media_type: Option<String>,
101    pub r#type: String,
102    pub data: String,
103}
104
105// =============================================================================
106// MESSAGE EXTRACTION
107// =============================================================================
108
109/// Result of extracting inbound message fields.
110#[derive(Debug, Clone)]
111pub struct InboundMessageFields {
112    pub content: String,
113    pub uuid: Option<String>,
114}
115
116/// Process an inbound user message from the bridge, extracting content
117/// and UUID for enqueueing. Supports both string content and
118/// ContentBlockParam[] (e.g. messages containing images).
119///
120/// Returns the extracted fields, or None if the message should be
121/// skipped (non-user type, missing/empty content).
122pub fn extract_inbound_message_fields(msg: &SDKMessage) -> Option<InboundMessageFields> {
123    let SDKMessage::User { message, uuid } = msg else {
124        return None;
125    };
126
127    let content = match message {
128        Some(UserMessageContent::String(s)) => {
129            if s.is_empty() {
130                return None;
131            }
132            s.clone()
133        }
134        Some(UserMessageContent::Blocks(blocks)) => {
135            if blocks.is_empty() {
136                return None;
137            }
138            // Normalize and extract text from blocks
139            let normalized = normalize_image_blocks(blocks);
140            extract_text_from_blocks(&normalized)
141        }
142        None => return None,
143    };
144
145    Some(InboundMessageFields {
146        content,
147        uuid: uuid.clone(),
148    })
149}
150
151// =============================================================================
152// IMAGE BLOCK NORMALIZATION
153// =============================================================================
154
155/// Normalize image content blocks from bridge clients.
156/// iOS/web clients may send `mediaType` (camelCase) instead of `media_type` (snake_case),
157/// or omit the field entirely.
158pub fn normalize_image_blocks(blocks: &[ContentBlock]) -> Vec<ContentBlock> {
159    if !blocks.iter().any(|b| is_malformed_base64_image(b)) {
160        return blocks.to_vec();
161    }
162
163    blocks
164        .iter()
165        .map(|block| {
166            if !is_malformed_base64_image(block) {
167                return block.clone();
168            }
169            // This is a malformed image block - we need to fix it
170            // Extract mediaType or detect from base64
171            let media_type = detect_image_format(block);
172            ContentBlock::Image {
173                media_type: Some(media_type),
174                data: get_image_data(block),
175            }
176        })
177        .collect()
178}
179
180fn is_malformed_base64_image(block: &ContentBlock) -> bool {
181    match block {
182        ContentBlock::Image { media_type, .. } => media_type.is_none(),
183        _ => false,
184    }
185}
186
187fn detect_image_format(_block: &ContentBlock) -> String {
188    // Try to get mediaType from the raw block
189    // In production, would use detectImageFormatFromBase64
190    // For now, default to a common format
191    "image/png".to_string()
192}
193
194fn get_image_data(block: &ContentBlock) -> String {
195    match block {
196        ContentBlock::Image { data, .. } => data.clone(),
197        _ => String::new(),
198    }
199}
200
201fn extract_text_from_blocks(blocks: &[ContentBlock]) -> String {
202    blocks
203        .iter()
204        .filter_map(|block| {
205            if let ContentBlock::Text { text } = block {
206                Some(text.clone())
207            } else {
208                None
209            }
210        })
211        .collect::<Vec<_>>()
212        .join("\n")
213}
214
215// =============================================================================
216// ALTERNATIVE: Direct JSON value processing
217// =============================================================================
218
219/// Alternative: Process a JSON value directly (for loosely-typed inbound messages).
220pub fn extract_inbound_message_fields_from_json(
221    msg: &serde_json::Value,
222) -> Option<InboundMessageFields> {
223    // Check if it's a user message
224    let msg_type = msg.get("type")?.as_str()?;
225    if msg_type != "user" {
226        return None;
227    }
228
229    let message = msg.get("message")?;
230    let content = if let Some(s) = message.as_str() {
231        if s.is_empty() {
232            return None;
233        }
234        s.to_string()
235    } else if let Some(arr) = message.as_array() {
236        if arr.is_empty() {
237            return None;
238        }
239        // Process content blocks
240        let normalized: Vec<ContentBlock> = arr
241            .iter()
242            .filter_map(|b| serde_json::from_value(b.clone()).ok())
243            .collect();
244        extract_text_from_blocks(&normalized)
245    } else {
246        return None;
247    };
248
249    let uuid = msg.get("uuid").and_then(|v| v.as_str()).map(String::from);
250
251    Some(InboundMessageFields { content, uuid })
252}