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"); } } /// /// Posts a status. /// /// The status data to send, including the content, visibility, etc. /// 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)!; } /// /// Fetches the latest statuses from the public timeline. /// public Task GetTimeline() { //TODO: Parameters and selecting different timelines (home, public, bubble) return Retry(new HttpRequestMessage(HttpMethod.Get, "/api/v1/timelines/public"))!; } /// /// Fetches the latest statuses from a user's timeline. /// public Task GetTimeline(Account account) => GetTimeline(account.ID); /// /// Fetches the latest statuses from a user's timeline. /// public Task GetTimeline(string account_id) { return Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{account_id}/statuses"))!; } /// /// Fetches the account of the pleroma client. /// public Task GetAccount() { return Retry(new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!; } /// /// Fetches an account. /// public Task GetAccount(string id) { return Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{id}")); } /// /// Fetches the context of a status. /// public Task GetContext(Status status) => GetContext(status.ID); /// /// Fetches the context of a status. /// public Task GetContext(string status_id) { return Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{status_id}/context")); } /// /// Fetches a status. /// public Task GetStatus(string status_id) { return Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{status_id}")); } /// /// Deletes a status. /// public Task Delete(Status status) => Delete(status.ID); /// /// Deletes a status by ID. /// public Task Delete(string status_id) { //TODO: Test return Retry(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{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)!; } }