tandem-server 0.5.6

HTTP server for Tandem engine APIs
pub mod discord;
pub mod slack;
pub mod telegram;

use std::sync::Arc;

use async_trait::async_trait;
use serde_json::json;
use sha2::{Digest, Sha256};
use tandem_channels::traits::{
    Channel, InteractiveCard, InteractiveCardButton, InteractiveCardButtonStyle,
    InteractiveCardField, InteractiveCardReasonPrompt,
};
use tandem_types::{ApprovalDecision, ApprovalRequest};

use crate::app::approval_outbound::{ApprovalNotifier, NotifierError};
use crate::app::state::approval_message_map::ApprovalMessageMap;

pub(crate) fn approval_request_to_card(
    request: &ApprovalRequest,
    recipient: String,
) -> InteractiveCard {
    let workflow = request
        .workflow_name
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("Tandem workflow");
    let action = request
        .action_kind
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or("approval gate");
    let title = format!("{workflow}: {action}");

    let mut body_parts = Vec::new();
    if let Some(instructions) = request
        .instructions
        .as_deref()
        .filter(|value| !value.trim().is_empty())
    {
        body_parts.push(instructions.to_string());
    }
    if let Some(preview) = request
        .action_preview_markdown
        .as_deref()
        .filter(|value| !value.trim().is_empty())
    {
        body_parts.push(preview.to_string());
    }
    let body_markdown = if body_parts.is_empty() {
        "A human approval is required before this run can continue.".to_string()
    } else {
        body_parts.join("\n\n")
    };

    let mut fields = vec![
        InteractiveCardField {
            label: "Run".to_string(),
            value: request.run_id.clone(),
        },
        InteractiveCardField {
            label: "Source".to_string(),
            value: format!("{:?}", request.source),
        },
        InteractiveCardField {
            label: "Workspace".to_string(),
            value: request.tenant.workspace_id.clone(),
        },
    ];
    if let Some(node_id) = request.node_id.as_ref().filter(|value| !value.is_empty()) {
        fields.push(InteractiveCardField {
            label: "Node".to_string(),
            value: node_id.clone(),
        });
    }

    let decisions = if request.decisions.is_empty() {
        vec![
            ApprovalDecision::Approve,
            ApprovalDecision::Rework,
            ApprovalDecision::Cancel,
        ]
    } else {
        request.decisions.clone()
    };
    let buttons = decisions
        .iter()
        .map(button_for_decision)
        .collect::<Vec<_>>();

    let mut correlation = json!({
        "request_id": request.request_id,
        "source": request.source,
        "automation_v2_run_id": request.run_id,
        "run_id": request.run_id,
        "node_id": request.node_id,
    });
    if let Some(payload) = request.surface_payload.as_ref() {
        if let Some(obj) = correlation.as_object_mut() {
            obj.insert("surface_payload".to_string(), payload.clone());
        }
    }

    InteractiveCard {
        recipient,
        title,
        body_markdown,
        fields,
        buttons,
        reason_prompt: Some(InteractiveCardReasonPrompt {
            modal_title: "Request rework".to_string(),
            field_label: "What should change before this can be approved?".to_string(),
            field_placeholder: Some("Add the feedback the workflow should use.".to_string()),
            submit_label: "Send rework".to_string(),
        }),
        thread_key: Some(request.run_id.clone()),
        correlation,
    }
}

fn button_for_decision(decision: &ApprovalDecision) -> InteractiveCardButton {
    match decision {
        ApprovalDecision::Approve => InteractiveCardButton {
            action_id: "approve".to_string(),
            label: "Approve".to_string(),
            style: InteractiveCardButtonStyle::Primary,
            requires_reason: false,
            confirm: None,
        },
        ApprovalDecision::Rework => InteractiveCardButton {
            action_id: "rework".to_string(),
            label: "Rework".to_string(),
            style: InteractiveCardButtonStyle::Default,
            requires_reason: true,
            confirm: None,
        },
        ApprovalDecision::Cancel => InteractiveCardButton {
            action_id: "cancel".to_string(),
            label: "Cancel".to_string(),
            style: InteractiveCardButtonStyle::Destructive,
            requires_reason: false,
            confirm: None,
        },
    }
}

