Skip to main content

barbed_7tv/
lib.rs

1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use barbed_core::emotes::Emote;
5#[cfg(any(test, feature = "reqwest-client"))]
6use barbed_core::emotes::{
7    EmoteId, EmoteImage, EmoteImageFormat, EmoteImageScale, EmoteProvider, EmoteThemeMode,
8};
9#[cfg(any(test, feature = "reqwest-client"))]
10use serde::Deserialize;
11use thiserror::Error;
12
13#[cfg(feature = "reqwest-client")]
14const API_BASE: &str = "https://7tv.io/v3";
15
16#[derive(Debug, Error)]
17pub enum SevenTvError {
18    #[error("7TV response failed to decode: {0}")]
19    Json(#[from] serde_json::Error),
20    #[cfg(feature = "reqwest-client")]
21    #[error("7TV request failed: {0}")]
22    Http(#[from] reqwest::Error),
23    #[error("7TV user `{0}` was not seeded in the in-memory client")]
24    MissingUser(String),
25    #[error("7TV emote set `{0}` was not seeded in the in-memory client")]
26    MissingEmoteSet(String),
27    #[error("7TV user had no associated Twitch emote set")]
28    MissingDefaultEmoteSet,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct SevenTvUser {
33    pub default_emote_set_id: Option<String>,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct SevenTvEmoteSet {
38    pub id: String,
39    pub name: String,
40    pub emotes: Vec<Emote>,
41}
42
43#[async_trait]
44pub trait SevenTvApi: Send + Sync {
45    async fn global_emote_set(&self) -> Result<SevenTvEmoteSet, SevenTvError>;
46
47    async fn user_by_twitch_id(&self, twitch_id: &str) -> Result<SevenTvUser, SevenTvError>;
48
49    async fn emote_set(&self, set_id: &str) -> Result<SevenTvEmoteSet, SevenTvError>;
50
51    async fn channel_emote_set_by_twitch_id(
52        &self,
53        twitch_id: &str,
54    ) -> Result<SevenTvEmoteSet, SevenTvError> {
55        let user = self.user_by_twitch_id(twitch_id).await?;
56        let set_id = user
57            .default_emote_set_id
58            .ok_or(SevenTvError::MissingDefaultEmoteSet)?;
59        self.emote_set(&set_id).await
60    }
61}
62
63#[derive(Clone, Default)]
64pub struct InMemorySevenTvApi {
65    global_set: Option<SevenTvEmoteSet>,
66    users: HashMap<String, SevenTvUser>,
67    emote_sets: HashMap<String, SevenTvEmoteSet>,
68}
69
70impl InMemorySevenTvApi {
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    pub fn with_global_set(mut self, set: SevenTvEmoteSet) -> Self {
76        self.global_set = Some(set);
77        self
78    }
79
80    pub fn insert_user(&mut self, twitch_id: impl Into<String>, user: SevenTvUser) {
81        self.users.insert(twitch_id.into(), user);
82    }
83
84    pub fn insert_emote_set(&mut self, set: SevenTvEmoteSet) {
85        self.emote_sets.insert(set.id.clone(), set);
86    }
87}
88
89#[async_trait]
90impl SevenTvApi for InMemorySevenTvApi {
91    async fn global_emote_set(&self) -> Result<SevenTvEmoteSet, SevenTvError> {
92        self.global_set
93            .clone()
94            .ok_or_else(|| SevenTvError::MissingEmoteSet("global".to_string()))
95    }
96
97    async fn user_by_twitch_id(&self, twitch_id: &str) -> Result<SevenTvUser, SevenTvError> {
98        self.users
99            .get(twitch_id)
100            .cloned()
101            .ok_or_else(|| SevenTvError::MissingUser(twitch_id.to_string()))
102    }
103
104    async fn emote_set(&self, set_id: &str) -> Result<SevenTvEmoteSet, SevenTvError> {
105        self.emote_sets
106            .get(set_id)
107            .cloned()
108            .ok_or_else(|| SevenTvError::MissingEmoteSet(set_id.to_string()))
109    }
110}
111
112#[cfg(feature = "reqwest-client")]
113#[derive(Clone)]
114pub struct SevenTvClient {
115    http: reqwest::Client,
116}
117
118#[cfg(feature = "reqwest-client")]
119impl SevenTvClient {
120    pub fn new() -> Result<Self, SevenTvError> {
121        Ok(Self {
122            http: reqwest::Client::builder()
123                .user_agent("barbed/0.0.2")
124                .build()?,
125        })
126    }
127}
128
129#[cfg(feature = "reqwest-client")]
130#[async_trait]
131impl SevenTvApi for SevenTvClient {
132    async fn global_emote_set(&self) -> Result<SevenTvEmoteSet, SevenTvError> {
133        let body = self
134            .http
135            .get(format!("{API_BASE}/emote-sets/global"))
136            .send()
137            .await?
138            .error_for_status()?
139            .text()
140            .await?;
141        parse_emote_set_json(&body)
142    }
143
144    async fn user_by_twitch_id(&self, twitch_id: &str) -> Result<SevenTvUser, SevenTvError> {
145        let body = self
146            .http
147            .get(format!("{API_BASE}/users/twitch/{twitch_id}"))
148            .send()
149            .await?
150            .error_for_status()?
151            .text()
152            .await?;
153        parse_user_json(&body)
154    }
155
156    async fn emote_set(&self, set_id: &str) -> Result<SevenTvEmoteSet, SevenTvError> {
157        let body = self
158            .http
159            .get(format!("{API_BASE}/emote-sets/{set_id}"))
160            .send()
161            .await?
162            .error_for_status()?
163            .text()
164            .await?;
165        parse_emote_set_json(&body)
166    }
167}
168
169#[cfg(any(test, feature = "reqwest-client"))]
170fn parse_user_json(body: &str) -> Result<SevenTvUser, SevenTvError> {
171    let user: SevenTvUserModel = serde_json::from_str(body)?;
172    Ok(SevenTvUser {
173        default_emote_set_id: user.default_emote_set_id(),
174    })
175}
176
177#[cfg(any(test, feature = "reqwest-client"))]
178fn parse_emote_set_json(body: &str) -> Result<SevenTvEmoteSet, SevenTvError> {
179    let model: EmoteSetModel = serde_json::from_str(body)?;
180    Ok(SevenTvEmoteSet {
181        id: model.id,
182        name: model.name,
183        emotes: model.emotes.into_iter().map(emote_from_model).collect(),
184    })
185}
186
187#[derive(Deserialize)]
188#[cfg(any(test, feature = "reqwest-client"))]
189struct SevenTvUserModel {
190    #[serde(default)]
191    emote_set: Option<SetRef>,
192    #[serde(default)]
193    connections: Vec<UserConnection>,
194    #[serde(default)]
195    emote_sets: Vec<SetRef>,
196}
197
198#[cfg(any(test, feature = "reqwest-client"))]
199impl SevenTvUserModel {
200    fn default_emote_set_id(&self) -> Option<String> {
201        if let Some(set) = &self.emote_set {
202            return Some(set.id.clone());
203        }
204        for connection in &self.connections {
205            if connection.platform.eq_ignore_ascii_case("twitch") {
206                if let Some(set) = &connection.emote_set {
207                    return Some(set.id.clone());
208                }
209                if let Some(id) = &connection.emote_set_id {
210                    return Some(id.clone());
211                }
212            }
213        }
214        self.emote_sets.first().map(|set| set.id.clone())
215    }
216}
217
218#[derive(Deserialize)]
219#[cfg(any(test, feature = "reqwest-client"))]
220struct UserConnection {
221    platform: String,
222    #[serde(default)]
223    emote_set: Option<SetRef>,
224    #[serde(default)]
225    emote_set_id: Option<String>,
226}
227
228#[derive(Deserialize)]
229#[cfg(any(test, feature = "reqwest-client"))]
230struct SetRef {
231    id: String,
232}
233
234#[derive(Deserialize)]
235#[cfg(any(test, feature = "reqwest-client"))]
236struct EmoteSetModel {
237    id: String,
238    name: String,
239    emotes: Vec<SetEmote>,
240}
241
242#[derive(Deserialize)]
243#[cfg(any(test, feature = "reqwest-client"))]
244struct SetEmote {
245    id: String,
246    name: String,
247    data: EmoteData,
248}
249
250#[derive(Deserialize)]
251#[cfg(any(test, feature = "reqwest-client"))]
252struct EmoteData {
253    host: EmoteHost,
254    #[serde(default)]
255    animated: bool,
256}
257
258#[derive(Deserialize)]
259#[cfg(any(test, feature = "reqwest-client"))]
260struct EmoteHost {
261    url: String,
262    #[serde(default)]
263    files: Vec<HostFile>,
264}
265
266#[derive(Deserialize)]
267#[cfg(any(test, feature = "reqwest-client"))]
268struct HostFile {
269    name: String,
270}
271
272#[cfg(any(test, feature = "reqwest-client"))]
273fn emote_from_model(model: SetEmote) -> Emote {
274    let format = EmoteImageFormat::from_animated(model.data.animated);
275    let images = if model.data.host.files.is_empty() {
276        vec![EmoteImage {
277            format: format.clone(),
278            theme_mode: EmoteThemeMode::Light,
279            scale: EmoteImageScale::One,
280            url: model.data.host.url.clone(),
281        }]
282    } else {
283        model
284            .data
285            .host
286            .files
287            .into_iter()
288            .map(|file| EmoteImage {
289                format: format.clone(),
290                theme_mode: EmoteThemeMode::Light,
291                scale: parse_scale_from_name(&file.name),
292                url: format!(
293                    "{}/{}",
294                    model.data.host.url.trim_end_matches('/'),
295                    file.name
296                ),
297            })
298            .collect()
299    };
300
301    Emote::new(
302        EmoteId::new(EmoteProvider::SevenTv, model.id),
303        model.name,
304        images,
305    )
306}
307
308#[cfg(any(test, feature = "reqwest-client"))]
309fn parse_scale_from_name(name: &str) -> EmoteImageScale {
310    if name.contains("1x") {
311        EmoteImageScale::One
312    } else if name.contains("2x") {
313        EmoteImageScale::Two
314    } else if name.contains("3x") {
315        EmoteImageScale::Three
316    } else {
317        EmoteImageScale::Other(name.to_string())
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn user_fixture_resolves_default_twitch_emote_set() {
327        let user = parse_user_json(include_str!("../tests/fixtures/user_twitch.json"))
328            .expect("user fixture should parse");
329        assert_eq!(user.default_emote_set_id.as_deref(), Some("set-123"));
330    }
331
332    #[test]
333    fn emote_set_fixture_builds_generic_emotes() {
334        let set = parse_emote_set_json(include_str!("../tests/fixtures/emote_set.json"))
335            .expect("set fixture should parse");
336        assert_eq!(set.id, "set-123");
337        assert_eq!(set.emotes.len(), 2);
338        assert!(set.emotes.iter().any(|emote| emote.is_animated()));
339    }
340
341    #[tokio::test(flavor = "current_thread")]
342    async fn in_memory_api_uses_seeded_user_to_resolve_channel_set() {
343        let mut api = InMemorySevenTvApi::new();
344        api.insert_user(
345            "42",
346            SevenTvUser {
347                default_emote_set_id: Some("set-123".to_string()),
348            },
349        );
350        api.insert_emote_set(
351            parse_emote_set_json(include_str!("../tests/fixtures/emote_set.json"))
352                .expect("fixture should parse"),
353        );
354
355        let set = api
356            .channel_emote_set_by_twitch_id("42")
357            .await
358            .expect("channel set should resolve");
359        assert_eq!(set.id, "set-123");
360    }
361}