twilight_webhook/
cache.rs

1use dashmap::{mapref::one::Ref, DashMap};
2use twilight_cache_inmemory::InMemoryCache;
3use twilight_http::{request::channel::webhook::CreateWebhook, Client};
4use twilight_model::{
5    channel::Webhook,
6    gateway::event::Event,
7    guild::Permissions,
8    id::{
9        marker::{ChannelMarker, UserMarker},
10        Id,
11    },
12};
13
14#[derive(thiserror::Error, Debug)]
15/// An error occurred when trying to update the cache
16pub enum Error {
17    /// An error was returned by Twilight's HTTP client while making the request
18    #[error("An error was returned by Twilight's HTTP client: {0}")]
19    Http(#[from] twilight_http::error::Error),
20    /// An error was returned by Twilight's HTTP client while deserializing the
21    /// response
22    #[error(
23        "An error was returned by Twilight's HTTP client while deserializing the response: {0}"
24    )]
25    Deserialize(#[from] twilight_http::response::DeserializeBodyError),
26    /// An error was returned by Twilight while validating a request
27    #[error("An error was returned by Twilight while validating a request: {0}")]
28    Validation(#[from] twilight_validate::request::ValidationError),
29    /// An error was returned by Twilight while trying to get the permissions
30    /// from the cache
31    #[error(
32        "An error was returned by Twilight while trying to get the permissions from the cache: {0}"
33    )]
34    CachePermissions(#[from] twilight_cache_inmemory::permission::ChannelError),
35}
36
37#[derive(Debug, Clone)]
38/// Specify how permissions are handled on [`WebhooksCache::update`]
39pub enum PermissionsSource<'cache> {
40    /// Use the given permissions
41    Given(Permissions),
42    /// Use the cache to get permissions
43    ///
44    /// Refer to [Twilight's docs] to make sure the passed cache is valid
45    ///
46    /// [Twilight's docs]:https://api.twilight.rs/twilight_cache_inmemory/permission/index.html
47    Cached {
48        /// The cache to get the permissions from
49        cache: &'cache InMemoryCache,
50        /// The bot's ID
51        current_user_id: Id<UserMarker>,
52    },
53    /// Understand the permissions from the error-response of the API request
54    ///
55    /// You may want to use this if you aren't already using `InMemoryCache`'s
56    /// permission feature, since the overhead of avoidable requests is usually
57    /// lower than caching the permissions
58    Request,
59}
60
61impl PermissionsSource<'_> {
62    /// Get the permissions from the source
63    fn get(self, channel_id: Id<ChannelMarker>) -> Result<Permissions, Error> {
64        Ok(match self {
65            PermissionsSource::Given(permissions) => permissions,
66            PermissionsSource::Cached {
67                cache,
68                current_user_id,
69            } => cache
70                .permissions()
71                .in_channel(current_user_id, channel_id)?,
72            PermissionsSource::Request => Permissions::all(),
73        })
74    }
75}
76
77/// Cache to hold webhooks, keyed by channel IDs for general usage
78#[derive(Debug)]
79#[allow(clippy::module_name_repetitions)]
80pub struct WebhooksCache(DashMap<Id<ChannelMarker>, Webhook>);
81
82impl Default for WebhooksCache {
83    fn default() -> Self {
84        Self::new()
85    }
86}
87
88impl WebhooksCache {
89    /// Creates a new webhook cache
90    ///
91    /// # Invalidation warning
92    /// Refer to the docs for [`WebhooksCache::update`] to avoid invalidation
93    #[must_use]
94    pub fn new() -> Self {
95        Self(DashMap::new())
96    }
97
98    /// Convenience function to get from the cache, requesting it from the API
99    /// if it doesn't exist, creating it if it's also not returned
100    ///
101    /// # Required permissions
102    /// Make sure the bot has `MANAGE_WEBHOOKS` permission in the given channel
103    ///
104    /// # Errors
105    /// Returns an [`Error::Http`] or [`Error::Deserialize`] if the webhook
106    /// isn't in the cache
107    ///
108    /// # Panics
109    /// If the webhook that was just inserted to the cache somehow doesn't exist
110    #[allow(clippy::unwrap_used)]
111    pub async fn get_infallible(
112        &self,
113        http: &Client,
114        channel_id: Id<ChannelMarker>,
115        name: &str,
116    ) -> Result<Ref<'_, Id<ChannelMarker>, Webhook>, Error> {
117        if let Some(webhook) = self.get(channel_id) {
118            Ok(webhook)
119        } else {
120            let webhook = if let Some(webhook) = http
121                .channel_webhooks(channel_id)
122                .await?
123                .models()
124                .await?
125                .into_iter()
126                .find(|w| w.token.is_some())
127            {
128                webhook
129            } else {
130                http.create_webhook(channel_id, name)?
131                    .await?
132                    .model()
133                    .await?
134            };
135            self.0.insert(channel_id, webhook);
136            Ok(self.get(channel_id).unwrap())
137        }
138    }
139
140    /// Creates the passed webhook and caches it, it takes a `CreateWebhook`
141    /// instead of a `Webhook` to reduce boilerplate and avoid clones
142    ///
143    /// # Errors
144    /// Returns [`Error::Http`] or [`Error::Deserialize`]
145    pub async fn create(&self, create_webhook: CreateWebhook<'_>) -> Result<(), Error> {
146        let webhook = create_webhook.await?.model().await?;
147        self.0.insert(webhook.channel_id, webhook);
148
149        Ok(())
150    }
151
152    /// Returns the webhook for the given `channel_id`, if it exists
153    #[must_use]
154    pub fn get(
155        &self,
156        channel_id: Id<ChannelMarker>,
157    ) -> Option<Ref<'_, Id<ChannelMarker>, Webhook>> {
158        self.0.get(&channel_id)
159    }
160
161    /// Removes the cached webhooks for the given event's channel or guild
162    ///
163    /// Unless the event is `WebhookUpdate`, this function isn't actually
164    /// `async`, `http` and `permissions` aren't used, and it isn't fallible
165    ///
166    /// `http` is required because Discord doesn't send info about updated
167    /// webhooks in the event
168    ///
169    /// `permissions` is required because the bot needs `MANAGE_WEBHOOKS`
170    /// permissions to request webhooks
171    ///
172    /// # Invalidation warning
173    /// You should run this on `ChannelDelete`, `GuildDelete` and
174    /// `WebhookUpdate` events to make sure deleted webhooks are removed
175    /// from the cache, or else executing a cached webhook will return
176    /// `Unknown Webhook` errors
177    ///
178    /// # Errors
179    /// Returns [`Error::Http`], [`Error::Deserialize`], or when
180    /// [`PermissionsSource::Cache`] is passed, [`Error::CachePermissions`]
181    #[allow(clippy::wildcard_enum_match_arm)]
182    pub async fn update(
183        &self,
184        event: &Event,
185        http: &Client,
186        permissions: PermissionsSource<'_>,
187    ) -> Result<(), Error> {
188        match event {
189            Event::ChannelDelete(channel) => {
190                self.0.remove(&channel.id);
191            }
192            Event::GuildDelete(guild) => self
193                .0
194                .retain(|_, webhook| webhook.guild_id != Some(guild.id)),
195            Event::WebhooksUpdate(update) => {
196                if !self.0.contains_key(&update.channel_id) {
197                    return Ok(());
198                }
199
200                if !permissions
201                    .get(update.channel_id)?
202                    .contains(Permissions::MANAGE_WEBHOOKS)
203                {
204                    self.0.remove(&update.channel_id);
205                    return Ok(());
206                }
207
208                if let Ok(response) = http.channel_webhooks(update.channel_id).await {
209                    if response
210                        .models()
211                        .await?
212                        .iter()
213                        .any(|webhook| webhook.token.is_some())
214                    {
215                        return Ok(());
216                    }
217                };
218
219                self.0.remove(&update.channel_id);
220            }
221            _ => (),
222        };
223
224        Ok(())
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use twilight_http::Client;
231    use twilight_model::{
232        channel::{Channel, ChannelType, Webhook, WebhookType},
233        gateway::{
234            event::Event,
235            payload::incoming::{ChannelDelete, GuildDelete, WebhooksUpdate},
236        },
237        id::Id,
238    };
239
240    use crate::cache::{PermissionsSource, WebhooksCache};
241
242    const WEBHOOK: Webhook = Webhook {
243        id: Id::new(1),
244        channel_id: Id::new(1),
245        kind: WebhookType::Application,
246        application_id: None,
247        avatar: None,
248        guild_id: Some(Id::new(10)),
249        name: None,
250        source_channel: None,
251        source_guild: None,
252        token: None,
253        url: None,
254        user: None,
255    };
256
257    #[allow(clippy::unwrap_used)]
258    async fn mock_update(cache: &WebhooksCache, event: &Event) {
259        cache
260            .update(
261                event,
262                &Client::builder().build(),
263                PermissionsSource::Request,
264            )
265            .await
266            .unwrap();
267    }
268
269    #[test]
270    fn get() {
271        let cache = WebhooksCache::new();
272        cache.0.insert(Id::new(1), WEBHOOK);
273
274        assert!(cache.get(Id::new(2)).is_none());
275
276        assert_eq!(cache.get(Id::new(1)).as_deref(), Some(&WEBHOOK));
277    }
278
279    #[tokio::test]
280    async fn update() {
281        let cache = WebhooksCache::new();
282
283        cache.0.insert(Id::new(1), WEBHOOK);
284        mock_update(
285            &cache,
286            &Event::GuildDelete(GuildDelete {
287                id: Id::new(11),
288                unavailable: false,
289            }),
290        )
291        .await;
292        assert_eq!(cache.get(Id::new(1)).as_deref(), Some(&WEBHOOK));
293
294        cache.0.insert(Id::new(2), WEBHOOK);
295        mock_update(
296            &cache,
297            &Event::GuildDelete(GuildDelete {
298                id: Id::new(10),
299                unavailable: false,
300            }),
301        )
302        .await;
303        assert!(cache.get(Id::new(1)).is_none());
304        assert!(cache.get(Id::new(2)).is_none());
305
306        cache.0.insert(Id::new(3), WEBHOOK);
307        mock_update(
308            &cache,
309            &Event::ChannelDelete(Box::new(ChannelDelete(Channel {
310                id: Id::new(3),
311                guild_id: Some(Id::new(10)),
312                kind: ChannelType::GuildText,
313                application_id: None,
314                applied_tags: None,
315                available_tags: None,
316                bitrate: None,
317                default_auto_archive_duration: None,
318                default_reaction_emoji: None,
319                default_thread_rate_limit_per_user: None,
320                icon: None,
321                invitable: None,
322                last_message_id: None,
323                last_pin_timestamp: None,
324                member: None,
325                member_count: None,
326                message_count: None,
327                name: None,
328                newly_created: None,
329                nsfw: None,
330                owner_id: None,
331                parent_id: None,
332                permission_overwrites: None,
333                position: None,
334                rate_limit_per_user: None,
335                recipients: None,
336                rtc_region: None,
337                thread_metadata: None,
338                topic: None,
339                user_limit: None,
340                video_quality_mode: None,
341                flags: None,
342            }))),
343        )
344        .await;
345        assert!(cache.get(Id::new(3)).is_none());
346
347        cache.0.insert(Id::new(4), WEBHOOK);
348        mock_update(
349            &cache,
350            &Event::WebhooksUpdate(WebhooksUpdate {
351                channel_id: Id::new(12),
352                guild_id: Id::new(10),
353            }),
354        )
355        .await;
356        assert_eq!(cache.get(Id::new(4)).as_deref(), Some(&WEBHOOK));
357
358        cache.0.insert(Id::new(5), WEBHOOK);
359        mock_update(
360            &cache,
361            &Event::WebhooksUpdate(WebhooksUpdate {
362                channel_id: Id::new(5),
363                guild_id: Id::new(10),
364            }),
365        )
366        .await;
367        assert!(cache.get(Id::new(5)).is_none());
368    }
369}