using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace Uwaa.Pleroma;
delegate void AddPair(string key, string value);
///
/// A pleroma client.
///
public class Pleroma
{
static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
static string CreateQuery(Action generator)
{
StringBuilder sb = new StringBuilder();
void addPair(string key, string value)
{
if (sb.Length == 0)
sb.Append('?');
else
sb.Append('&');
sb.Append(WebUtility.UrlEncode(key));
sb.Append('=');
sb.Append(WebUtility.UrlEncode(value));
}
generator(addPair);
return sb.ToString();
}
public HttpClient HttpClient;
public Pleroma(string host, string? authorization, string? userAgent = "Uwaa.Pleroma/0.0")
{
UriBuilder builder = new UriBuilder();
builder.Scheme = "https";
builder.Host = host;
HttpClient = new HttpClient();
HttpClient.DefaultRequestHeaders.Add("User-agent", userAgent);
HttpClient.BaseAddress = builder.Uri;
HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (authorization != null)
HttpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorization);
}
async Task Retry(Func reqFactory)
{
while (true)
{
HttpResponseMessage res = await HttpClient.SendAsync(reqFactory());
if (res.StatusCode == HttpStatusCode.NotFound)
return default;
if (res.Content == null)
throw new HttpRequestException("Server responded with no content");
string text = await res.Content.ReadAsStringAsync();
if (res.StatusCode is >= (HttpStatusCode)300)
{
try
{
PleromaSimpleException? err = JsonSerializer.Deserialize(text, SerializerOptions);
if (err != null && err.Text != null)
{
if (err.Text == "Throttled")
{
await Task.Delay(5000);
continue; //Retry
}
else
{
throw err;
}
}
}
catch (JsonException)
{
//Not a simple error
}
try
{
PleromaAggregateException? err = JsonSerializer.Deserialize(text, SerializerOptions);
if (err != null && err.Text != null)
throw err;
}
catch (JsonException)
{
//Not an aggregate error
}
throw new HttpRequestException(text);
}
if (res.StatusCode is >= (HttpStatusCode)200 and < (HttpStatusCode)300)
return JsonSerializer.Deserialize(text, SerializerOptions) ?? throw new HttpRequestException("Couldn't deserialize response");
else
throw new HttpRequestException("Unknown error occurred");
}
}
///
/// Fetches the account of the pleroma client.
///
public Task GetAccount() => Retry(() => new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!;
///
/// Fetches an account by ID.
///
/// Account ID to fetch
public Task GetAccountByID(string id) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(id)}"));
///
/// Fetches an account by nickname.
///
/// User nickname
public Task GetAccountByName(string acct) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/lookup?acct={WebUtility.UrlEncode(acct)}"));
///
/// Follows the given account.
///
/// Account to follow
/// The new relationship, if the provided account exists.
public Task Follow(AccountID account) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/follow"));
///
/// Unfollows the given account.
///
/// Account to unfollow
/// The new state of the relationship, if the provided account exists.
public Task Unfollow(AccountID account) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/unfollow"));
///
/// Gets accounts which the given account is following, if network is not hidden by the account owner.
///
/// Account to get the follows of.
/// Return items older than this ID
/// Return the oldest items newer than this ID
/// Return the newest items newer than this ID
/// Return items past this number of items
/// Maximum number of items to return. Will be ignored if it's more than 40
public Task GetFollowing(AccountID account,
string? max_id = null,
string? min_id = null,
string? since_id = null,
int offset = 0,
int limit = 20)
{
return Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{account.ID}/following" + CreateQuery(addPair =>
{
if (max_id != null) addPair("max_id", max_id);
if (min_id != null) addPair("min_id", min_id);
if (since_id != null) addPair("since_id", since_id);
if (offset > 0) addPair("offset", offset.ToString());
if (limit != 20) addPair("limit", limit.ToString());
})))!;
}
///
/// Sets a private note for the given account.
///
/// Account ID
/// Account note body
///
public Task SetUserNote(AccountID account, string comment)
{
MemoryStream mem = new MemoryStream();
{
Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true });
writer.WriteStartObject();
writer.WritePropertyName("comment");
writer.WriteStringValue(comment);
writer.WriteEndObject();
writer.Flush();
}
return Retry(() =>
{
mem.Position = 0;
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/note");
req.Content = new StreamContent(mem);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
})!;
}
///
/// Gets the account's relationship with the given account.
///
/// Account ID
/// A relationship object for the requested account, if the account exists.
public async Task GetRelationship(AccountID account)
{
Relationship[]? rels = await Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/relationships?id={WebUtility.UrlEncode(account.ID)}"));
if (rels == null || rels.Length == 0)
return null;
else
return rels[0];
}
///
/// Posts a status.
///
/// Text content of the status.
/// Mark status and attached media as sensitive?
/// The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour.
/// ID of the status being replied to, if status is a reply
/// ID of the status being quoted.
/// ISO 639 language code for this status.
/// Array of Attachment ids to be attached as media.
/// Visibility of the posted status.
/// Thrown if something goes wrong while publishing the status.
/// The newly published status if posting was successful.
public Task PostStatus(string? content = null,
bool sensitive = false,
int? expiresIn = null,
string? replyTo = null,
string? quoting = null,
string? language = null,
MediaID[]? attachments = null,
StatusVisibility visibility = StatusVisibility.Public)
{
if (content == null && (attachments == null || attachments.Length == 0))
throw new ArgumentException("Cannot post nothing. Content and/or attachments must be provided.");
MemoryStream mem = new MemoryStream();
{
Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true });
writer.WriteStartObject();
if (content != null)
{
writer.WritePropertyName("status");
writer.WriteStringValue(content);
writer.WritePropertyName("content-type");
writer.WriteStringValue("text/plain");
}
if (sensitive)
{
writer.WritePropertyName("sensitive");
writer.WriteBooleanValue(true);
}
if (expiresIn.HasValue)
{
writer.WritePropertyName("expires_in");
writer.WriteNumberValue(expiresIn.Value);
}
if (replyTo != null)
{
writer.WritePropertyName("in_reply_to_id");
writer.WriteStringValue(replyTo);
}
if (quoting != null)
{
writer.WritePropertyName("quote_id");
writer.WriteStringValue(quoting);
}
if (language != null)
{
writer.WritePropertyName("language");
writer.WriteStringValue(language);
}
if (attachments != null)
{
writer.WritePropertyName("media_ids");
writer.WriteStartArray();
foreach (MediaID media in attachments)
writer.WriteStringValue(media.ID);
writer.WriteEndArray();
}
if (visibility != StatusVisibility.Public)
{
writer.WritePropertyName("visibility");
writer.WriteStringValue(visibility.ToString().ToLowerInvariant());
}
writer.WriteEndObject();
writer.Flush();
}
return Retry(() =>
{
mem.Position = 0;
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses");
req.Content = new StreamContent(mem);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
})!;
}
static string CommonMIMEString(CommonMIMEs mime) => mime switch
{
CommonMIMEs.PNG => "image/png",
CommonMIMEs.JPEG => "image/jpeg",
CommonMIMEs.GIF => "image/gif",
CommonMIMEs.MP4 => "video/mp4",
CommonMIMEs.WEBM => "video/webm",
CommonMIMEs.MOV => "video/quicktime",
CommonMIMEs.WAV => "audio/vnd.wav",
CommonMIMEs.MP3 => "audio/mpeg",
CommonMIMEs.OGG => "audio/ogg",
CommonMIMEs.Text => "text/plain",
_ => throw new ArgumentException("Unknown common MIME", nameof(mime)),
};
///
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
///
/// The MIME type of the file.
/// An array containing the file's contents.
/// A handle of the uploaded file, including its ID and URLs.
public Task Upload(CommonMIMEs mime, byte[] file) => Upload(CommonMIMEString(mime), file);
///
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
///
/// The MIME type of the file.
/// An array containing the file's contents.
/// A handle of the uploaded file, including its ID and URLs.
public Task Upload(string mime, byte[] file) => Upload(mime, new ByteArrayContent(file));
///
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
///
/// The MIME type of the file.
/// A stream of the file's contents.
/// A handle of the uploaded file, including its ID and URLs.
public Task Upload(CommonMIMEs mime, Stream stream) => Upload(CommonMIMEString(mime), stream);
///
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
///
/// The MIME type of the file.
/// A stream of the file's contents.
/// A handle of the uploaded file, including its ID and URLs.
public Task Upload(string mime, Stream stream) => Upload(mime, new StreamContent(stream));
async Task Upload(string mime, HttpContent content)
{
return (await Retry(() =>
{
content.Headers.ContentType = new MediaTypeHeaderValue(mime);
content.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "\"file\"",
FileName = $"\"{Random.Shared.NextInt64()}\"",
};
MultipartFormDataContent form = [content];
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/media")
{
Content = form
};
return req;
}))!;
}
///
/// Reposts/boosts/shares a status.
///
/// The status to reblog.
/// The reblogged status, if it exists.
public Task Reblog(StatusID status, StatusVisibility? visibility = null)
{
MemoryStream mem = new MemoryStream();
{
Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true });
writer.WriteStartObject();
if (visibility.HasValue)
{
writer.WritePropertyName("visibility");
writer.WriteStringValue(visibility.Value.ToString().ToLowerInvariant());
}
writer.WriteEndObject();
writer.Flush();
}
return Retry(() =>
{
mem.Position = 0;
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/reblog");
req.Content = new StreamContent(mem);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return req;
});
}
///
/// Removes a reblog.
///
/// The reblogged status to undo.
/// The status, if it exists.
public Task Unreblog(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unreblog"))!;
///
/// Likes/favourites a status, adding it to the account's favourite list.
///
/// The status to favourite.
/// The favourited status, if it exists.
public Task Favourite(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/favourite"))!;
///
/// Unfavourites a favorited status, removing it from the account's favourite list.
///
/// The status to unfavourite.
/// The status, if it exists.
public Task Unfavourite(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unfavourite"))!;
///
/// Fetches the latest statuses from a standard timeline.
///
/// The timeline to fetch.
/// Show only local statuses?
/// Show only remote statuses?
/// Show only statuses from the given domain
/// Show only statuses with media attached?
/// Include activities by muted users
/// Exclude the statuses with the given visibilities
/// Filter replies.
/// Return items older than this ID
/// Return the oldest items newer than this ID
/// Return the newest items newer than this ID
/// Return items past this number of items
/// Maximum number of items to return. Will be ignored if it's more than 40
public Task GetTimeline(Timeline timeline,
bool local = false,
bool remote = false,
string? instance = null,
bool only_media = false,
bool with_muted = false,
StatusVisibility[]? exclude_visibilities = null,
ReplyVisibility reply_visibility = ReplyVisibility.All,
string? max_id = null,
string? min_id = null,
string? since_id = null,
int offset = 0,
int limit = 20)
{
string timelineName;
switch (timeline)
{
case Timeline.Direct:
timelineName = "direct";
break;
case Timeline.Home:
timelineName = "home";
break;
case Timeline.Local:
timelineName = "public";
local = true;
break;
case Timeline.Bubble:
timelineName = "bubble";
break;
case Timeline.Public:
timelineName = "public";
break;
default:
throw new ArgumentException("Invalid timeline", nameof(timeline));
}
return Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/timelines/{timelineName}" + CreateQuery(addPair =>
{
if (local) addPair("local", "true");
if (instance != null) addPair("instance", instance);
if (only_media) addPair("only_media", "true");
if (remote) addPair("remote", "true");
if (with_muted) addPair("with_muted", "true");
if (exclude_visibilities != null) addPair("exclude_visibilities", JsonSerializer.Serialize(exclude_visibilities));
if (reply_visibility != ReplyVisibility.All) addPair("reply_visibility", reply_visibility.ToString().ToLowerInvariant());
if (max_id != null) addPair("max_id", max_id);
if (min_id != null) addPair("min_id", min_id);
if (since_id != null) addPair("since_id", since_id);
if (offset > 0) addPair("offset", offset.ToString());
if (limit != 20) addPair("limit", limit.ToString());
})))!;
}
///
/// Fetches the latest statuses from a user's timeline.
///
/// The user to fetch the timeline of.
/// Include only pinned statuses
/// With tag
/// Show only statuses with media attached?
/// Include activities by muted users
/// Exclude reblogs
/// Exclude replies
/// Exclude the statuses with the given visibilities
/// Return items older than this ID
/// Return the oldest items newer than this ID
/// Return the newest items newer than this ID
/// Return items past this number of items
/// Maximum number of items to return. Will be ignored if it's more than 40
public Task GetTimeline(AccountID account,
bool pinned = false,
string? tagged = null,
bool only_media = false,
bool with_muted = false,
bool exclude_reblogs = false,
bool exclude_replies = false,
StatusVisibility[]? exclude_visibilities = null,
string? max_id = null,
string? min_id = null,
string? since_id = null,
int offset = 0,
int limit = 20)
{
return Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/statuses" + CreateQuery(addPair =>
{
if (pinned) addPair("pinned", "true");
if (tagged != null) addPair("tagged", tagged);
if (only_media) addPair("only_media", "true");
if (with_muted) addPair("with_muted", "true");
if (exclude_reblogs) addPair("exclude_reblogs", "true");
if (exclude_replies) addPair("exclude_replies", "true");
if (exclude_visibilities != null) addPair("exclude_visibilities", JsonSerializer.Serialize(exclude_visibilities));
if (max_id != null) addPair("max_id", max_id);
if (min_id != null) addPair("min_id", min_id);
if (since_id != null) addPair("since_id", since_id);
if (offset > 0) addPair("offset", offset.ToString());
if (limit != 20) addPair("limit", limit.ToString());
})));
}
///
/// Fetches the context of a status.
///
public Task GetContext(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/context"));
///
/// Fetches a status by ID.
///
public Task GetStatus(string id) => Retry(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(id)}"));
///
/// Deletes a status.
///
public Task Delete(StatusID status) => Retry(() => new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}"));
///
/// Searches for an accounts, hashtags, and/or statuses.
///
/// What to search for
/// If provided, statuses returned will be authored only by this account
/// Search type
/// Attempt WebFinger lookup
/// Only include accounts that the user is following
/// Return items older than this ID
/// Return the oldest items newer than this ID
/// Return the newest items newer than this ID
/// Return items past this number of items
/// Maximum number of items to return. Will be ignored if it's more than 40
public Task Search(string query,
string? account_id = null,
SearchType type = SearchType.All,
bool resolve = false,
bool following = false,
string? max_id = null,
string? min_id = null,
string? since_id = null,
int offset = 0,
int limit = 20)
{
return Retry(() => new HttpRequestMessage(HttpMethod.Get, "/api/v2/search" + CreateQuery(addPair =>
{
addPair("q", query);
if (account_id != null) addPair("account_id", account_id);
if (type != SearchType.All) addPair("type", type.ToString().ToLowerInvariant());
if (resolve) addPair("resolve", "true");
if (following) addPair("following", "true");
if (max_id != null) addPair("max_id", max_id);
if (min_id != null) addPair("min_id", min_id);
if (since_id != null) addPair("since_id", since_id);
if (offset > 0) addPair("offset", offset.ToString());
if (limit != 20) addPair("limit", limit.ToString());
})))!;
}
}