pub struct ChannelApprovalNotifier {
    name: &'static str,
    recipient: String,
    channel: Arc<dyn Channel>,
    message_map: Option<Arc<ApprovalMessageMap>>,
}

impl ChannelApprovalNotifier {
    pub fn new(
        name: &'static str,
        recipient: impl Into<String>,
        channel: Arc<dyn Channel>,
    ) -> Self {
        Self::new_with_message_map(name, recipient, channel, None)
    }

    pub fn new_with_message_map(
        name: &'static str,
        recipient: impl Into<String>,
        channel: Arc<dyn Channel>,
        message_map: Option<Arc<ApprovalMessageMap>>,
    ) -> Self {
        Self {
            name,
            recipient: recipient.into(),
            channel,
            message_map,
        }
    }
}

#[async_trait]
impl ApprovalNotifier for ChannelApprovalNotifier {
    fn name(&self) -> &str {
        self.name
    }

    async fn notify(&self, request: &ApprovalRequest) -> Result<(), NotifierError> {
        if !self.channel.supports_interactive_cards() {
            return Err(NotifierError::Permanent(format!(
                "{} channel does not support interactive cards",
                self.channel.name()
            )));
        }

        let mut card = approval_request_to_card(request, self.recipient.clone());
        if self.name == "telegram" {
            let callback_id = telegram_callback_id(&request.request_id);
            if let Some(obj) = card.correlation.as_object_mut() {
                obj.insert("telegram_callback_id".to_string(), json!(callback_id));
            }
        }
        let sent = self
            .channel
            .send_card(&card)
            .await
            .map_err(|err| NotifierError::Transient(err.to_string()))?;
        if let Some(message_map) = self.message_map.as_ref() {
            if self.name == "telegram" {
                let callback_id = telegram_callback_id(&request.request_id);
                message_map
                    .record_telegram_callback(callback_id, request, self.recipient.clone())
                    .await
                    .map_err(|err| {
                        NotifierError::Transient(format!(
                            "failed to persist Telegram callback mapping: {err}"
                        ))
                    })?;
            }
            message_map
                .record_approval_sent(request, sent)
                .await
                .map_err(|err| {
                    NotifierError::Transient(format!("failed to persist approval message: {err}"))
                })?;
        }
        Ok(())
    }
}

