1use anyhow::Context as _;
11use moka::future::{Cache, CacheBuilder};
12use serenity::all::{ChannelId, GuildChannel, GuildId};
13use serenity::client::Context;
14use std::collections::HashMap;
15use std::sync::LazyLock;
16
17pub struct CacheArgs {
19 pub guild_id: GuildId,
21 pub channel_id: ChannelId,
23}
24
25fn channel_cache<K, V>(name: &str) -> Cache<K, V>
35where
36 K: std::hash::Hash + Eq + Send + Sync + 'static,
37 V: Clone + Send + Sync + 'static,
38{
39 CacheBuilder::new(500)
40 .name(name)
41 .time_to_idle(std::time::Duration::from_secs(3600))
42 .time_to_live(std::time::Duration::from_secs(3600))
43 .build()
44}
45
46pub static GUILD_CHANNEL_LIST_CACHE: LazyLock<Cache<GuildId, HashMap<ChannelId, GuildChannel>>> =
48 LazyLock::new(|| channel_cache("guild_channel_list_cache"));
49
50pub static GUILD_CHANNEL_CACHE: LazyLock<Cache<ChannelId, GuildChannel>> =
52 LazyLock::new(|| channel_cache("guild_channel_cache"));
53
54fn belongs_to_guild(channel: &GuildChannel, guild_id: GuildId) -> bool {
56 channel.guild_id == guild_id
57}
58
59pub async fn invalidate_channel(guild_id: GuildId, channel_id: ChannelId) {
71 GUILD_CHANNEL_CACHE.invalidate(&channel_id).await;
72 GUILD_CHANNEL_LIST_CACHE.invalidate(&guild_id).await;
73 tracing::debug!(%guild_id, %channel_id, "invalidated channel caches");
74}
75
76impl CacheArgs {
77 #[tracing::instrument(
94 skip(self, ctx),
95 fields(guild_id = %self.guild_id, channel_id = %self.channel_id)
96 )]
97 pub async fn get(&self, ctx: &Context) -> anyhow::Result<GuildChannel> {
98 if let Some(channel) = GUILD_CHANNEL_CACHE.get(&self.channel_id).await {
99 if !belongs_to_guild(&channel, self.guild_id) {
105 tracing::warn!(
106 cached_guild_id = %channel.guild_id,
107 "channel cache hit belongs to another guild"
108 );
109 anyhow::bail!("Channel does not belong to the requested guild");
110 }
111 tracing::debug!("channel cache hit");
112 return Ok(channel);
113 }
114 tracing::debug!("channel cache miss");
115
116 let channel_list = GUILD_CHANNEL_LIST_CACHE
120 .try_get_with(self.guild_id, self.get_channel_list_from_api(ctx))
121 .await
122 .map_err(|e| anyhow::anyhow!("Failed to get channel list: {e}"))?;
123
124 let channel = match channel_list.get(&self.channel_id).cloned() {
125 Some(c) => c,
126 None => {
127 tracing::debug!("channel not in list, searching active threads");
130 let data = self
131 .guild_id
132 .get_active_threads(&ctx.http)
133 .await
134 .context("Failed to get active threads")?;
135 data.threads
136 .iter()
137 .find(|t| t.id == self.channel_id)
138 .cloned()
139 .ok_or_else(|| {
140 tracing::debug!("channel not found in guild or active threads");
141 anyhow::anyhow!("Channel not found in cache")
142 })?
143 }
144 };
145
146 GUILD_CHANNEL_CACHE
147 .insert(self.channel_id, channel.clone())
148 .await;
149 tracing::trace!("inserted channel into cache");
150 Ok(channel)
151 }
152
153 #[tracing::instrument(skip(self, ctx), fields(guild_id = %self.guild_id))]
158 async fn get_channel_list_from_api(
159 &self,
160 ctx: &Context,
161 ) -> anyhow::Result<HashMap<ChannelId, GuildChannel>> {
162 tracing::debug!("fetching channel list from Discord API");
163 let started = std::time::Instant::now();
164 let channels = self
165 .guild_id
166 .channels(&ctx.http)
167 .await
168 .context("Failed to get channel list")?;
169
170 tracing::debug!(
171 channels = channels.len(),
172 elapsed_ms = started.elapsed().as_millis(),
173 "fetched channel list from Discord API"
174 );
175
176 Ok(channels)
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn channel_in(guild_id: GuildId) -> GuildChannel {
187 let mut channel = GuildChannel::default();
188 channel.guild_id = guild_id;
189 channel
190 }
191
192 #[test]
193 fn channel_from_the_requested_guild_is_accepted() {
194 let guild = GuildId::new(1);
195 assert!(belongs_to_guild(&channel_in(guild), guild));
196 }
197
198 #[test]
199 fn channel_from_another_guild_is_rejected() {
200 assert!(!belongs_to_guild(
201 &channel_in(GuildId::new(2)),
202 GuildId::new(1)
203 ));
204 }
205
206 #[tokio::test]
210 async fn invalidating_drops_the_individual_channel() {
211 let guild = GuildId::new(900);
212 let channel = ChannelId::new(901);
213 GUILD_CHANNEL_CACHE.insert(channel, channel_in(guild)).await;
214
215 invalidate_channel(guild, channel).await;
216
217 assert!(GUILD_CHANNEL_CACHE.get(&channel).await.is_none());
218 }
219
220 #[tokio::test]
221 async fn invalidating_drops_the_whole_guild_channel_list() {
222 let guild = GuildId::new(910);
223 let channel = ChannelId::new(911);
224 let other = ChannelId::new(912);
225 let mut list = HashMap::new();
228 list.insert(channel, channel_in(guild));
229 list.insert(other, channel_in(guild));
230 GUILD_CHANNEL_LIST_CACHE.insert(guild, list).await;
231
232 invalidate_channel(guild, channel).await;
233
234 assert!(GUILD_CHANNEL_LIST_CACHE.get(&guild).await.is_none());
235 }
236
237 #[tokio::test]
238 async fn invalidating_leaves_other_guilds_alone() {
239 let target = GuildId::new(920);
240 let bystander = GuildId::new(921);
241 let bystander_channel = ChannelId::new(922);
242 GUILD_CHANNEL_LIST_CACHE
243 .insert(bystander, HashMap::new())
244 .await;
245 GUILD_CHANNEL_CACHE
246 .insert(bystander_channel, channel_in(bystander))
247 .await;
248
249 invalidate_channel(target, ChannelId::new(923)).await;
250
251 assert!(GUILD_CHANNEL_LIST_CACHE.get(&bystander).await.is_some());
252 assert!(GUILD_CHANNEL_CACHE.get(&bystander_channel).await.is_some());
253 }
254}