gsm-core 0.4.40

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

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

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

const HEADER_LIMIT: usize = 150;
const MODAL_TITLE_LIMIT: usize = 24;
const BUTTON_LIMIT: usize = 5;

#[derive(Default)]
pub struct SlackRenderer;

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

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

    fn render(&self, ir: &MessageCardIr) -> RenderOutput {
        let mut warnings = Vec::new();
        let mut metrics = RenderMetrics::default();
        let has_inputs = ir
            .elements
            .iter()
            .any(|el| matches!(el, Element::Input { .. }));

        let payload = if has_inputs {
            render_modal(ir, &mut warnings, &mut metrics)
        } else {
            json!({ "blocks": render_blocks(ir, &mut warnings, false, &mut metrics) })
        };
        let mut output = RenderOutput::new(payload);
        output.used_modal = has_inputs;
        output.warnings = warnings;
        output.limit_exceeded = metrics.limit_exceeded;
        output.sanitized_count = metrics.sanitized_count;
        output.url_blocked_count = metrics.url_blocked_count;
        output
    }
}

fn render_modal(
    ir: &MessageCardIr,
    warnings: &mut Vec<String>,
    metrics: &mut RenderMetrics,
) -> Value {
    let title_raw = ir
        .head
        .title
        .as_deref()
        .or(ir.head.text.as_deref())
        .unwrap_or("Card");
    let sanitized = sanitize_text_for_tier(title_raw, ir.tier, metrics);
    let title = truncate(&sanitized, MODAL_TITLE_LIMIT);

    json!({
        "type": "modal",
        "title": plain_text(&title),
        "submit": plain_text("Submit"),
        "close": plain_text("Close"),
        "callback_id": "gsm_card_modal",
        "blocks": render_blocks(ir, warnings, true, metrics),
    })
}

fn render_blocks(
    ir: &MessageCardIr,
    warnings: &mut Vec<String>,
    include_inputs: bool,
    metrics: &mut RenderMetrics,
) -> Vec<Value> {
    let mut blocks = Vec::new();

    if !include_inputs && let Some(title) = &ir.head.title {
        let sanitized = sanitize_text_for_tier(title, ir.tier, metrics);
        let limited = enforce_text_limit(
            &sanitized,
            SLACK_TEXT_LIMIT,
            "slack.text_truncated",
            metrics,
            warnings,
        );
        blocks.push(json!({
            "type": "header",
            "text": plain_text(&truncate(&limited, HEADER_LIMIT)),
        }));
    }

    let mut saw_text_element = false;

    for element in &ir.elements {
        match element {
            Element::Text { text, markdown } => {
                saw_text_element = true;
                let sanitized = sanitize_text_for_tier(text, ir.tier, metrics);
                let limited = enforce_text_limit(
                    &sanitized,
                    SLACK_TEXT_LIMIT,
                    "slack.text_truncated",
                    metrics,
                    warnings,
                );
                blocks.push(section_block(&limited, *markdown));
            }
            Element::Image { url, alt } => {
                let alt_text = alt
                    .as_deref()
                    .map(|value| sanitize_text_for_tier(value, ir.tier, metrics))
                    .unwrap_or_else(|| "image".to_string());
                blocks.push(json!({
                    "type": "image",
                    "image_url": url,
                    "alt_text": alt_text,
                }));
            }
            Element::FactSet { facts } => {
                if facts.is_empty() {
                    continue;
                }
                let mut fields = Vec::new();
                for fact in facts {
                    if fields.len() == 10 {
                        warnings.push("slack.factset_truncated".into());
                        break;
                    }
                    let label = sanitize_text_for_tier(&fact.label, ir.tier, metrics);
                    let value = sanitize_text_for_tier(&fact.value, ir.tier, metrics);
                    let text = format!("*{label}*\n{value}");
                    fields.push(json!({
                        "type": "mrkdwn",
                        "text": enforce_text_limit(
                            &text,
                            SLACK_TEXT_LIMIT,
                            "slack.text_truncated",
                            metrics,
                            warnings,
                        )
                    }));
                }
                if !fields.is_empty() {
                    blocks.push(json!({
                        "type": "section",
                        "fields": fields,
                    }));
                }
            }
            Element::Input {
                label,
                kind,
                id,
                required,
                choices,
            } => {
                if include_inputs {
                    if let Some(block) = input_block(
                        label.as_deref(),
                        kind,
                        id.as_deref(),
                        *required,
                        choices,
                        warnings,
                        metrics,
                        ir.tier,
                    ) {
                        blocks.push(block);
                    }
                } else {
                    warnings.push("slack.inputs_require_modal".into());
                }
            }
        }
    }

    if !saw_text_element && let Some(text) = &ir.head.text {
        let sanitized = sanitize_text_for_tier(text, ir.tier, metrics);
        let limited = enforce_text_limit(
            &sanitized,
            SLACK_TEXT_LIMIT,
            "slack.text_truncated",
            metrics,
            warnings,
        );
        blocks.push(section_block(&limited, true));
    }

    if let Some(footer) = &ir.head.footer {
        blocks.push(json!({
            "type": "context",
            "elements": [
                json!({
                    "type": "mrkdwn",
                    "text": enforce_text_limit(
                        &sanitize_text_for_tier(footer, ir.tier, metrics),
                        SLACK_TEXT_LIMIT,
                        "slack.text_truncated",
                        metrics,
                        warnings,
                    ),
                })
            ],
        }));
    }

    if let Some(actions) = actions_block(ir, warnings, metrics) {
        blocks.push(actions);
    }

    blocks
}

