barbed-7tv 0.0.4

7TV provider helpers for the barbed workspace
Documentation
use std::collections::HashMap;

use async_trait::async_trait;
use barbed_core::emotes::Emote;
#[cfg(any(test, feature = "reqwest-client"))]
use barbed_core::emotes::{
    EmoteId, EmoteImage, EmoteImageFormat, EmoteImageScale, EmoteProvider, EmoteThemeMode,
};
#[cfg(any(test, feature = "reqwest-client"))]
use serde::Deserialize;
use thiserror::Error;

#[cfg(feature = "reqwest-client")]
const API_BASE: &str = "https://7tv.io/v3";

#[derive(Debug, Error)]
pub enum SevenTvError {
    #[error("7TV response failed to decode: {0}")]
    Json(#[from] serde_json::Error),
    #[cfg(feature = "reqwest-client")]
    #[error("7TV request failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("7TV user `{0}` was not seeded in the in-memory client")]
    MissingUser(String),
    #[error("7TV emote set `{0}` was not seeded in the in-memory client")]
    MissingEmoteSet(String),
    #[error("7TV user had no associated Twitch emote set")]
    MissingDefaultEmoteSet,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SevenTvUser {
    pub default_emote_set_id: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SevenTvEmoteSet {
    pub id: String,
    pub name: String,
    pub emotes: Vec<Emote>,
}

#[async_trait]
pub trait SevenTvApi: Send + Sync {
    async fn global_emote_set(&self) -> Result<SevenTvEmoteSet, SevenTvError>;

    async fn user_by_twitch_id(&self, twitch_id: &str) -> Result<SevenTvUser, SevenTvError>;

    async fn emote_set(&self, set_id: &str) -> Result<SevenTvEmoteSet, SevenTvError>;

    async fn channel_emote_set_by_twitch_id(
        &self,
        twitch_id: &str,
    ) -> Result<SevenTvEmoteSet, SevenTvError> {
        let user = self.user_by_twitch_id(twitch_id).await?;
        let set_id = user
            .default_emote_set_id
            .ok_or(SevenTvError::MissingDefaultEmoteSet)?;
        self.emote_set(&set_id).await
    }
}

#[derive(Clone, Default)]
pub struct InMemorySevenTvApi {
    global_set: Option<SevenTvEmoteSet>,
    users: HashMap<String, SevenTvUser>,
    emote_sets: HashMap<String, SevenTvEmoteSet>,
}

impl InMemorySevenTvApi {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_global_set(mut self, set: SevenTvEmoteSet) -> Self {
        self.global_set = Some(set);
        self
    }

    pub fn insert_user(&mut self, twitch_id: impl Into<String>, user: SevenTvUser) {
        self.users.insert(twitch_id.into(), user);
    }

    pub fn insert_emote_set(&mut self, set: SevenTvEmoteSet) {
        self.emote_sets.insert(set.id.clone(), set);
    }
}

#[async_trait]
impl SevenTvApi for InMemorySevenTvApi {
    async fn global_emote_set(&self) -> Result<SevenTvEmoteSet, SevenTvError> {
        self.global_set
            .clone()
            .ok_or_else(|| SevenTvError::MissingEmoteSet("global".to_string()))
    }

    async fn user_by_twitch_id(&self, twitch_id: &str) -> Result<SevenTvUser, SevenTvError> {
        self.users
            .get(twitch_id)
            .cloned()
            .ok_or_else(|| SevenTvError::MissingUser(twitch_id.to_string()))
    }

    async fn emote_set(&self, set_id: &str) -> Result<SevenTvEmoteSet, SevenTvError> {
        self.emote_sets
            .get(set_id)
            .cloned()
            .ok_or_else(|| SevenTvError::MissingEmoteSet(set_id.to_string()))
    }
}

#[cfg(feature = "reqwest-client")]
#[derive(Clone)]
pub struct SevenTvClient {
    http: reqwest::Client,
}

#[cfg(feature = "reqwest-client")]
impl SevenTvClient {
    pub fn new() -> Result<Self, SevenTvError> {
        Ok(Self {
            http: reqwest::Client::builder()
                .user_agent("barbed/0.0.2")
                .build()?,
        })
    }
}

#[cfg(feature = "reqwest-client")]
#[async_trait]
impl SevenTvApi for SevenTvClient {
    async fn global_emote_set(&self) -> Result<SevenTvEmoteSet, SevenTvError> {
        let body = self
            .http
            .get(format!("{API_BASE}/emote-sets/global"))
            .send()
            .await?
            .error_for_status()?
            .text()
            .await?;
        parse_emote_set_json(&body)
    }

    async fn user_by_twitch_id(&self, twitch_id: &str) -> Result<SevenTvUser, SevenTvError> {
        let body = self
            .http
            .get(format!("{API_BASE}/users/twitch/{twitch_id}"))
            .send()
            .await?
            .error_for_status()?
            .text()
            .await?;
        parse_user_json(&body)
    }

    async fn emote_set(&self, set_id: &str) -> Result<SevenTvEmoteSet, SevenTvError> {
        let body = self
            .http
            .get(format!("{API_BASE}/emote-sets/{set_id}"))
            .send()
            .await?
            .error_for_status()?
            .text()
            .await?;
        parse_emote_set_json(&body)
    }
}

#[cfg(any(test, feature = "reqwest-client"))]
fn parse_user_json(body: &str) -> Result<SevenTvUser, SevenTvError> {
    let user: SevenTvUserModel = serde_json::from_str(body)?;
    Ok(SevenTvUser {
        default_emote_set_id: user.default_emote_set_id(),
    })
}

#[cfg(any(test, feature = "reqwest-client"))]
fn parse_emote_set_json(body: &str) -> Result<SevenTvEmoteSet, SevenTvError> {
    let model: EmoteSetModel = serde_json::from_str(body)?;
    Ok(SevenTvEmoteSet {
        id: model.id,
        name: model.name,
        emotes: model.emotes.into_iter().map(emote_from_model).collect(),
    })
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct SevenTvUserModel {
    #[serde(default)]
    emote_set: Option<SetRef>,
    #[serde(default)]
    connections: Vec<UserConnection>,
    #[serde(default)]
    emote_sets: Vec<SetRef>,
}

#[cfg(any(test, feature = "reqwest-client"))]
impl SevenTvUserModel {
    fn default_emote_set_id(&self) -> Option<String> {
        if let Some(set) = &self.emote_set {
            return Some(set.id.clone());
        }
        for connection in &self.connections {
            if connection.platform.eq_ignore_ascii_case("twitch") {
                if let Some(set) = &connection.emote_set {
                    return Some(set.id.clone());
                }
                if let Some(id) = &connection.emote_set_id {
                    return Some(id.clone());
                }
            }
        }
        self.emote_sets.first().map(|set| set.id.clone())
    }
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct UserConnection {
    platform: String,
    #[serde(default)]
    emote_set: Option<SetRef>,
    #[serde(default)]
    emote_set_id: Option<String>,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct SetRef {
    id: String,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct EmoteSetModel {
    id: String,
    name: String,
    emotes: Vec<SetEmote>,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct SetEmote {
    id: String,
    name: String,
    data: EmoteData,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct EmoteData {
    host: EmoteHost,
    #[serde(default)]
    animated: bool,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct EmoteHost {
    url: String,
    #[serde(default)]
    files: Vec<HostFile>,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct HostFile {
    name: String,
}

#[cfg(any(test, feature = "reqwest-client"))]
fn emote_from_model(model: SetEmote) -> Emote {
    let format = EmoteImageFormat::from_animated(model.data.animated);
    let images = if model.data.host.files.is_empty() {
        vec![EmoteImage {
            format: format.clone(),
            theme_mode: EmoteThemeMode::Light,
            scale: EmoteImageScale::One,
            url: model.data.host.url.clone(),
        }]
    } else {
        model
            .data
            .host
            .files
            .into_iter()
            .map(|file| EmoteImage {
                format: format.clone(),
                theme_mode: EmoteThemeMode::Light,
                scale: parse_scale_from_name(&file.name),
                url: format!(
                    "{}/{}",
                    model.data.host.url.trim_end_matches('/'),
                    file.name
                ),
            })
            .collect()
    };

    Emote::new(
        EmoteId::new(EmoteProvider::SevenTv, model.id),
        model.name,
        images,
    )
}

#[cfg(any(test, feature = "reqwest-client"))]
fn parse_scale_from_name(name: &str) -> EmoteImageScale {
    if name.contains("1x") {
        EmoteImageScale::One
    } else if name.contains("2x") {
        EmoteImageScale::Two
    } else if name.contains("3x") {
        EmoteImageScale::Three
    } else {
        EmoteImageScale::Other(name.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn user_fixture_resolves_default_twitch_emote_set() {
        let user = parse_user_json(include_str!("../tests/fixtures/user_twitch.json"))
            .expect("user fixture should parse");
        assert_eq!(user.default_emote_set_id.as_deref(), Some("set-123"));
    }

    #[test]
    fn emote_set_fixture_builds_generic_emotes() {
        let set = parse_emote_set_json(include_str!("../tests/fixtures/emote_set.json"))
            .expect("set fixture should parse");
        assert_eq!(set.id, "set-123");
        assert_eq!(set.emotes.len(), 2);
        assert!(set.emotes.iter().any(|emote| emote.is_animated()));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn in_memory_api_uses_seeded_user_to_resolve_channel_set() {
        let mut api = InMemorySevenTvApi::new();
        api.insert_user(
            "42",
            SevenTvUser {
                default_emote_set_id: Some("set-123".to_string()),
            },
        );
        api.insert_emote_set(
            parse_emote_set_json(include_str!("../tests/fixtures/emote_set.json"))
                .expect("fixture should parse"),
        );

        let set = api
            .channel_emote_set_by_twitch_id("42")
            .await
            .expect("channel set should resolve");
        assert_eq!(set.id, "set-123");
    }
}