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}