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()); })))!; } }