gsm-core 0.4.13

Core types and platform abstractions for the Greentic messaging runtime.
Documentation
use serde_json::{Map, Value, json};
use tracing::warn;

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

use super::{
    PlatformRenderer, RenderMetrics, RenderOutput, WHATSAPP_TEXT_LIMIT, enforce_text_limit,
    resolve_url_with_policy, sanitize_text_for_tier,
};

const MAX_BUTTONS: usize = 3;

#[derive(Default)]
pub struct WhatsAppRenderer;

impl PlatformRenderer for WhatsAppRenderer {
    fn platform(&self) -> &'static str {
        "whatsapp"
    }

    fn target_tier(&self) -> Tier {
        Tier::Basic
    }

    fn render(&self, ir: &MessageCardIr) -> RenderOutput {
        let mut warnings = Vec::new();
        let mut metrics = RenderMetrics::default();
        let mut body_lines = Vec::new();

        if let Some(title) = &ir.head.title {
            let sanitized = sanitize_text_for_tier(title, ir.tier, &mut metrics);
            body_lines.push(sanitized.trim().to_string());
        }
        if let Some(text) = &ir.head.text
            && !text.trim().is_empty()
        {
            let sanitized = sanitize_text_for_tier(text, ir.tier, &mut metrics);
            body_lines.push(sanitized.trim().to_string());
        }

        let primary_text = ir.head.text.as_deref().map(str::to_string);
        let mut skipped_primary = false;

        for element in &ir.elements {
            match element {
                Element::Text { text, .. } => {
                    if !skipped_primary {
                        if let Some(primary) = &primary_text
                            && primary == text
                        {
                            skipped_primary = true;
                            continue;
                        }
                        skipped_primary = true;
                    }
                    let sanitized = sanitize_text_for_tier(text, ir.tier, &mut metrics);
                    body_lines.push(sanitized.trim().to_string());
                }
                Element::Image { url, .. } => body_lines.push(url.to_string()),
                Element::FactSet { facts } => {
                    for fact in facts {
                        let label = sanitize_text_for_tier(&fact.label, ir.tier, &mut metrics);
                        let value = sanitize_text_for_tier(&fact.value, ir.tier, &mut metrics);
                        body_lines.push(format!("{label}: {value}"));
                    }
                }
                Element::Input {
                    label,
                    kind,
                    choices,
                    ..
                } => {
                    warnings.push("whatsapp.inputs_not_supported".into());
                    warn!(
                        target = "gsm.mcard.whatsapp",
                        "downgrading inputs to prompt text"
                    );
                    let field = label
                        .as_deref()
                        .map(|value| sanitize_text_for_tier(value, ir.tier, &mut metrics))
                        .unwrap_or_else(|| "Input".into());
                    let field = field.trim().to_string();
                    let prompt = match kind {
                        InputKind::Text => format!("{field}: reply with your answer."),
                        InputKind::Choice => {
                            let opts = if choices.is_empty() {
                                "(choose any option)".to_string()
                            } else {
                                choices
                                    .iter()
                                    .map(|c| {
                                        sanitize_text_for_tier(&c.title, ir.tier, &mut metrics)
                                            .trim()
                                            .to_string()
                                    })
                                    .collect::<Vec<_>>()
                                    .join(", ")
                            };
                            format!("{field}: reply with [{opts}].")
                        }
                    };
                    body_lines.push(prompt);
                }
            }
        }

        if let Some(footer) = &ir.head.footer {
            let sanitized = sanitize_text_for_tier(footer, ir.tier, &mut metrics);
            body_lines.push(sanitized.trim().to_string());
        }

        let mut components = Vec::new();
        let formatted_text = body_lines.join("\n");
        let text = enforce_text_limit(
            &formatted_text,
            WHATSAPP_TEXT_LIMIT,
            "whatsapp.body_truncated",
            &mut metrics,
            &mut warnings,
        );

        let buttons = build_buttons(ir, &mut warnings, &mut metrics);
        if !buttons.is_empty() {
            components.push(json!({
            "type": "BUTTONS",
            "buttons": buttons,
            }));
        }

        let mut payload = Map::new();
        payload.insert("type".into(), Value::String("WhatsAppTemplate".into()));
        payload.insert("body".into(), Value::String(text));
        if !components.is_empty() {
            payload.insert("components".into(), Value::Array(components));
        }

        let mut render_output = RenderOutput::new(Value::Object(payload));
        render_output.warnings = warnings;
        render_output.limit_exceeded = metrics.limit_exceeded;
        render_output.sanitized_count = metrics.sanitized_count;
        render_output.url_blocked_count = metrics.url_blocked_count;
        render_output
    }
}

fn build_buttons(
    ir: &MessageCardIr,
    warnings: &mut Vec<String>,
    metrics: &mut RenderMetrics,
) -> Vec<Value> {
    let mut buttons = Vec::new();
    for action in &ir.actions {
        if buttons.len() == MAX_BUTTONS {
            warnings.push("whatsapp.actions_truncated".into());
            warn!(
                target = "gsm.mcard.whatsapp",
                "action buttons truncated at WhatsApp limit"
            );
            break;
        }

        match action {
            IrAction::OpenUrl { title, url } => {
                if let Some(resolved) = resolve_url_with_policy(&ir.meta, url, metrics, warnings) {
                    let button_text = sanitize_text_for_tier(title, ir.tier, metrics);
                    buttons.push(json!({
                        "type": "URL",
                        "text": button_text,
                        "url": resolved,
                    }));
                }
            }
            IrAction::Postback { title, data } => {
                let payload = serde_json::to_string(data).unwrap_or_else(|_| "{}".into());
                let button_text = sanitize_text_for_tier(title, ir.tier, metrics);
                buttons.push(json!({
                    "type": "QUICK_REPLY",
                    "text": button_text,
                    "payload": payload,
                }));
            }
        }
    }
    buttons
}