tinychannels 0.1.0

Pluggable channel and messaging primitives for OpenHuman harness communication.
Documentation
use crate::channel::{
    ChannelInboundEnvelope, ChannelPresentationCapabilities, ChannelRef, ChannelStaticCapabilities,
    ConversationKind, ConversationRef, MediaKind, MediaReference, OutboundPayload, SenderRef,
};
use crate::harness::{
    BridgeTranslationOptions, ChannelOutputEvent, ChannelTurn, TurnAdmissionVerdict,
    translate_output_event,
};

fn turn() -> ChannelTurn {
    ChannelTurn {
        id: "turn-1".into(),
        session_key: "session-1".into(),
        admission: TurnAdmissionVerdict::Dispatch,
        envelope: ChannelInboundEnvelope {
            channel: ChannelRef {
                id: "telegram".into(),
                account_id: Some("bot".into()),
            },
            message_id: "msg-1".into(),
            conversation: ConversationRef {
                kind: ConversationKind::Group,
                id: "chat-1".into(),
                topic_id: Some("topic-1".into()),
                ..Default::default()
            },
            sender: SenderRef {
                id: "alice".into(),
                ..Default::default()
            },
            text: "hello".into(),
            ..Default::default()
        },
        lifecycle: Vec::new(),
    }
}

#[test]
fn text_delta_uses_draft_when_editing_supported() {
    let intents = translate_output_event(
        &turn(),
        ChannelOutputEvent::TextDelta { text: "hi".into() },
        &ChannelStaticCapabilities::default(),
        &ChannelPresentationCapabilities {
            supports_edit: true,
            ..Default::default()
        },
        BridgeTranslationOptions::default(),
    );
    assert!(matches!(
        intents[0].payload,
        OutboundPayload::NativeChannelData { .. }
    ));
}

#[test]
fn text_delta_degrades_to_segment_send_without_editing() {
    let intents = translate_output_event(
        &turn(),
        ChannelOutputEvent::TextDelta { text: "hi".into() },
        &ChannelStaticCapabilities::default(),
        &ChannelPresentationCapabilities::default(),
        BridgeTranslationOptions::default(),
    );
    assert!(matches!(intents[0].payload, OutboundPayload::Text { .. }));
}

#[test]
fn approval_degrades_to_text_when_native_actions_unavailable() {
    let intents = translate_output_event(
        &turn(),
        ChannelOutputEvent::ApprovalRequest {
            id: "approve-1".into(),
            prompt: "Run command?".into(),
            choices: vec!["approve".into(), "deny".into()],
        },
        &ChannelStaticCapabilities::default(),
        &ChannelPresentationCapabilities::default(),
        BridgeTranslationOptions::default(),
    );
    let OutboundPayload::Text { text } = &intents[0].payload else {
        panic!("expected text fallback");
    };
    assert!(text.contains("Reply with one of: approve, deny"));
}

#[test]
fn media_event_projects_media_urls() {
    let intents = translate_output_event(
        &turn(),
        ChannelOutputEvent::Media {
            text: Some("image".into()),
            media: vec![MediaReference {
                kind: MediaKind::Image,
                url: Some("https://example.test/image.png".into()),
                ..Default::default()
            }],
        },
        &ChannelStaticCapabilities::default(),
        &ChannelPresentationCapabilities::default(),
        BridgeTranslationOptions::default(),
    );
    let OutboundPayload::Media { media_urls, .. } = &intents[0].payload else {
        panic!("expected media payload");
    };
    assert_eq!(
        media_urls,
        &vec!["https://example.test/image.png".to_string()]
    );
    assert_eq!(intents[0].thread_id.as_deref(), Some("topic-1"));
}