diff --git a/Pleroma.Test.sln b/Pleroma.Test.sln new file mode 100644 index 0000000..ffa733b --- /dev/null +++ b/Pleroma.Test.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Pleroma.Test", "Pleroma.Test\Pleroma.Test.csproj", "{38EB55E0-55FC-4D18-ADFC-28C3DC45DD97}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Pleroma", "Pleroma\Pleroma.csproj", "{9E897444-04AE-4063-9945-9964F502994F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HTTP", "HTTP\HTTP.csproj", "{7F622DB8-062D-4D21-8419-F3E5C7DAEC79}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {38EB55E0-55FC-4D18-ADFC-28C3DC45DD97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38EB55E0-55FC-4D18-ADFC-28C3DC45DD97}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38EB55E0-55FC-4D18-ADFC-28C3DC45DD97}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38EB55E0-55FC-4D18-ADFC-28C3DC45DD97}.Release|Any CPU.Build.0 = Release|Any CPU + {9E897444-04AE-4063-9945-9964F502994F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9E897444-04AE-4063-9945-9964F502994F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9E897444-04AE-4063-9945-9964F502994F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9E897444-04AE-4063-9945-9964F502994F}.Release|Any CPU.Build.0 = Release|Any CPU + {7F622DB8-062D-4D21-8419-F3E5C7DAEC79}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F622DB8-062D-4D21-8419-F3E5C7DAEC79}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F622DB8-062D-4D21-8419-F3E5C7DAEC79}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F622DB8-062D-4D21-8419-F3E5C7DAEC79}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {80040410-D102-4350-92BE-D3E98286B1C3} + EndGlobalSection +EndGlobal diff --git a/Pleroma.Test/Pleroma.Test.csproj b/Pleroma.Test/Pleroma.Test.csproj new file mode 100644 index 0000000..f319bc2 --- /dev/null +++ b/Pleroma.Test/Pleroma.Test.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + disable + enable + Uwaa.Pleroma.Test + Uwaa.Pleroma.Test + + + + + + diff --git a/Pleroma.Test/Program.cs b/Pleroma.Test/Program.cs new file mode 100644 index 0000000..64b0644 --- /dev/null +++ b/Pleroma.Test/Program.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; + +namespace Uwaa.Pleroma.Test; + +static class Program +{ + static void Main(string[] args) + { + MainInline().Wait(); + } + + static async Task MainInline() + { + Pleroma client = new Pleroma("alpine1.local", "Bearer Y52OX25r7rp3Lyqa-oTibk5_4sLapEKBIsxa5vWMRtw"); + Account account = await client.GetAccount(); + Status[] statuses = await client.GetTimeline(); + + Console.WriteLine($"Account: {account} ({account.ID})"); + Console.WriteLine("Public statuses:"); + foreach (Status status in statuses) + { + Console.Write('\t'); + Console.Write(status.CreatedAt.ToShortDateString()); + Console.Write(' '); + Console.Write(status.CreatedAt.ToLongTimeString()); + Console.Write(' '); + Console.WriteLine(status); + } + Console.ReadKey(); + } +} diff --git a/Pleroma.sln b/Pleroma.sln new file mode 100644 index 0000000..bacad6e --- /dev/null +++ b/Pleroma.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35027.167 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Pleroma", "Pleroma\Pleroma.csproj", "{2BDD2FEF-2B99-47AD-9C3B-B380DD8E6BB9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HTTP", "HTTP\HTTP.csproj", "{8986A015-8FF2-42E3-9D74-58C8A1BABC44}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2BDD2FEF-2B99-47AD-9C3B-B380DD8E6BB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BDD2FEF-2B99-47AD-9C3B-B380DD8E6BB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BDD2FEF-2B99-47AD-9C3B-B380DD8E6BB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BDD2FEF-2B99-47AD-9C3B-B380DD8E6BB9}.Release|Any CPU.Build.0 = Release|Any CPU + {8986A015-8FF2-42E3-9D74-58C8A1BABC44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8986A015-8FF2-42E3-9D74-58C8A1BABC44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8986A015-8FF2-42E3-9D74-58C8A1BABC44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8986A015-8FF2-42E3-9D74-58C8A1BABC44}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8D29C553-2975-44E6-9862-43D1D9ABEB75} + EndGlobalSection +EndGlobal diff --git a/Pleroma/API/PublishStatus.cs b/Pleroma/API/PublishStatus.cs new file mode 100644 index 0000000..7e4bb1c --- /dev/null +++ b/Pleroma/API/PublishStatus.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma.API; + +[JsonConverter(typeof(PublishStatusConverter))] +public class PublishStatus +{ + public string Content { get; set; } + + public bool Sensitive { get; set; } + + public int? ExpiresIn { get; set; } = null!; + + public string? ReplyTo { get; set; } + + public StatusVisibility Visibility { get; set; } = StatusVisibility.Public; + + /// + /// Called after the status has been successfully published. + /// + [JsonIgnore] + public Action? OnPublish; + + public PublishStatus(string content) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + } +} + +class PublishStatusConverter : JsonConverter +{ + public sealed override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(PublishStatus); + + public override PublishStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, PublishStatus status, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WritePropertyName("status"); + writer.WriteStringValue(status.Content); + + writer.WritePropertyName("content-type"); + writer.WriteStringValue("text/plain"); + + if (status.Sensitive) + { + writer.WritePropertyName("sensitive"); + writer.WriteBooleanValue(true); + } + + if (status.ExpiresIn.HasValue) + { + writer.WritePropertyName("expires_in"); + writer.WriteNumberValue(status.ExpiresIn.Value); + } + + if (status.ReplyTo != null) + { + writer.WritePropertyName("in_reply_to_id"); + writer.WriteStringValue(status.ReplyTo); + } + + if (status.Visibility != StatusVisibility.Public) + { + writer.WritePropertyName("visibility"); + writer.WriteStringValue(status.Visibility.ToString().ToLowerInvariant()); + } + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/Pleroma/Models/Account.cs b/Pleroma/Models/Account.cs new file mode 100644 index 0000000..0ac1b11 --- /dev/null +++ b/Pleroma/Models/Account.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class Account +{ + [JsonPropertyName("id")] + public string ID { get; set; } = null!; + + [JsonPropertyName("bot")] + public bool Bot { get; set; } + + [JsonPropertyName("display_name")] + public string DisplayName { get; set; } = null!; + + [JsonPropertyName("username")] + public string Username { get; set; } = null!; + + [JsonPropertyName("statuses_count")] + public uint StatusesCount { get; set; } + + [JsonPropertyName("url")] + public string URL { get; set; } = null!; + + [JsonPropertyName("followers_count")] + public uint Followers { get; set; } + + [JsonPropertyName("following_count")] + public uint Following { get; set; } + + public override string ToString() => $"@{Username}"; +} diff --git a/Pleroma/Models/Context.cs b/Pleroma/Models/Context.cs new file mode 100644 index 0000000..6d68f57 --- /dev/null +++ b/Pleroma/Models/Context.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class Context +{ + [JsonPropertyName("ancestors")] + public Status[] Ancestors { get; set; } = null!; + + [JsonPropertyName("descendants")] + public Status[] Descendants { get; set; } = null!; +} \ No newline at end of file diff --git a/Pleroma/Models/PleromaException.cs b/Pleroma/Models/PleromaException.cs new file mode 100644 index 0000000..c92c71d --- /dev/null +++ b/Pleroma/Models/PleromaException.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class PleromaException : Exception +{ + [JsonPropertyName("error")] + public string Text { get; set; } = null!; + + public override string Message => Text; + + public override string ToString() => Text; +} diff --git a/Pleroma/Models/PleromaObject.cs b/Pleroma/Models/PleromaObject.cs new file mode 100644 index 0000000..f8d51b0 --- /dev/null +++ b/Pleroma/Models/PleromaObject.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class PleromaObject +{ + [JsonPropertyName("content")] + public Content Content { get; set; } = null!; + + [JsonPropertyName("local")] + public bool Local { get; set; } +} + +public class Content +{ + [JsonPropertyName("text/plain")] + public string Plain { get; set; } = null!; +} \ No newline at end of file diff --git a/Pleroma/Models/Status.cs b/Pleroma/Models/Status.cs new file mode 100644 index 0000000..925266a --- /dev/null +++ b/Pleroma/Models/Status.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace Uwaa.Pleroma; + +public class Status +{ + [JsonPropertyName("id")] + public string ID { get; set; } = null!; + + [JsonPropertyName("content")] + public string HtmlContent { get; set; } = null!; + + [JsonPropertyName("account")] + public Account Account { get; set; } = null!; + + [JsonPropertyName("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonPropertyName("pleroma")] + public PleromaObject Pleroma { get; set; } = null!; + + [JsonPropertyName("in_reply_to_account_id")] + public string? ReplyToAccount { get; set; } = null; + + [JsonPropertyName("in_reply_to_id")] + public string? ReplyToStatus { get; set; } = null; + + [JsonPropertyName("mentions")] + public Mention[] Mentions { get; set; } = Array.Empty(); + + [JsonPropertyName("language")] + public string? Language { get; set; } + + [JsonPropertyName("pinned")] + public bool Pinned { get; set; } + + [JsonIgnore] + public string Content => Pleroma?.Content.Plain ?? HtmlContent; + + public bool CheckMention(string id) + { + return ReplyToAccount == id || Mentions.Any(m => m.ID == id); + } + + public override string ToString() => $"{Account?.Username ?? "unknown"}: \"{Content}\""; +} + +public class Mention +{ + public static implicit operator string(Mention mention) => mention.ID; + + [JsonPropertyName("id")] + public string ID { get; set; } = null!; +} \ No newline at end of file diff --git a/Pleroma/Pleroma.cs b/Pleroma/Pleroma.cs new file mode 100644 index 0000000..cc859f9 --- /dev/null +++ b/Pleroma/Pleroma.cs @@ -0,0 +1,156 @@ +using System.Text.Json; +using System.Threading.Tasks; +using Uwaa.HTTP; +using Uwaa.Pleroma.API; + +namespace Uwaa.Pleroma; + +/// +/// A pleroma client. +/// +public class Pleroma +{ + static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + }; + + static readonly MIMEType JsonMIMEType = new("application", "json"); + + + /// + /// The hostname of the pleroma instance. + /// + public string Host; + + /// + /// The full token, including the "Bearer" string. + /// + public string Authorization; + + /// + /// The user agent string. + /// + public string UserAgent = "Uwaa.Pleroma/0.0"; + + public Pleroma(string host, string authorization) + { + Host = host; + Authorization = authorization; + } + + 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) + { + try + { + PleromaException? err = JsonSerializer.Deserialize(text, SerializerOptions); + if (err != null && err.Text != null) + 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) + { + HttpRequest req = new HttpRequest(HttpMethod.POST, "/api/v1/statuses"); + req.Content = new HttpContent(JsonMIMEType, JsonSerializer.SerializeToUtf8Bytes(status, SerializerOptions)); + req.Fields.Accept = [JsonMIMEType]; + return RequestJSON(req)!; + } + + /// + /// Fetches the latest statuses from the public timeline. + /// + public Task GetTimeline() + { + //TODO: Parameters and selecting different timelines (home, public, bubble) + HttpRequest req = new HttpRequest(HttpMethod.GET, "/api/v1/timelines/public"); + req.Fields.Accept = [ JsonMIMEType ]; + return RequestJSON(req)!; + } + + /// + /// 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) + { + HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{account_id}/statuses"); + req.Fields.Accept = [JsonMIMEType]; + return RequestJSON(req)!; + } + + /// + /// Fetches the account of the pleroma client. + /// + public Task GetAccount() + { + HttpRequest req = new HttpRequest(HttpMethod.GET, "/api/v1/accounts/verify_credentials"); + req.Fields.Accept = [JsonMIMEType]; + return RequestJSON(req)!; + } + + /// + /// Fetches an account. + /// + public Task GetAccount(string id) + { + HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{id}"); + req.Fields.Accept = [JsonMIMEType]; + return RequestJSON(req); + } + + /// + /// 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) + { + HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/statuses/{status_id}/context"); + req.Fields.Accept = [JsonMIMEType]; + return RequestJSON(req); + } +} diff --git a/Pleroma/Pleroma.csproj b/Pleroma/Pleroma.csproj new file mode 100644 index 0000000..0295dd5 --- /dev/null +++ b/Pleroma/Pleroma.csproj @@ -0,0 +1,20 @@ + + + net8.0 + disable + enable + Uwaa.Pleroma + Uwaa.Pleroma + + + + + + + + + + + + + diff --git a/Pleroma/README.md b/Pleroma/README.md new file mode 100644 index 0000000..9326b60 --- /dev/null +++ b/Pleroma/README.md @@ -0,0 +1 @@ +Simple pleroma API client for .NET \ No newline at end of file diff --git a/Pleroma/StatusVisibility.cs b/Pleroma/StatusVisibility.cs new file mode 100644 index 0000000..370169a --- /dev/null +++ b/Pleroma/StatusVisibility.cs @@ -0,0 +1,10 @@ +namespace Uwaa.Pleroma; + +public enum StatusVisibility +{ + Public, + Unlisted, + Private, + Local, + Direct, +}