fn telegram_callback_id(request_id: &str) -> String {
    let digest = Sha256::digest(request_id.as_bytes());
    format!(
        "tgcb_{:016x}",
        u64::from_be_bytes(digest[0..8].try_into().unwrap())
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use tandem_channels::traits::{ChannelMessage, InteractiveCardError, InteractiveCardSent};
    use tandem_types::{ApprovalSourceKind, ApprovalTenantRef};

    fn fake_request() -> ApprovalRequest {
        ApprovalRequest {
            request_id: "automation_v2:run-1:send_email".to_string(),
            source: ApprovalSourceKind::AutomationV2,
            tenant: ApprovalTenantRef {
                org_id: "org".to_string(),
                workspace_id: "workspace".to_string(),
                user_id: None,
            },
            run_id: "run-1".to_string(),
            node_id: Some("send_email".to_string()),
            workflow_name: Some("Sales outreach".to_string()),
            action_kind: Some("send email".to_string()),
            action_preview_markdown: Some("Email alice@example.com".to_string()),
            surface_payload: None,
            requested_at_ms: 1,
            expires_at_ms: None,
            decisions: vec![
                ApprovalDecision::Approve,
                ApprovalDecision::Rework,
                ApprovalDecision::Cancel,
            ],
            rework_targets: vec![],
            instructions: Some("Check the recipient and tone.".to_string()),
            decided_by: None,
            decided_at_ms: None,
            decision: None,
            rework_feedback: None,
        }
    }

    struct FakeChannel {
        supports_cards: bool,
        seen: std::sync::Mutex<Vec<InteractiveCard>>,
    }

    #[async_trait]
    impl Channel for FakeChannel {
        fn name(&self) -> &str {
            "fake"
        }

        async fn send(
            &self,
            _message: &tandem_channels::traits::SendMessage,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn listen(
            &self,
            _tx: tokio::sync::mpsc::Sender<ChannelMessage>,
        ) -> anyhow::Result<()> {
            Ok(())
        }

        async fn send_card(
            &self,
            card: &InteractiveCard,
        ) -> Result<InteractiveCardSent, InteractiveCardError> {
            self.seen.lock().unwrap().push(card.clone());
            Ok(InteractiveCardSent {
                channel: "fake".to_string(),
                message_id: "msg-1".to_string(),
                recipient: card.recipient.clone(),
                thread_id: card.thread_key.clone(),
            })
        }

        fn supports_interactive_cards(&self) -> bool {
            self.supports_cards
        }
    }

    #[test]
    fn approval_request_to_card_preserves_core_identity() {
        let card = approval_request_to_card(&fake_request(), "C123".to_string());

        assert_eq!(card.recipient, "C123");
        assert_eq!(card.title, "Sales outreach: send email");
        assert!(card.body_markdown.contains("alice@example.com"));
        assert_eq!(card.thread_key.as_deref(), Some("run-1"));
        assert_eq!(card.buttons.len(), 3);
        assert_eq!(
            card.correlation["request_id"],
            "automation_v2:run-1:send_email"
        );
        assert_eq!(card.correlation["automation_v2_run_id"], "run-1");
        assert_eq!(card.correlation["run_id"], "run-1");
        assert_eq!(card.correlation["node_id"], "send_email");
    }

    #[tokio::test]
    async fn channel_approval_notifier_sends_interactive_card() {
        let channel = Arc::new(FakeChannel {
            supports_cards: true,
            seen: std::sync::Mutex::new(Vec::new()),
        });
        let notifier = ChannelApprovalNotifier::new("fake", "C123", channel.clone());

        notifier.notify(&fake_request()).await.unwrap();

        let seen = channel.seen.lock().unwrap();
        assert_eq!(seen.len(), 1);
        assert_eq!(seen[0].recipient, "C123");
    }

    #[tokio::test]
    async fn channel_approval_notifier_records_sent_message() {
        let channel = Arc::new(FakeChannel {
            supports_cards: true,
            seen: std::sync::Mutex::new(Vec::new()),
        });
        let message_map = Arc::new(ApprovalMessageMap::ephemeral());
        let notifier = ChannelApprovalNotifier::new_with_message_map(
            "fake",
            "C123",
            channel,
            Some(message_map.clone()),
        );

        let request = fake_request();
        notifier.notify(&request).await.unwrap();

        let record = message_map.get(&request.request_id).await.unwrap();
        assert_eq!(record.channel, "fake");
        assert_eq!(record.recipient, "C123");
        assert_eq!(record.message_id, "msg-1");
        let thread = message_map
            .get_thread_for_run(&request.run_id)
            .await
            .unwrap();
        assert_eq!(thread.message_id, "msg-1");
    }

    #[tokio::test]
    async fn channel_approval_notifier_rejects_non_interactive_channel() {
        let channel = Arc::new(FakeChannel {
            supports_cards: false,
            seen: std::sync::Mutex::new(Vec::new()),
        });
        let notifier = ChannelApprovalNotifier::new("fake", "C123", channel);

        let err = notifier.notify(&fake_request()).await.unwrap_err();
        assert!(matches!(err, NotifierError::Permanent(_)));
    }
}