From 39bf03ae9558c06168f879cbecdaa1ae59b423ee Mon Sep 17 00:00:00 2001 From: uwaa Date: Wed, 11 Dec 2024 02:49:46 +0000 Subject: [PATCH] pleroma: cleanups and stuff --- Pleroma.Test/Program.cs | 2 +- Pleroma/API/PublishStatus.cs | 37 +++++ Pleroma/EnumLowerCaseConverter.cs | 22 +++ Pleroma/Models/Account.cs | 3 + Pleroma/Models/Hashtag.cs | 20 +++ Pleroma/Models/SearchResults.cs | 24 +++ Pleroma/Models/Status.cs | 61 ++++++- Pleroma/Pleroma.cs | 254 +++++++++++++++++------------- Pleroma/SearchType.cs | 9 ++ Pleroma/StatusVisibility.cs | 8 +- 10 files changed, 326 insertions(+), 114 deletions(-) create mode 100644 Pleroma/EnumLowerCaseConverter.cs create mode 100644 Pleroma/Models/Hashtag.cs create mode 100644 Pleroma/Models/SearchResults.cs create mode 100644 Pleroma/SearchType.cs diff --git a/Pleroma.Test/Program.cs b/Pleroma.Test/Program.cs index 64b0644..1e93413 100644 --- a/Pleroma.Test/Program.cs +++ b/Pleroma.Test/Program.cs @@ -12,7 +12,7 @@ static class Program static async Task MainInline() { - Pleroma client = new Pleroma("alpine1.local", "Bearer Y52OX25r7rp3Lyqa-oTibk5_4sLapEKBIsxa5vWMRtw"); + Pleroma client = new Pleroma("localhost", "Bearer abcdefghijklmnopqrstuvwxyz"); Account account = await client.GetAccount(); Status[] statuses = await client.GetTimeline(); diff --git a/Pleroma/API/PublishStatus.cs b/Pleroma/API/PublishStatus.cs index 7e4bb1c..3df81e9 100644 --- a/Pleroma/API/PublishStatus.cs +++ b/Pleroma/API/PublishStatus.cs @@ -6,14 +6,39 @@ namespace Uwaa.Pleroma.API; [JsonConverter(typeof(PublishStatusConverter))] public class PublishStatus { + /// + /// Text content of the status. + /// public string Content { get; set; } + /// + /// Mark status and attached media as sensitive? + /// public bool Sensitive { get; set; } + /// + /// 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. + /// public int? ExpiresIn { get; set; } = null!; + /// + /// ID of the status being replied to, if status is a reply + /// public string? ReplyTo { get; set; } + /// + /// ID of the status being quoted. + /// + public string? Quoting { get; set; } + + /// + /// ISO 639 language code for this status. + /// + public string? Language { get; set; } + + /// + /// Visibility of the posted status. + /// public StatusVisibility Visibility { get; set; } = StatusVisibility.Public; /// @@ -65,6 +90,18 @@ class PublishStatusConverter : JsonConverter writer.WriteStringValue(status.ReplyTo); } + if (status.Quoting != null) + { + writer.WritePropertyName("quote_id"); + writer.WriteStringValue(status.Quoting); + } + + if (status.Language != null) + { + writer.WritePropertyName("language"); + writer.WriteStringValue(status.Language); + } + if (status.Visibility != StatusVisibility.Public) { writer.WritePropertyName("visibility"); diff --git a/Pleroma/EnumLowerCaseConverter.cs b/Pleroma/EnumLowerCaseConverter.cs new file mode 100644 index 0000000..038dfa7 --- /dev/null +++ b/Pleroma/EnumLowerCaseConverter.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +/// +/// Converts to and from enum values in lowercase. +/// +class EnumLowerCaseConverter : JsonConverter where T : struct +{ + public override bool CanConvert(Type typeToConvert) => typeToConvert.IsEnum; + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Enum.Parse(reader.GetString() ?? throw new NullReferenceException("Expected a string, got null"), true); + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()?.ToLowerInvariant()); + } +} \ No newline at end of file diff --git a/Pleroma/Models/Account.cs b/Pleroma/Models/Account.cs index 0ac1b11..e8e86c3 100644 --- a/Pleroma/Models/Account.cs +++ b/Pleroma/Models/Account.cs @@ -4,6 +4,9 @@ namespace Uwaa.Pleroma; public class Account { + /// + /// Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings + /// [JsonPropertyName("id")] public string ID { get; set; } = null!; diff --git a/Pleroma/Models/Hashtag.cs b/Pleroma/Models/Hashtag.cs new file mode 100644 index 0000000..d27c885 --- /dev/null +++ b/Pleroma/Models/Hashtag.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class Hashtag +{ + /// + /// The value of the hashtag after the # sign + /// + [JsonPropertyName("name")] + public string Name { get; set; } = null!; + + /// + /// A link to the hashtag on the instance + /// + [JsonPropertyName("url")] + public string URL { get; set; } = null!; + + public override string ToString() => $"#{Name}"; +} \ No newline at end of file diff --git a/Pleroma/Models/SearchResults.cs b/Pleroma/Models/SearchResults.cs new file mode 100644 index 0000000..e560572 --- /dev/null +++ b/Pleroma/Models/SearchResults.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class SearchResults +{ + /// + /// Accounts which match the search query. + /// + [JsonPropertyName("accounts")] + public Account[]? Accounts { get; set; } + + /// + /// Hashtags which match the search query. + /// + [JsonPropertyName("hashtags")] + public Hashtag[]? Hashtags { get; set; } + + /// + /// Statuses which match the search query. + /// + [JsonPropertyName("statuses")] + public Status[]? Statuses { get; set; } +} diff --git a/Pleroma/Models/Status.cs b/Pleroma/Models/Status.cs index b8ae5a5..a243972 100644 --- a/Pleroma/Models/Status.cs +++ b/Pleroma/Models/Status.cs @@ -4,36 +4,72 @@ namespace Uwaa.Pleroma; public class Status { + /// + /// Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings + /// [JsonPropertyName("id")] public string ID { get; set; } = null!; + /// + /// HTML-encoded status content + /// [JsonPropertyName("content")] public string HtmlContent { get; set; } = null!; + /// + /// The account that authored this status + /// [JsonPropertyName("account")] public Account Account { get; set; } = null!; + /// + /// The date when this status was created + /// [JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; } [JsonPropertyName("pleroma")] public PleromaObject Pleroma { get; set; } = null!; + /// + /// ID of the account being replied to + /// [JsonPropertyName("in_reply_to_account_id")] public string? ReplyToAccount { get; set; } = null; + /// + /// ID of the status being replied + /// [JsonPropertyName("in_reply_to_id")] public string? ReplyToStatus { get; set; } = null; + /// + /// Mentions of users within the status content + /// [JsonPropertyName("mentions")] public Mention[] Mentions { get; set; } = Array.Empty(); + /// + /// Primary language of this status + /// [JsonPropertyName("language")] public string? Language { get; set; } + /// + /// Have you pinned this status? Only appears if the status is pinnable. + /// [JsonPropertyName("pinned")] public bool Pinned { get; set; } + /// + /// Visibility of this status + /// + [JsonPropertyName("visibility")] + public StatusVisibility Visibility { get; set; } + + /// + /// Plain text content of that status. + /// [JsonIgnore] public string Content => Pleroma?.Content?.Plain ?? HtmlContent; @@ -49,6 +85,29 @@ public class Mention { public static implicit operator string(Mention mention) => mention.ID; + /// + /// The webfinger acct: URI of the mentioned user. Equivalent to username for local users, or username@domain for remote users. + /// + [JsonPropertyName("acct")] + public string Account { get; set; } = null!; + + /// + /// The account id of the mentioned user + /// [JsonPropertyName("id")] public string ID { get; set; } = null!; -} \ No newline at end of file + + /// + /// The location of the mentioned user's profile + /// + [JsonPropertyName("url")] + public string URL { get; set; } = null!; + + /// + /// The username of the mentioned user + /// + [JsonPropertyName("username")] + public string Username { get; set; } = null!; + + public override string ToString() => $"@{Account}"; +} diff --git a/Pleroma/Pleroma.cs b/Pleroma/Pleroma.cs index deee4cf..e1d7dcd 100644 --- a/Pleroma/Pleroma.cs +++ b/Pleroma/Pleroma.cs @@ -40,134 +40,118 @@ public class Pleroma Authorization = authorization; } - async Task RequestJSONRetry(HttpRequest req) - { - while (true) - { - try - { - await RequestJSON(req); - return; - } - catch (PleromaException e) - { - if (e.Text == "Throttled") - { - await Task.Delay(5000); - continue; - } - else - throw; - } - } - } - - async Task RequestJSONRetry(HttpRequest req) where T : class - { - while (true) - { - try - { - return await RequestJSON(req); - } - catch (PleromaException e) - { - if (e.Text == "Throttled") - { - await Task.Delay(5000); - continue; - } - else - throw; - } - } - } - async Task RequestJSON(HttpRequest req) { req.Fields.UserAgent = UserAgent; req.Fields.Authorization = Authorization; - - HttpResponse res = await HttpClient.Request(Host, true, req); - - if (res.StatusCode == 404) - return; - - if (!res.Fields.ContentType.HasValue || !res.Fields.ContentType.Value.Match(JsonMIMEType)) - throw new HttpException("Server did not respond with JSON" + (res.Content.HasValue ? ", got: " + res.Content.Value.AsText : null)); - - if (!res.Content.HasValue) - throw new HttpException("Server responded with no content"); - - string text = res.Content.Value.AsText; - - if (res.StatusCode is >= 400 and < 600) + while (true) { - try - { - PleromaException? err = JsonSerializer.Deserialize(text, SerializerOptions); - if (err != null && err.Text != null) - throw err; - } - catch (JsonException) - { - //Not an error - } - } + HttpResponse res = await HttpClient.Request(Host, true, req); - if (res.StatusCode is not >= 200 or not < 300) - throw new HttpException("Unknown error occurred"); + if (res.StatusCode == 404) + return; + + if (res.Content.HasValue) + { + if (res.Fields.ContentType.HasValue && !res.Fields.ContentType.Value.Match(JsonMIMEType)) + throw new HttpException(res.Content.Value.AsText); + + if (res.StatusCode is >= 400 and < 600) + { + try + { + string text = res.Content.Value.AsText; + 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 not >= 200 or not < 300) + throw new HttpException("Unknown error occurred"); + } } async Task RequestJSON(HttpRequest req) where T : class { req.Fields.UserAgent = UserAgent; req.Fields.Authorization = Authorization; - - HttpResponse res = await HttpClient.Request(Host, true, req); - - if (res.StatusCode == 404) - return null; - - if (!res.Fields.ContentType.HasValue || !res.Fields.ContentType.Value.Match(JsonMIMEType)) - throw new HttpException("Server did not respond with JSON" + (res.Content.HasValue ? ", got: " + res.Content.Value.AsText : null)); - - if (!res.Content.HasValue) - throw new HttpException("Server responded with no content"); - - string text = res.Content.Value.AsText; - - if (res.StatusCode is >= 400 and < 600) + req.Fields.Accept = [JsonMIMEType]; + while (true) { - try - { - PleromaException? err = JsonSerializer.Deserialize(text, SerializerOptions); - if (err != null && err.Text != null) - throw err; - } - catch (JsonException) - { - //Not an error - } - } + HttpResponse res = await HttpClient.Request(Host, true, req); - if (res.StatusCode is >= 200 and < 300) - return JsonSerializer.Deserialize(text, SerializerOptions) ?? throw new HttpException("Couldn't deserialize response"); - else - throw new HttpException("Unknown error occurred"); + if (res.StatusCode == 404) + return null; + + if (!res.Fields.ContentType.HasValue || !res.Fields.ContentType.Value.Match(JsonMIMEType)) + throw new HttpException("Server did not respond with JSON" + (res.Content.HasValue ? ", got: " + res.Content.Value.AsText : null)); + + if (!res.Content.HasValue) + throw new HttpException("Server responded with no content"); + + string text = res.Content.Value.AsText; + + if (res.StatusCode is >= 400 and < 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 >= 200 and < 300) + return JsonSerializer.Deserialize(text, SerializerOptions) ?? throw new HttpException("Couldn't deserialize response"); + else + throw new HttpException("Unknown error occurred"); + } } /// /// Posts a status. /// /// The status data to send, including the content, visibility, etc. - /// The status, if posting was successful. - public Task PostStatus(PublishStatus status) + /// Thrown if something goes wrong while uploading the status. + /// The status if posting was successful. + public async Task PostStatus(PublishStatus status) { HttpRequest req = new HttpRequest(HttpMethod.POST, "/api/v1/statuses"); req.Content = new HttpContent(JsonMIMEType, JsonSerializer.SerializeToUtf8Bytes(status, SerializerOptions)); req.Fields.Accept = [JsonMIMEType]; - return RequestJSONRetry(req)!; + Status result = (await RequestJSON(req))!; + status.OnPublish?.Invoke(); + return result; } /// @@ -178,7 +162,7 @@ public class Pleroma //TODO: Parameters and selecting different timelines (home, public, bubble) HttpRequest req = new HttpRequest(HttpMethod.GET, "/api/v1/timelines/public"); req.Fields.Accept = [ JsonMIMEType ]; - return RequestJSONRetry(req)!; + return RequestJSON(req)!; } /// @@ -194,7 +178,7 @@ public class Pleroma { HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{account_id}/statuses"); req.Fields.Accept = [JsonMIMEType]; - return RequestJSONRetry(req)!; + return RequestJSON(req)!; } /// @@ -204,7 +188,7 @@ public class Pleroma { HttpRequest req = new HttpRequest(HttpMethod.GET, "/api/v1/accounts/verify_credentials"); req.Fields.Accept = [JsonMIMEType]; - return RequestJSONRetry(req)!; + return RequestJSON(req)!; } /// @@ -214,7 +198,7 @@ public class Pleroma { HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{id}"); req.Fields.Accept = [JsonMIMEType]; - return RequestJSONRetry(req); + return RequestJSON(req); } /// @@ -230,7 +214,17 @@ public class Pleroma { HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/statuses/{status_id}/context"); req.Fields.Accept = [JsonMIMEType]; - return RequestJSONRetry(req); + return RequestJSON(req); + } + + /// + /// Fetches a status. + /// + public Task GetStatus(string status_id) + { + HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/statuses/{status_id}"); + req.Fields.Accept = [JsonMIMEType]; + return RequestJSON(req); } /// @@ -246,6 +240,46 @@ public class Pleroma { HttpRequest req = new HttpRequest(HttpMethod.DELETE, $"/api/v1/statuses/{status_id}"); req.Fields.Accept = [JsonMIMEType]; - return RequestJSONRetry(req); + return RequestJSON(req); + } + + /// + /// 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) + { + HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v2/search"); + req.Fields.Accept = [JsonMIMEType]; + req.Query["q"] = query; + if (account_id != null) req.Query["account_id"] = account_id; + if (type != SearchType.All) req.Query["type"] = type.ToString().ToLowerInvariant(); + if (resolve) req.Query["resolve"] = "true"; + if (following) req.Query["following"] = "true"; + if (max_id != null) req.Query["max_id"] = max_id; + if (min_id != null) req.Query["min_id"] = min_id; + if (since_id != null) req.Query["since_id"] = since_id; + if (offset > 0) req.Query["offset"] = offset.ToString(); + if (limit != 20) req.Query["limit"] = limit.ToString(); + return RequestJSON(req)!; } } diff --git a/Pleroma/SearchType.cs b/Pleroma/SearchType.cs new file mode 100644 index 0000000..5ada74f --- /dev/null +++ b/Pleroma/SearchType.cs @@ -0,0 +1,9 @@ +namespace Uwaa.Pleroma; + +public enum SearchType +{ + All, + Accounts, + Hashtags, + Statuses +} \ No newline at end of file diff --git a/Pleroma/StatusVisibility.cs b/Pleroma/StatusVisibility.cs index 370169a..00acd77 100644 --- a/Pleroma/StatusVisibility.cs +++ b/Pleroma/StatusVisibility.cs @@ -1,10 +1,14 @@ -namespace Uwaa.Pleroma; +using System.Text.Json.Serialization; +namespace Uwaa.Pleroma; + +[JsonConverter(typeof(EnumLowerCaseConverter))] public enum StatusVisibility { Public, Unlisted, - Private, Local, + Private, Direct, + List }