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(HttpRequestMessage req) { while (true) { HttpResponseMessage res = await HttpClient.SendAsync(req); 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)400 and < (HttpStatusCode)600) { try { PleromaException? 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 an error } } 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")); /// /// 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(); } 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 Retry(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. /// Visibility of the posted status. /// Thrown if something goes wrong while uploading the status. /// The status if posting was successful. public Task PostStatus(string content, bool sensitive = false, int? expiresIn = null, string? replyTo = null, string? quoting = null, string? language = null, StatusVisibility visibility = StatusVisibility.Public) { MemoryStream mem = new MemoryStream(); { Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true }); writer.WriteStartObject(); 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 (visibility != StatusVisibility.Public) { writer.WritePropertyName("visibility"); writer.WriteStringValue(visibility.ToString().ToLowerInvariant()); } writer.WriteEndObject(); writer.Flush(); } 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 Retry(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(); } 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 Retry(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. /// public Task GetTimeline(Timeline timeline, bool local = false, string? instance = null, bool only_media = false, bool remote = false, bool with_muted = false, StatusVisibility[]? exclude_visibilities = null, ReplyVisibility? reply_visibility = null, 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.HasValue) addPair("reply_visibility", reply_visibility.Value.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. /// 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) { HttpRequestMessage req = 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()); })); return Retry(req)!; } }