#[allow(clippy::too_many_arguments)]
fn input_block(
    label: Option<&str>,
    kind: &InputKind,
    id: Option<&str>,
    required: bool,
    choices: &[InputChoice],
    warnings: &mut Vec<String>,
    metrics: &mut RenderMetrics,
    tier: Tier,
) -> Option<Value> {
    let block_id = id.unwrap_or("input").to_string();
    match kind {
        InputKind::Text => Some(json!({
            "type": "input",
            "block_id": block_id,
            "label": plain_text(&sanitize_text_for_tier(label.unwrap_or("Input"), tier, metrics)),
            "optional": !required,
            "element": {
                "type": "plain_text_input",
                "action_id": format!("{}_action", block_id)
            }
        })),
        InputKind::Choice => {
            if choices.is_empty() {
                warnings.push("slack.choice_without_options".into());
                return None;
            }
            let options: Vec<_> = choices
                .iter()
                .map(|choice| {
                    json!({
                        "text": plain_text(&sanitize_text_for_tier(&choice.title, tier, metrics)),
                        "value": choice.value,
                    })
                })
                .collect();
            Some(json!({
                "type": "input",
                "block_id": block_id,
                "label": plain_text(&sanitize_text_for_tier(
                    label.unwrap_or("Select an option"),
                    tier,
                    metrics,
                )),
                "optional": !required,
                "element": {
                    "type": "static_select",
                    "action_id": format!("{}_select", block_id),
                    "options": options
                }
            }))
        }
    }
}

fn actions_block(
    ir: &MessageCardIr,
    warnings: &mut Vec<String>,
    metrics: &mut RenderMetrics,
) -> Option<Value> {
    if ir.actions.is_empty() {
        return None;
    }

    let mut elements = Vec::new();
    for action in &ir.actions {
        if elements.len() == BUTTON_LIMIT {
            warnings.push("slack.actions_truncated".into());
            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);
                    elements.push(json!({
                        "type": "button",
                        "text": plain_text(&button_text),
                        "url": resolved,
                    }));
                }
            }
            IrAction::Postback { title, data } => match serde_json::to_string(data) {
                Ok(value) => {
                    let button_text = sanitize_text_for_tier(title, ir.tier, metrics);
                    elements.push(json!({
                        "type": "button",
                        "text": plain_text(&button_text),
                        "value": value,
                        "action_id": format!("postback_{}", elements.len()),
                    }));
                }
                Err(_) => warnings.push("slack.postback_unserializable".into()),
            },
        }
    }

    if elements.is_empty() {
        None
    } else {
        Some(json!({
            "type": "actions",
            "elements": elements,
        }))
    }
}

fn section_block(text: &str, markdown: bool) -> Value {
    if markdown {
        json!({
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": text,
            }
        })
    } else {
        json!({
            "type": "section",
            "text": {
                "type": "plain_text",
                "text": text,
            }
        })
    }
}

fn plain_text(text: &str) -> Value {
    json!({
        "type": "plain_text",
        "text": text,
        "emoji": true,
    })
}

fn truncate(value: &str, limit: usize) -> String {
    if value.chars().count() <= limit {
        return value.to_string();
    }
    value.chars().take(limit - 1).collect::<String>() + ""
}