gsm-core 0.4.45

Core types and platform abstractions for the Greentic messaging runtime.
Documentation
use anyhow::{Context, Result};
use serde_json::Value;

use crate::messaging_card::ir::{Element, Fact, InputKind, IrAction, MessageCardIr, Meta};
use crate::messaging_card::tier::Tier;

pub fn ac_to_ir(card: &Value) -> Result<MessageCardIr> {
    let root = card
        .as_object()
        .context("adaptive card must be an object")?;

    let mut ir = MessageCardIr {
        tier: Tier::Advanced,
        ..MessageCardIr::default()
    };

    if let Some(title) = root.get("title").and_then(|v| v.as_str()) {
        ir.head.title = Some(title.to_string());
    }

    if let Some(body) = root.get("body").and_then(|b| b.as_array()) {
        for element in body {
            let parsed = normalize_body_element(element, &mut ir.meta);
            ir.elements.extend(parsed);
        }
    }

    if let Some(actions) = root.get("actions").and_then(|a| a.as_array()) {
        for action in actions {
            if let Some(parsed) = normalize_action(action, &mut ir.meta)? {
                ir.actions.push(parsed);
            }
        }
    }

    Ok(ir)
}

fn normalize_body_element(value: &Value, meta: &mut Meta) -> Vec<Element> {
    let obj = match value.as_object() {
        Some(obj) => obj,
        None => return Vec::new(),
    };
    let element_type = match obj.get("type").and_then(|v| v.as_str()) {
        Some(t) => t,
        None => return Vec::new(),
    };
    match element_type {
        "TextBlock" => {
            let text = match obj.get("text").and_then(|v| v.as_str()) {
                Some(text) => text.to_string(),
                None => return Vec::new(),
            };
            let markdown = obj.get("wrap").and_then(|v| v.as_bool()).unwrap_or(true);
            vec![Element::Text { text, markdown }]
        }
        "Image" => {
            let url = match obj.get("url").and_then(|v| v.as_str()) {
                Some(url) => url.to_string(),
                None => return Vec::new(),
            };
            let alt = obj
                .get("altText")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            vec![Element::Image { url, alt }]
        }
        "FactSet" => {
            meta.add_capability("facts");
            let facts = obj
                .get("facts")
                .and_then(|v| v.as_array())
                .into_iter()
                .flatten()
                .filter_map(|fact| {
                    let fact_obj = fact.as_object()?;
                    Some(Fact {
                        label: fact_obj.get("title")?.as_str()?.to_string(),
                        value: fact_obj.get("value")?.as_str()?.to_string(),
                    })
                })
                .collect::<Vec<_>>();
            vec![Element::FactSet { facts }]
        }
        t if t.starts_with("Input.") => {
            meta.add_capability("inputs");
            let kind = match t {
                "Input.Text" => InputKind::Text,
                "Input.ChoiceSet" => InputKind::Choice,
                _ => InputKind::Text,
            };
            let label = obj
                .get("label")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            let id = obj
                .get("id")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            let required = obj
                .get("isRequired")
                .and_then(|v| v.as_bool())
                .unwrap_or(false);
            let choices = obj
                .get("choices")
                .and_then(|v| v.as_array())
                .map(|choices| {
                    choices
                        .iter()
                        .filter_map(|choice| {
                            let choice_obj = choice.as_object()?;
                            Some(crate::messaging_card::ir::InputChoice {
                                title: choice_obj.get("title")?.as_str()?.to_string(),
                                value: choice_obj.get("value")?.as_str()?.to_string(),
                            })
                        })
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();

            vec![Element::Input {
                label,
                kind,
                id,
                required,
                choices,
            }]
        }
        "ColumnSet" => normalize_column_items(obj.get("columns").and_then(|v| v.as_array()), meta),
        "Column" => normalize_items(obj.get("items").and_then(|v| v.as_array()), meta),
        _ => Vec::new(),
    }
}

fn normalize_items(items: Option<&Vec<Value>>, meta: &mut Meta) -> Vec<Element> {
    if let Some(items) = items {
        items
            .iter()
            .flat_map(|item| normalize_body_element(item, meta))
            .collect()
    } else {
        Vec::new()
    }
}

fn normalize_column_items(columns: Option<&Vec<Value>>, meta: &mut Meta) -> Vec<Element> {
    if let Some(columns) = columns {
        columns
            .iter()
            .filter_map(|column| column.as_object())
            .flat_map(|column| {
                normalize_items(column.get("items").and_then(|v| v.as_array()), meta)
            })
            .collect()
    } else {
        Vec::new()
    }
}

fn normalize_action(value: &Value, meta: &mut Meta) -> Result<Option<IrAction>> {
    let obj = match value.as_object() {
        Some(obj) => obj,
        None => return Ok(None),
    };

    let action_type = match obj.get("type").and_then(|t| t.as_str()) {
        Some(t) => t,
        None => return Ok(None),
    };

    match action_type {
        "Action.OpenUrl" => {
            let title = obj
                .get("title")
                .and_then(|v| v.as_str())
                .context("openUrl action missing title")?
                .to_string();
            let url = obj
                .get("url")
                .and_then(|v| v.as_str())
                .context("openUrl action missing url")?
                .to_string();
            Ok(Some(IrAction::OpenUrl { title, url }))
        }
        "Action.Submit" | "Action.Execute" => {
            if action_type == "Action.Execute" {
                meta.add_capability("execute");
            }
            let title = obj
                .get("title")
                .and_then(|v| v.as_str())
                .unwrap_or("Submit")
                .to_string();
            let data = obj.get("data").cloned().unwrap_or(Value::Null);
            Ok(Some(IrAction::Postback { title, data }))
        }
        "Action.ShowCard" => {
            meta.add_capability("showcard");
            Ok(None)
        }
        _ => Ok(None),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn maps_text_blocks() {
        let card = json!({
            "type": "AdaptiveCard",
            "version": "1.6",
            "body": [
                { "type": "TextBlock", "text": "Hello" }
            ]
        });

        let ir = ac_to_ir(&card).expect("normalize");
        assert_eq!(ir.elements.len(), 1);
    }

    #[test]
    fn column_set_body_elements_are_normalized() {
        let card = json!({
            "type": "AdaptiveCard",
            "version": "1.6",
            "body": [
                {
                    "type": "ColumnSet",
                    "columns": [
                        {
                            "type": "Column",
                            "width": "auto",
                            "items": [
                                {
                                    "type": "Image",
                                    "url": "https://example.com/avatar.png"
                                }
                            ]
                        },
                        {
                            "type": "Column",
                            "width": "stretch",
                            "items": [
                                {
                                    "type": "TextBlock",
                                    "text": "Column Title",
                                    "weight": "Bolder",
                                    "wrap": true
                                },
                                {
                                    "type": "TextBlock",
                                    "text": "Column subtitle",
                                    "isSubtle": true,
                                    "wrap": true
                                }
                            ]
                        }
                    ]
                }
            ]
        });

        let ir = ac_to_ir(&card).expect("normalize");
        assert_eq!(ir.elements.len(), 3);
        assert!(matches!(ir.elements[0], Element::Image { .. }));
        assert!(matches!(ir.elements[1], Element::Text { .. }));
        assert!(matches!(ir.elements[2], Element::Text { .. }));
    }
}