You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
457 lines
16 KiB
457 lines
16 KiB
//! # Elefren: API Wrapper around the Mastodon API.
|
|
//!
|
|
//! Most of the api is documented on [Mastodon's website](https://docs.joinmastodon.org/client/intro/)
|
|
//!
|
|
|
|
#![deny(unused_must_use)]
|
|
|
|
#[macro_use]
|
|
extern crate log;
|
|
#[macro_use]
|
|
extern crate thiserror;
|
|
|
|
use std::borrow::Cow;
|
|
use std::ops;
|
|
|
|
pub use isolang::Language;
|
|
use reqwest::multipart;
|
|
|
|
use entities::prelude::*;
|
|
pub use errors::{Error, Result};
|
|
use helpers::deserialise_response;
|
|
use serde::{Serialize,Deserialize};
|
|
use requests::{
|
|
AddFilterRequest,
|
|
AddPushRequest,
|
|
StatusesRequest,
|
|
UpdateCredsRequest,
|
|
UpdatePushRequest,
|
|
};
|
|
use streaming::EventReader;
|
|
pub use streaming::StreamKind;
|
|
|
|
pub use crate::{
|
|
data::AppData,
|
|
media_builder::MediaBuilder,
|
|
page::Page,
|
|
registration::Registration,
|
|
scopes::Scopes,
|
|
status_builder::NewStatus,
|
|
status_builder::StatusBuilder,
|
|
};
|
|
|
|
#[macro_use]
|
|
mod macros;
|
|
/// Registering your App
|
|
pub mod apps;
|
|
/// Contains the struct that holds the client auth data
|
|
pub mod data;
|
|
/// Entities returned from the API
|
|
pub mod entities;
|
|
/// Errors
|
|
pub mod errors;
|
|
/// Collection of helpers for serializing/deserializing `Data` objects
|
|
pub mod helpers;
|
|
/// Constructing media attachments for a status.
|
|
pub mod media_builder;
|
|
/// Handling multiple pages of entities.
|
|
pub mod page;
|
|
/// Registering your app.
|
|
pub mod registration;
|
|
/// Requests
|
|
pub mod requests;
|
|
/// OAuth Scopes
|
|
pub mod scopes;
|
|
/// Constructing a status
|
|
pub mod status_builder;
|
|
/// Client that doesn't need auth
|
|
pub mod unauth;
|
|
/// Streaming API
|
|
pub mod streaming;
|
|
|
|
pub mod debug;
|
|
|
|
/// Your mastodon application client, handles all requests to and from Mastodon.
|
|
#[derive(Clone, Debug)]
|
|
pub struct FediClient {
|
|
http_client: reqwest::Client,
|
|
/// Raw data about your mastodon instance.
|
|
pub data: AppData,
|
|
}
|
|
|
|
impl From<AppData> for FediClient {
|
|
/// Creates a mastodon instance from the data struct.
|
|
fn from(data: AppData) -> FediClient {
|
|
let mut builder = ClientBuilder::new();
|
|
builder.data(data);
|
|
builder.build().expect("We know `data` is present, so this should be fine")
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize,Deserialize,Clone,Copy,PartialEq)]
|
|
#[serde(rename_all="snake_case")]
|
|
pub enum SearchType {
|
|
Accounts,
|
|
Hashtags,
|
|
Statuses
|
|
}
|
|
|
|
impl FediClient {
|
|
methods![get, put, post, delete,];
|
|
|
|
pub fn route(&self, url: &str) -> String {
|
|
format!("{}{}", self.base, url)
|
|
}
|
|
|
|
/// # Low-level API for extending
|
|
/// Send a request and get response
|
|
pub async fn send(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
|
|
let request = req.bearer_auth(&self.token).build()?;
|
|
Ok(self.http_client.execute(request).await?)
|
|
}
|
|
|
|
/// Open streaming API of the given kind
|
|
pub async fn open_streaming_api<'k>(&self, kind: StreamKind<'k>) -> Result<EventReader> {
|
|
let mut url: url::Url = self.route(&"/api/v1/streaming").parse()?;
|
|
// let mut url: url::Url = self.route(&format!("/api/v1/streaming/{}", kind.get_url_fragment())).parse()?;
|
|
|
|
{
|
|
let mut qpm = url.query_pairs_mut();
|
|
qpm.append_pair("access_token", &self.token);
|
|
qpm.append_pair("stream", kind.get_stream_name());
|
|
|
|
for (k, v) in kind.get_query_params() {
|
|
qpm.append_pair(k, v);
|
|
}
|
|
}
|
|
|
|
streaming::do_open_streaming(url.as_str()).await
|
|
}
|
|
|
|
route_v1_paged!((get) favourites: "favourites" => Status);
|
|
route_v1_paged!((get) blocks: "blocks" => Account);
|
|
route_v1_paged!((get) domain_blocks: "domain_blocks" => String);
|
|
route_v1_paged!((get) follow_requests: "follow_requests" => Account);
|
|
route_v1_paged!((get) get_home_timeline: "timelines/home" => Status);
|
|
route_v1_paged!((get) get_local_timeline: "timelines/public?local=true" => Status);
|
|
route_v1_paged!((get) get_federated_timeline: "timelines/public?local=false" => Status);
|
|
route_v1_paged!((get) get_emojis: "custom_emojis" => Emoji);
|
|
route_v1_paged!((get) mutes: "mutes" => Account);
|
|
route_v1_paged!((get) notifications: "notifications" => Notification);
|
|
route_v1_paged!((get) reports: "reports" => Report);
|
|
route_v1_paged!((get (q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] limit: Option<u64>, following: bool,)) search_accounts: "accounts/search" => Account);
|
|
route_v1_paged!((get) get_endorsements: "endorsements" => Account);
|
|
|
|
route_v1_paged_id!((get) followers: "accounts/{}/followers" => Account);
|
|
route_v1_paged_id!((get) following: "accounts/{}/following" => Account);
|
|
route_v1_paged_id!((get) reblogged_by: "statuses/{}/reblogged_by" => Account);
|
|
route_v1_paged_id!((get) favourited_by: "statuses/{}/favourited_by" => Account);
|
|
|
|
route_v1!((delete (domain: String,)) unblock_domain: "domain_blocks" => Empty);
|
|
route_v1!((get) instance: "instance" => Instance);
|
|
route_v1!((get) verify_credentials: "accounts/verify_credentials" => Account);
|
|
route_v1!((post (account_id: &str, status_ids: Vec<&str>, comment: String,)) report: "reports" => Report);
|
|
route_v1!((post (domain: String,)) block_domain: "domain_blocks" => Empty);
|
|
route_v1!((post (id: &str,)) authorize_follow_request: "accounts/follow_requests/authorize" => Empty);
|
|
route_v1!((post (id: &str,)) reject_follow_request: "accounts/follow_requests/reject" => Empty);
|
|
route_v1!((get (q: &'a str, resolve: bool,)) search: "search" => SearchResult);
|
|
route_v1!((post (uri: Cow<'static, str>,)) follows: "follows" => Account);
|
|
route_v1!((post) clear_notifications: "notifications/clear" => Empty);
|
|
route_v1!((post (id: &str,)) dismiss_notification: "notifications/dismiss" => Empty);
|
|
route_v1!((get) get_push_subscription: "push/subscription" => Subscription);
|
|
route_v1!((delete) delete_push_subscription: "push/subscription" => Empty);
|
|
route_v1!((get) get_filters: "filters" => Vec<Filter>);
|
|
route_v1!((get) get_follow_suggestions: "suggestions" => Vec<Account>);
|
|
|
|
route_v2!((get (
|
|
q: &'a str,
|
|
resolve: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
#[serde(rename = "type")]
|
|
type_: Option<SearchType>,
|
|
limit: Option<i32>,
|
|
following: bool,
|
|
)) search_v2: "search" => SearchResultV2);
|
|
|
|
route_v1_id!((get) get_account: "accounts/{}" => Account);
|
|
route_v1_id!((post) follow: "accounts/{}/follow" => Relationship);
|
|
route_v1_id!((post) unfollow: "accounts/{}/unfollow" => Relationship);
|
|
route_v1_id!((post) block: "accounts/{}/block" => Relationship);
|
|
route_v1_id!((post) unblock: "accounts/{}/unblock" => Relationship);
|
|
route_v1_id!((get) mute: "accounts/{}/mute" => Relationship);
|
|
route_v1_id!((get) unmute: "accounts/{}/unmute" => Relationship);
|
|
route_v1_id!((get) get_notification: "notifications/{}" => Notification);
|
|
route_v1_id!((get) get_status: "statuses/{}" => Status);
|
|
route_v1_id!((get) get_context: "statuses/{}/context" => Context);
|
|
route_v1_id!((get) get_card: "statuses/{}/card" => Card);
|
|
route_v1_id!((post) reblog: "statuses/{}/reblog" => Status);
|
|
route_v1_id!((post) unreblog: "statuses/{}/unreblog" => Status);
|
|
route_v1_id!((post) favourite: "statuses/{}/favourite" => Status);
|
|
route_v1_id!((post) unfavourite: "statuses/{}/unfavourite" => Status);
|
|
route_v1_id!((delete) delete_status: "statuses/{}" => Empty);
|
|
route_v1_id!((get) get_filter: "filters/{}" => Filter);
|
|
route_v1_id!((delete) delete_filter: "filters/{}" => Empty);
|
|
route_v1_id!((delete) delete_from_suggestions: "suggestions/{}" => Empty);
|
|
route_v1_id!((post) endorse_user: "accounts/{}/pin" => Relationship);
|
|
route_v1_id!((post) unendorse_user: "accounts/{}/unpin" => Relationship);
|
|
|
|
/// POST /api/v1/filters
|
|
pub async fn add_filter(&self, request: &mut AddFilterRequest) -> Result<Filter> {
|
|
let url = self.route("/api/v1/filters");
|
|
let response = self.send(self.http_client.post(&url).json(&request)).await?;
|
|
|
|
let status = response.status();
|
|
|
|
if status.is_client_error() {
|
|
return Err(Error::Client(status));
|
|
} else if status.is_server_error() {
|
|
return Err(Error::Server(status));
|
|
}
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
|
|
/// PUT /api/v1/filters/:id
|
|
pub async fn update_filter(&self, id: &str, request: &mut AddFilterRequest) -> Result<Filter> {
|
|
let url = self.route(&format!("/api/v1/filters/{}", id));
|
|
let response = self.send(self.http_client.put(&url).json(&request)).await?;
|
|
|
|
let status = response.status();
|
|
|
|
if status.is_client_error() {
|
|
return Err(Error::Client(status));
|
|
} else if status.is_server_error() {
|
|
return Err(Error::Server(status));
|
|
}
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
|
|
/// PATCH /api/v1/accounts/update_credentials
|
|
pub async fn update_credentials(&self, builder: &mut UpdateCredsRequest) -> Result<Account> {
|
|
let changes = builder.build()?;
|
|
let url = self.route("/api/v1/accounts/update_credentials");
|
|
let response = self.send(self.http_client.patch(&url).json(&changes)).await?;
|
|
|
|
let status = response.status();
|
|
|
|
if status.is_client_error() {
|
|
return Err(Error::Client(status));
|
|
} else if status.is_server_error() {
|
|
return Err(Error::Server(status));
|
|
}
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
|
|
/// Post a new status to the account.
|
|
pub async fn new_status(&self, status: NewStatus) -> Result<Status> {
|
|
let response = self.send(self.http_client.post(&self.route("/api/v1/statuses")).json(&status)).await?;
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
|
|
/// Get timeline filtered by a hashtag(eg. `#coffee`) either locally or
|
|
/// federated.
|
|
pub async fn get_hashtag_timeline<'a>(&'a self, hashtag: &str, local: bool) -> Result<Page<'a, Status>> {
|
|
let base = "/api/v1/timelines/tag/";
|
|
let url = if local {
|
|
self.route(&format!("{}{}?local=1", base, hashtag))
|
|
} else {
|
|
self.route(&format!("{}{}", base, hashtag))
|
|
};
|
|
|
|
Page::new(self, self.send(self.http_client.get(&url)).await?).await
|
|
}
|
|
|
|
/// Get statuses of a single account by id. Optionally only with pictures
|
|
/// and or excluding replies.
|
|
pub async fn statuses<'a, 'b: 'a, S>(&'b self, id: &'b str, request: S) -> Result<Page<'b, Status>>
|
|
where
|
|
S: Into<Option<StatusesRequest<'a>>>,
|
|
{
|
|
let mut url = format!("{}/api/v1/accounts/{}/statuses", self.base, id);
|
|
|
|
if let Some(request) = request.into() {
|
|
url = format!("{}{}", url, request.to_querystring()?);
|
|
}
|
|
|
|
let response = self.send(self.http_client.get(&url)).await?;
|
|
|
|
Page::new(self, response).await
|
|
}
|
|
|
|
/// Returns the client account's relationship to a list of other accounts.
|
|
/// Such as whether they follow them or vice versa.
|
|
pub async fn relationships<'a>(&'a self, ids: &[&str]) -> Result<Page<'a, Relationship>> {
|
|
let mut url = self.route("/api/v1/accounts/relationships?");
|
|
|
|
if ids.len() == 1 {
|
|
url += "id=";
|
|
url += ids[0];
|
|
} else {
|
|
for id in ids {
|
|
url += "id[]=";
|
|
url += id;
|
|
url += "&";
|
|
}
|
|
url.pop();
|
|
}
|
|
|
|
let response = self.send(self.http_client.get(&url)).await?;
|
|
|
|
Page::new(self, response).await
|
|
}
|
|
|
|
/// Add a push notifications subscription
|
|
pub async fn add_push_subscription(&self, request: &AddPushRequest) -> Result<Subscription> {
|
|
let request = request.build()?;
|
|
let response = self.send(self.http_client.post(&self.route("/api/v1/push/subscription")).json(&request)).await?;
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
|
|
/// Update the `data` portion of the push subscription associated with this
|
|
/// access token
|
|
pub async fn update_push_data(&self, request: &UpdatePushRequest) -> Result<Subscription> {
|
|
let request = request.build();
|
|
let response = self.send(self.http_client.put(&self.route("/api/v1/push/subscription")).json(&request)).await?;
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
|
|
/// Get all accounts that follow the authenticated user
|
|
pub async fn follows_me<'a>(&'a self) -> Result<Page<'a, Account>> {
|
|
let me = self.verify_credentials().await?;
|
|
self.followers(&me.id).await
|
|
}
|
|
|
|
/// Get all accounts that the authenticated user follows
|
|
pub async fn followed_by_me<'a>(&'a self) -> Result<Page<'a, Account>> {
|
|
let me = self.verify_credentials().await?;
|
|
self.following(&me.id).await
|
|
}
|
|
|
|
|
|
/// returns events that are relevant to the authorized user, i.e. home
|
|
/// timeline & notifications
|
|
pub async fn streaming_user(&self) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::User).await
|
|
}
|
|
|
|
/// returns all public statuses
|
|
pub async fn streaming_public(&self) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::Public).await
|
|
}
|
|
|
|
/// Returns all local statuses
|
|
pub async fn streaming_local(&self) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::PublicLocal).await
|
|
}
|
|
|
|
/// Returns all public statuses for a particular hashtag
|
|
pub async fn streaming_public_hashtag(&self, hashtag: &str) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::Hashtag(hashtag)).await
|
|
}
|
|
|
|
/// Returns all local statuses for a particular hashtag
|
|
pub async fn streaming_local_hashtag(&self, hashtag: &str) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::HashtagLocal(hashtag)).await
|
|
}
|
|
|
|
/// Returns statuses for a list
|
|
pub async fn streaming_list(&self, list_id: &str) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::List(list_id)).await
|
|
}
|
|
|
|
/// Returns all direct messages
|
|
pub async fn streaming_direct(&self) -> Result<EventReader> {
|
|
self.open_streaming_api(StreamKind::Direct).await
|
|
}
|
|
|
|
/// Upload some media to the server for possible attaching to a new status
|
|
///
|
|
/// Upon successful upload of a media attachment, the server will assign it an id. To actually
|
|
/// use the attachment in a new status, you can use the `media_ids` field of
|
|
/// [`StatusBuilder`]
|
|
pub async fn media(&self, media: MediaBuilder) -> Result<Attachment> {
|
|
use media_builder::MediaBuilderData;
|
|
|
|
let mut form = multipart::Form::new();
|
|
form = match media.data {
|
|
MediaBuilderData::Reader(reader) => {
|
|
let mut part = multipart::Part::stream(reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(reader)));
|
|
if let Some(filename) = media.filename {
|
|
part = part.file_name(filename);
|
|
}
|
|
|
|
if let Some(mimetype) = media.mimetype {
|
|
part = part.mime_str(&mimetype)?;
|
|
}
|
|
|
|
form.part("file", part)
|
|
}
|
|
MediaBuilderData::File(file) => {
|
|
let f = tokio::fs::OpenOptions::new().read(true).open(file).await?;
|
|
let part = multipart::Part::stream(reqwest::Body::wrap_stream(tokio_util::io::ReaderStream::new(f)));
|
|
form.part("file", part)
|
|
}
|
|
};
|
|
|
|
if let Some(description) = media.description {
|
|
form = form.text("description", description);
|
|
}
|
|
|
|
if let Some((x, y)) = media.focus {
|
|
form = form.text("focus", format!("{},{}", x, y));
|
|
}
|
|
|
|
let response = self.send(self.http_client.post(&self.route("/api/v1/media")).multipart(form)).await?;
|
|
|
|
deserialise_response(response).await
|
|
}
|
|
}
|
|
|
|
impl ops::Deref for FediClient {
|
|
type Target = AppData;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.data
|
|
}
|
|
}
|
|
|
|
struct ClientBuilder {
|
|
http_client: Option<reqwest::Client>,
|
|
data: Option<AppData>,
|
|
}
|
|
|
|
impl ClientBuilder {
|
|
pub fn new() -> Self {
|
|
ClientBuilder {
|
|
http_client: None,
|
|
data: None,
|
|
}
|
|
}
|
|
|
|
pub fn http_client(&mut self, client: reqwest::Client) -> &mut Self {
|
|
self.http_client = Some(client);
|
|
self
|
|
}
|
|
|
|
pub fn data(&mut self, data: AppData) -> &mut Self {
|
|
self.data = Some(data);
|
|
self
|
|
}
|
|
|
|
pub fn build(self) -> Result<FediClient> {
|
|
Ok(if let Some(data) = self.data {
|
|
FediClient {
|
|
http_client: self.http_client.unwrap_or_else(reqwest::Client::new),
|
|
data,
|
|
}
|
|
} else {
|
|
return Err(Error::MissingField("missing field 'data'"));
|
|
})
|
|
}
|
|
}
|
|
|
|
|