gsm-core 0.3.4

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 {
            if let Some(parsed) = normalize_body_element(element, &mut ir.meta) {
                ir.elements.push(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) -> Option<Element> {
    let obj = value.as_object()?;
    let element_type = obj.get("type")?.as_str()?;
    match element_type {
        "TextBlock" => {
            let text = obj.get("text")?.as_str()?.to_string();
            let markdown = obj.get("wrap").and_then(|v| v.as_bool()).unwrap_or(true);
            Some(Element::Text { text, markdown })
        }
        "Image" => {
            let url = obj.get("url")?.as_str()?.to_string();
            let alt = obj
                .get("altText")
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());
            Some(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<_>>();
            Some(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();

            Some(Element::Input {
                label,
                kind,
                id,
                required,
                choices,
            })
        }
        _ => None,
    }
}

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);
    }
}