pikadick/commands/
nekos.rs

1use crate::{
2    client_data::{
3        CacheStatsBuilder,
4        CacheStatsProvider,
5    },
6    ClientDataKey,
7};
8use anyhow::Context as _;
9use crossbeam::queue::ArrayQueue;
10use indexmap::set::IndexSet;
11use parking_lot::RwLock;
12use pikadick_slash_framework::FromOptions;
13use rand::Rng;
14use serenity::builder::{
15    CreateInteractionResponse,
16    CreateInteractionResponseMessage,
17};
18use std::sync::Arc;
19use tracing::error;
20use url::Url;
21
22/// Max images per single request
23const BUFFER_SIZE: u8 = 100;
24
25/// A nekos cache
26#[derive(Clone, Debug)]
27pub struct Cache(Arc<CacheInner>);
28
29impl Cache {
30    /// Make a new cache
31    pub fn new() -> Self {
32        Self(Arc::new(CacheInner {
33            primary: ArrayQueue::new(BUFFER_SIZE.into()),
34            secondary: RwLock::new(IndexSet::new()),
35        }))
36    }
37
38    /// Get the size of the primary cache
39    pub fn primary_len(&self) -> usize {
40        self.0.primary.len()
41    }
42
43    /// Get the size of the secondary cache
44    pub fn secondary_len(&self) -> usize {
45        self.0.secondary.read().len()
46    }
47
48    /// Check if the primary cache is emoty
49    pub fn primary_is_empty(&self) -> bool {
50        self.0.primary.is_empty()
51    }
52
53    /// Check if the secondary cache is empty
54    pub fn secondary_is_empty(&self) -> bool {
55        self.0.secondary.read().is_empty()
56    }
57
58    /// Add a url to the cache
59    pub fn add(&self, uri: Url) {
60        let _ = self.0.primary.push(uri.clone()).is_ok();
61        self.0.secondary.write().insert(uri);
62    }
63
64    /// Add many urls to the cache
65    pub fn add_many<I>(&self, iter: I)
66    where
67        I: Iterator<Item = Url>,
68    {
69        let mut secondary = self.0.secondary.write();
70        for uri in iter {
71            let _ = self.0.primary.push(uri.clone()).is_ok();
72            secondary.insert(uri);
73        }
74    }
75
76    /// Get a random url
77    pub async fn get_rand(&self) -> Option<Url> {
78        if let Some(uri) = self.0.primary.pop() {
79            Some(uri)
80        } else {
81            let lock = self.0.secondary.read();
82
83            if lock.is_empty() {
84                return None;
85            }
86
87            let mut rng = rand::thread_rng();
88            let index = rng.gen_range(0..lock.len());
89
90            lock.get_index(index).cloned()
91        }
92    }
93}
94
95impl Default for Cache {
96    fn default() -> Self {
97        Cache::new()
98    }
99}
100
101/// The inner cache data
102#[derive(Debug)]
103struct CacheInner {
104    primary: ArrayQueue<Url>,
105    secondary: RwLock<IndexSet<Url>>,
106}
107
108/// The nekos client
109#[derive(Clone, Debug)]
110pub struct NekosClient {
111    client: nekos::Client,
112
113    cache: Cache,
114    nsfw_cache: Cache,
115}
116
117impl NekosClient {
118    /// Make a new nekos client
119    pub fn new() -> Self {
120        NekosClient {
121            client: Default::default(),
122            cache: Cache::new(),
123            nsfw_cache: Cache::new(),
124        }
125    }
126
127    /// Get a cache
128    fn get_cache(&self, nsfw: bool) -> &Cache {
129        if nsfw {
130            &self.nsfw_cache
131        } else {
132            &self.cache
133        }
134    }
135
136    /// Repopulate a cache
137    pub async fn populate(&self, nsfw: bool) -> anyhow::Result<()> {
138        let cache = self.get_cache(nsfw);
139        let image_list = self
140            .client
141            .get_random(Some(nsfw), BUFFER_SIZE)
142            .await
143            .context("failed to get random nekos image list")?;
144
145        cache.add_many(
146            image_list
147                .images
148                .iter()
149                .filter_map(|img| img.get_url().ok()),
150        );
151
152        Ok(())
153    }
154
155    /// Get a random nekos image
156    pub async fn get_rand(&self, nsfw: bool) -> anyhow::Result<Url> {
157        let cache = self.get_cache(nsfw);
158
159        if cache.primary_is_empty() {
160            let self_clone = self.clone();
161            tokio::spawn(async move {
162                // Best effort here, we can always fall back to secondary cache
163                if let Err(e) = self_clone
164                    .populate(nsfw)
165                    .await
166                    .context("failed to get new nekos data")
167                {
168                    error!("{:?}", e);
169                }
170            });
171        }
172
173        if cache.secondary_is_empty() {
174            self.populate(nsfw)
175                .await
176                .context("failed to populate caches")?;
177        }
178
179        cache
180            .get_rand()
181            .await
182            .context("both primary and secondary caches are empty")
183    }
184}
185
186impl CacheStatsProvider for NekosClient {
187    fn publish_cache_stats(&self, cache_stats_builder: &mut CacheStatsBuilder) {
188        let cache = self.get_cache(false);
189        let nsfw_cache = self.get_cache(true);
190
191        cache_stats_builder.publish_stat("nekos", "primary_cache", cache.primary_len() as f32);
192        cache_stats_builder.publish_stat(
193            "nekos",
194            "primary_nsfw_cache",
195            nsfw_cache.primary_len() as f32,
196        );
197        cache_stats_builder.publish_stat("nekos", "secondary_cache", cache.secondary_len() as f32);
198        cache_stats_builder.publish_stat(
199            "nekos",
200            "secondary_nsfw_cache",
201            nsfw_cache.secondary_len() as f32,
202        );
203    }
204}
205
206impl Default for NekosClient {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212// TODO:
213// Consider adding https://nekos.life/api/v2/endpoints
214
215/// Arguments for the nekos command
216#[derive(Debug, Copy, Clone, FromOptions)]
217pub struct NekosArguments {
218    /// Whether the command should look for nsfw pictures
219    pub nsfw: Option<bool>,
220}
221
222/// Make a nekos slash command
223pub fn create_slash_command() -> anyhow::Result<pikadick_slash_framework::Command> {
224    pikadick_slash_framework::CommandBuilder::new()
225        .name("nekos")
226        .description("Get a random neko")
227        .argument(
228            pikadick_slash_framework::ArgumentParamBuilder::new()
229                .name("nsfw")
230                .kind(pikadick_slash_framework::ArgumentKind::Boolean)
231                .description("Whether this should use nsfw results")
232                .build()?,
233        )
234        .on_process(|ctx, interaction, args: NekosArguments| async move {
235            let data_lock = ctx.data.read().await;
236            let client_data = data_lock
237                .get::<ClientDataKey>()
238                .expect("failed to get client data");
239            let nekos_client = client_data.nekos_client.clone();
240            drop(data_lock);
241
242            let content = match nekos_client
243                .get_rand(args.nsfw.unwrap_or(false))
244                .await
245                .context("failed to repopulate nekos caches")
246            {
247                Ok(url) => url.into(),
248                Err(error) => {
249                    error!("{error:?}");
250                    format!("{error:?}")
251                }
252            };
253
254            let message_builder = CreateInteractionResponseMessage::new().content(content);
255            let response = CreateInteractionResponse::Message(message_builder);
256
257            interaction.create_response(&ctx.http, response).await?;
258
259            Ok(())
260        })
261        .build()
262        .context("failed to build command")
263}