mastodon_async/
mastodon.rs

1use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc};
2
3use crate::{
4    entities::{
5        account::Account,
6        prelude::*,
7        report::Report,
8        status::{Emoji, Status},
9        Empty,
10    },
11    errors::{Error, Result},
12    helpers::read_response::read_response,
13    log_serde,
14    polling_time::PollingTime,
15    AddFilterRequest, AddPushRequest, Data, NewStatus, Page, StatusesRequest, UpdateCredsRequest,
16    UpdatePushRequest,
17};
18use futures::TryStream;
19use log::{as_debug, as_serde, debug, error, trace};
20use mastodon_async_entities::attachment::ProcessedAttachment;
21use reqwest::{multipart::Part, Client, RequestBuilder};
22use url::Url;
23use uuid::Uuid;
24
25/// The Mastodon client is a smart pointer to this struct
26#[derive(Debug)]
27pub struct MastodonClient {
28    pub(crate) client: Client,
29    /// Raw data about your mastodon instance.
30    pub data: Data,
31}
32
33/// Your mastodon application client, handles all requests to and from Mastodon.
34#[derive(Debug, Clone)]
35pub struct Mastodon(Arc<MastodonClient>);
36
37// This ensures we don't accidentally make Mastodon not Send or Sync again
38static_assertions::assert_impl_all!(Mastodon: Send, Sync);
39
40/// A client for making unauthenticated requests to the public API.
41#[derive(Clone, Debug)]
42pub struct MastodonUnauthenticated {
43    client: Client,
44    /// Which Mastodon instance to contact
45    pub base: Url,
46}
47
48impl From<Data> for Mastodon {
49    /// Creates a mastodon instance from the data struct.
50    fn from(data: Data) -> Mastodon {
51        Mastodon::new(Client::new(), data)
52    }
53}
54impl Mastodon {
55    methods![get and get_with_call_id, post and post_with_call_id, delete and delete_with_call_id,];
56
57    paged_routes! {
58        (get) favourites: "favourites" => Status,
59        (get) bookmarks: "bookmarks" => Status,
60        (get) blocks: "blocks" => Account,
61        (get) domain_blocks: "domain_blocks" => String,
62        (get) follow_requests: "follow_requests" => Account,
63        (get) get_home_timeline: "timelines/home" => Status,
64        (get) get_emojis: "custom_emojis" => Emoji,
65        (get) mutes: "mutes" => Account,
66        (get) notifications: "notifications" => Notification,
67        (get) reports: "reports" => Report,
68        (get (q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] limit: Option<u64>, following: bool,)) search_accounts: "accounts/search" => Account,
69        (get) get_endorsements: "endorsements" => Account,
70    }
71
72    paged_routes_with_id! {
73        (get) followers: "accounts/{}/followers" => Account,
74        (get) following: "accounts/{}/following" => Account,
75        (get) reblogged_by: "statuses/{}/reblogged_by" => Account,
76        (get) favourited_by: "statuses/{}/favourited_by" => Account,
77    }
78
79    route! {
80        (delete (domain: String,)) unblock_domain: "domain_blocks" => Empty,
81        (get) instance: "instance" => Instance,
82        (get) verify_credentials: "accounts/verify_credentials" => Account,
83        (post (account_id: &str, status_ids: Vec<&str>, comment: String,)) report: "reports" => Report,
84        (post (domain: String,)) block_domain: "domain_blocks" => Empty,
85        (post (id: &str,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty,
86        (post (id: &str,)) reject_follow_request: "accounts/follow_requests/reject" => Empty,
87        (get  (local: bool,)) get_public_timeline: "timelines/public" => Vec<Status>,
88        (post (uri: Cow<'static, str>,)) follows: "follows" => Account,
89        (post) clear_notifications: "notifications/clear" => Empty,
90        (get) get_push_subscription: "push/subscription" => Subscription,
91        (delete) delete_push_subscription: "push/subscription" => Empty,
92        (get) get_filters: "filters" => Vec<Filter>,
93        (get) get_follow_suggestions: "suggestions" => Vec<Account>,
94    }
95
96    route_v2! {
97        (get (q: &'a str, resolve: bool,)) search: "search" => SearchResult,
98        (post multipart with description (file: impl AsRef<Path>,)) media: "media" => Attachment,
99        (post multipart with description (file: impl AsRef<Path>, thumbnail: impl AsRef<Path>,)) media_with_thumbnail: "media" => Attachment,
100    }
101
102    route_id! {
103        (get) get_account[AccountId]: "accounts/{}" => Account,
104        (post) follow[AccountId]: "accounts/{}/follow" => Relationship,
105        (post) unfollow[AccountId]: "accounts/{}/unfollow" => Relationship,
106        (post) block[AccountId]: "accounts/{}/block" => Relationship,
107        (post) unblock[AccountId]: "accounts/{}/unblock" => Relationship,
108        (get) mute[AccountId]: "accounts/{}/mute" => Relationship,
109        (get) unmute[AccountId]: "accounts/{}/unmute" => Relationship,
110        (get) get_notification[NotificationId]: "notifications/{}" => Notification,
111        (post) dismiss_notification[NotificationId]: "notifications/{}/dismiss" => Empty,
112        (get) get_status[StatusId]: "statuses/{}" => Status,
113        (get) get_context[StatusId]: "statuses/{}/context" => Context,
114        (get) get_card[StatusId]: "statuses/{}/card" => Card,
115        (post) reblog[StatusId]: "statuses/{}/reblog" => Status,
116        (post) unreblog[StatusId]: "statuses/{}/unreblog" => Status,
117        (post) favourite[StatusId]: "statuses/{}/favourite" => Status,
118        (post) unfavourite[StatusId]: "statuses/{}/unfavourite" => Status,
119        (delete) delete_status[StatusId]: "statuses/{}" => Empty,
120        (get) get_filter[FilterId]: "filters/{}" => Filter,
121        (delete) delete_filter[FilterId]: "filters/{}" => Empty,
122        (delete) delete_from_suggestions[AccountId]: "suggestions/{}" => Empty,
123        (post) endorse_user[AccountId]: "accounts/{}/pin" => Relationship,
124        (post) unendorse_user[AccountId]: "accounts/{}/unpin" => Relationship,
125        (get) attachment[AttachmentId]: "media/{}" => Attachment,
126    }
127
128    streaming! {
129        "returns events that are relevant to the authorized user, i.e. home timeline & notifications"
130        stream_user@"user",
131        "All public posts known to the server. Analogous to the federated timeline."
132        stream_public@"public",
133        "All public posts known to the server, filtered for media attachments. Analogous to the federated timeline with 'only media' enabled."
134        stream_public_media@"public/media",
135        "All public posts originating from this server."
136        stream_local(flag only_media)@"public/local",
137        "All public posts originating from other servers."
138        stream_remote(flag only_media)@"public/remote",
139        "All public posts using a certain hashtag."
140        stream_hashtag(tag: impl AsRef<str>, like "#bots")@"hashtag",
141        "All public posts using a certain hashtag, originating from this server."
142        stream_local_hashtag(tag: impl AsRef<str>, like "#bots")@"hashtag/local",
143        "Notifications for the current user."
144        stream_notifications@"user/notification",
145        "Updates to a specific list."
146        stream_list(list: impl AsRef<str>, like "12345")@"list",
147        "Updates to direct conversations."
148        stream_direct@"direct",
149    }
150
151    /// A new instance.
152    pub fn new(client: Client, data: Data) -> Self {
153        Mastodon(Arc::new(MastodonClient { client, data }))
154    }
155
156    fn route(&self, url: impl AsRef<str>) -> String {
157        format!("{}{}", self.data.base, url.as_ref())
158    }
159
160    /// POST /api/v1/filters
161    pub async fn add_filter(&self, request: &mut AddFilterRequest) -> Result<Filter> {
162        let response = self
163            .client
164            .post(self.route("/api/v1/filters"))
165            .json(&request)
166            .send()
167            .await?;
168
169        read_response(response).await
170    }
171
172    /// PUT /api/v1/filters/:id
173    pub async fn update_filter(&self, id: &str, request: &mut AddFilterRequest) -> Result<Filter> {
174        let url = self.route(format!("/api/v1/filters/{id}"));
175        let response = self.client.put(&url).json(&request).send().await?;
176
177        read_response(response).await
178    }
179
180    /// Update the user credentials
181    pub async fn update_credentials(&self, builder: &mut UpdateCredsRequest) -> Result<Account> {
182        let changes = builder.build()?;
183        let url = self.route("/api/v1/accounts/update_credentials");
184        let response = self.client.patch(&url).json(&changes).send().await?;
185
186        read_response(response).await
187    }
188
189    /// Post a new status to the account.
190    pub async fn new_status(&self, status: NewStatus) -> Result<Status> {
191        let url = self.route("/api/v1/statuses");
192        let response = self
193            .authenticated(self.client.post(&url))
194            .json(&status)
195            .send()
196            .await?;
197        debug!(
198            status = log_serde!(response Status), url = url,
199            headers = log_serde!(response Headers);
200            "received API response"
201        );
202        read_response(response).await
203    }
204
205    /// Get timeline filtered by a hashtag(eg. `#coffee`) either locally or
206    /// federated.
207    pub async fn get_tagged_timeline(&self, hashtag: String, local: bool) -> Result<Vec<Status>> {
208        let base = "/api/v1/timelines/tag/";
209        let url = if local {
210            self.route(format!("{base}{hashtag}?local=1"))
211        } else {
212            self.route(format!("{base}{hashtag}"))
213        };
214
215        self.get(url).await
216    }
217
218    /// Get statuses of a single account by id. Optionally only with pictures
219    /// and or excluding replies.
220    ///
221    /// // Example
222    ///
223    /// ```no_run
224    /// use mastodon_async::prelude::*;
225    /// tokio_test::block_on(async {
226    ///     let data = Data::default();
227    ///     let client = Mastodon::from(data);
228    ///     let statuses = client.statuses(&AccountId::new("user-id"), Default::default()).await.unwrap();
229    /// });
230    /// ```
231    ///
232    /// ```no_run
233    /// use mastodon_async::prelude::*;
234    /// tokio_test::block_on(async {
235    ///     let data = Data::default();
236    ///     let client = Mastodon::from(data);
237    ///     let mut request = StatusesRequest::new();
238    ///     request.only_media();
239    ///     let statuses = client.statuses(&AccountId::new("user-id"), request).await.unwrap();
240    /// });
241    /// ```
242    pub async fn statuses<'a, 'b: 'a>(
243        &'b self,
244        id: &'b AccountId,
245        request: StatusesRequest<'a>,
246    ) -> Result<Page<Status>> {
247        let call_id = Uuid::new_v4();
248        let mut url = format!("{}/api/v1/accounts/{}/statuses", self.data.base, id);
249
250        url += request.to_query_string()?.as_str();
251
252        debug!(url = url, method = stringify!($method), call_id = as_debug!(call_id); "making API request");
253        let response = self.client.get(&url).send().await?;
254
255        Page::new(self.clone(), response, call_id).await
256    }
257
258    /// Returns the client account's relationship to a list of other accounts.
259    /// Such as whether they follow them or vice versa.
260    pub async fn relationships(&self, ids: &[&AccountId]) -> Result<Page<Relationship>> {
261        let call_id = Uuid::new_v4();
262        let mut url = self.route("/api/v1/accounts/relationships?");
263
264        if ids.len() == 1 {
265            url += "id=";
266            url += ids[0].as_ref();
267        } else {
268            for id in ids {
269                url += "id[]=";
270                url += id.as_ref();
271                url += "&";
272            }
273            url.pop();
274        }
275
276        debug!(
277            url = url, method = stringify!($method),
278            call_id = as_debug!(call_id), account_ids = as_serde!(ids);
279            "making API request"
280        );
281        let response = self.client.get(&url).send().await?;
282
283        Page::new(self.clone(), response, call_id).await
284    }
285
286    /// Add a push notifications subscription
287    pub async fn add_push_subscription(&self, request: &AddPushRequest) -> Result<Subscription> {
288        let call_id = Uuid::new_v4();
289        let request = request.build()?;
290        let url = &self.route("/api/v1/push/subscription");
291        debug!(
292            url = url, method = stringify!($method),
293            call_id = as_debug!(call_id), post_body = as_serde!(request);
294            "making API request"
295        );
296        let response = self.client.post(url).json(&request).send().await?;
297
298        read_response(response).await
299    }
300
301    /// Update the `data` portion of the push subscription associated with this
302    /// access token
303    pub async fn update_push_data(&self, request: &UpdatePushRequest) -> Result<Subscription> {
304        let call_id = Uuid::new_v4();
305        let request = request.build();
306        let url = &self.route("/api/v1/push/subscription");
307        debug!(
308            url = url, method = stringify!($method),
309            call_id = as_debug!(call_id), post_body = as_serde!(request);
310            "making API request"
311        );
312        let response = self.client.post(url).json(&request).send().await?;
313
314        read_response(response).await
315    }
316
317    /// Get all accounts that follow the authenticated user
318    pub async fn follows_me(&self) -> Result<Page<Account>> {
319        let me = self.verify_credentials().await?;
320        self.followers(&me.id).await
321    }
322
323    /// Get all accounts that the authenticated user follows
324    pub async fn followed_by_me(&self) -> Result<Page<Account>> {
325        let me = self.verify_credentials().await?;
326        self.following(&me.id).await
327    }
328
329    /// Wait for the media to be done processing and return it with the URL.
330    ///
331    /// `Default::default()` may be passed as the polling time to select a
332    /// polling time of 500ms.
333    ///
334    /// ## Example
335    /// ```rust,no_run
336    /// use mastodon_async::prelude::*;
337    /// let mastodon = Mastodon::from(Data::default());
338    /// tokio_test::block_on(async {
339    ///     let attachment = mastodon.media("/path/to/some/file.jpg", None).await.expect("upload");
340    ///     let attachment = mastodon.wait_for_processing(attachment, Default::default()).await.expect("processing");
341    ///     println!("{}", attachment.url);
342    /// });
343    /// ```
344    ///
345    /// For a different polling time, use `.into()` on a `std::time::Duration`.
346    /// ```rust,no_run
347    /// use mastodon_async::prelude::*;
348    /// use std::time::Duration;
349    /// let mastodon = Mastodon::from(Data::default());
350    /// tokio_test::block_on(async {
351    ///     let attachment = mastodon.media("/path/to/some/file.jpg", None).await.expect("upload");
352    ///     let attachment = mastodon.wait_for_processing(
353    ///         attachment,
354    ///         Duration::from_secs(1).into(),
355    ///     ).await.expect("processing");
356    ///     println!("{}", attachment.url);
357    /// });
358    /// ```
359    pub async fn wait_for_processing(
360        &self,
361        mut attachment: Attachment,
362        polling_time: PollingTime,
363    ) -> Result<ProcessedAttachment> {
364        let id = attachment.id;
365        loop {
366            if let Some(url) = attachment.url {
367                return Ok(ProcessedAttachment {
368                    id,
369                    media_type: attachment.media_type,
370                    url,
371                    remote_url: attachment.remote_url,
372                    preview_url: attachment.preview_url,
373                    text_url: attachment.text_url,
374                    meta: attachment.meta,
375                    description: attachment.description,
376                });
377            } else {
378                attachment = self.attachment(&id).await?;
379                tokio::time::sleep(*polling_time).await;
380            }
381        }
382    }
383
384    /// Set the bearer authentication token
385    pub(crate) fn authenticated(&self, request: RequestBuilder) -> RequestBuilder {
386        request.bearer_auth(&self.data.token)
387    }
388
389    /// Return a part for a multipart form submission from a file, including
390    /// the name of the file.
391    fn get_form_part(path: impl AsRef<Path>) -> Result<Part> {
392        use std::io::Read;
393
394        let path = path.as_ref();
395
396        match std::fs::File::open(path) {
397            Ok(mut file) => {
398                let mut data = if let Ok(metadata) = file.metadata() {
399                    Vec::with_capacity(metadata.len().try_into()?)
400                } else {
401                    vec![]
402                };
403                file.read_to_end(&mut data)?;
404                // TODO extract filename, error on dirs, etc.
405                Ok(Part::bytes(data).file_name(Cow::Owned(path.to_string_lossy().to_string())))
406            }
407            Err(err) => {
408                error!(path = as_debug!(path), error = as_debug!(err); "error reading file contents for multipart form");
409                Err(err.into())
410            }
411        }
412    }
413}
414
415impl MastodonUnauthenticated {
416    methods![get and get_with_call_id,];
417
418    /// Create a new client for unauthenticated requests to a given Mastodon
419    /// instance.
420    pub fn new(base: impl AsRef<str>) -> Result<MastodonUnauthenticated> {
421        let base = base.as_ref();
422        let base = if base.starts_with("https://") {
423            base.to_string()
424        } else {
425            format!("https://{}", base.trim_start_matches("http://"))
426        };
427        trace!(base = base; "creating new mastodon client");
428        Ok(MastodonUnauthenticated {
429            client: Client::new(),
430            base: Url::parse(&base)?,
431        })
432    }
433
434    fn route(&self, url: &str) -> Result<Url> {
435        Ok(self.base.join(url)?)
436    }
437
438    /// GET /api/v1/statuses/:id
439    pub async fn get_status(&self, id: &str) -> Result<Status> {
440        let route = self.route("/api/v1/statuses")?;
441        let route = route.join(id)?;
442        self.get(route.as_str()).await
443    }
444
445    /// GET /api/v1/statuses/:id/context
446    pub async fn get_context(&self, id: &str) -> Result<Context> {
447        let route = self.route("/api/v1/statuses")?;
448        let route = route.join(id)?;
449        let route = route.join("context")?;
450        self.get(route.as_str()).await
451    }
452
453    /// GET /api/v1/statuses/:id/card
454    pub async fn get_card(&self, id: &str) -> Result<Card> {
455        let route = self.route("/api/v1/statuses")?;
456        let route = route.join(id)?;
457        let route = route.join("card")?;
458        self.get(route.as_str()).await
459    }
460
461    /// Since this client needs no authentication, this returns the
462    /// `RequestBuilder` unmodified.
463    fn authenticated(&self, request: RequestBuilder) -> RequestBuilder {
464        request
465    }
466}
467impl Deref for Mastodon {
468    type Target = Arc<MastodonClient>;
469
470    fn deref(&self) -> &Self::Target {
471        &self.0
472    }
473}
474
475impl From<MastodonClient> for Mastodon {
476    fn from(value: MastodonClient) -> Self {
477        Mastodon(Arc::new(value))
478    }
479}