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#[derive(Debug)]
27pub struct MastodonClient {
28 pub(crate) client: Client,
29 pub data: Data,
31}
32
33#[derive(Debug, Clone)]
35pub struct Mastodon(Arc<MastodonClient>);
36
37static_assertions::assert_impl_all!(Mastodon: Send, Sync);
39
40#[derive(Clone, Debug)]
42pub struct MastodonUnauthenticated {
43 client: Client,
44 pub base: Url,
46}
47
48impl From<Data> for Mastodon {
49 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(crate) fn authenticated(&self, request: RequestBuilder) -> RequestBuilder {
386 request.bearer_auth(&self.data.token)
387 }
388
389 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 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 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 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 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 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 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}