diff --git a/Pleroma.Test/Program.cs b/Pleroma.Test/Program.cs index 8bbc48e..bbc5162 100644 --- a/Pleroma.Test/Program.cs +++ b/Pleroma.Test/Program.cs @@ -24,7 +24,7 @@ static class Program Pleroma client = new Pleroma("localhost", "Bearer abcdefghijklmnopqrstuvwxyz"); Account account = await client.GetAccount(); - Status[] statuses = await client.GetTimeline(); + Status[] statuses = await client.GetTimeline(Timeline.Bubble); Console.WriteLine($"Account: {account} ({account.ID})"); Console.WriteLine("Public statuses:"); diff --git a/Pleroma/Models/Account.cs b/Pleroma/Models/Account.cs index dfa8384..22bc4f7 100644 --- a/Pleroma/Models/Account.cs +++ b/Pleroma/Models/Account.cs @@ -14,14 +14,8 @@ public class Account [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("fields")] + public AccountField[] Fields { get; set; } = Array.Empty(); [JsonPropertyName("followers_count")] public uint Followers { get; set; } @@ -29,5 +23,52 @@ public class Account [JsonPropertyName("following_count")] public uint Following { get; set; } + [JsonPropertyName("local")] + public bool Local { get; set; } + + [JsonPropertyName("locked")] + public bool Locked { get; set; } + + [JsonPropertyName("note")] + public string Bio { get; set; } = null!; + + [JsonPropertyName("statuses_count")] + public uint StatusesCount { get; set; } + + [JsonPropertyName("url")] + public string URL { get; set; } = null!; + + [JsonPropertyName("username")] + public string Username { get; set; } = null!; + public override string ToString() => $"@{Username}"; } + +[JsonConverter(typeof(AccountIDConverter))] +public readonly struct AccountID(string id) +{ + public static implicit operator AccountID(string id) => new AccountID(id); + + public static implicit operator AccountID(Account account) => new AccountID(account.ID); + + public static implicit operator AccountID(Relationship relationship) => new AccountID(relationship.ID); + + public readonly string ID = id; + + public override string ToString() => ID; +} + +class AccountIDConverter : JsonConverter +{ + public override bool CanConvert(Type type) => type.IsAssignableTo(typeof(AccountID)); + + public override AccountID Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new AccountID(reader.GetString() ?? throw new NullReferenceException("Expected a string, got null")); + } + + public override void Write(Utf8JsonWriter writer, AccountID value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ID); + } +} \ No newline at end of file diff --git a/Pleroma/Models/AccountField.cs b/Pleroma/Models/AccountField.cs new file mode 100644 index 0000000..e5f3911 --- /dev/null +++ b/Pleroma/Models/AccountField.cs @@ -0,0 +1,12 @@ +namespace Uwaa.Pleroma; + +public class AccountField +{ + [JsonPropertyName("name")] + public string Name { get; set; } = null!; + + [JsonPropertyName("value")] + public string Value { get; set; } = null!; + + public DateTime? VerifiedAt { get; set; } +} diff --git a/Pleroma/Models/Attachment.cs b/Pleroma/Models/Attachment.cs new file mode 100644 index 0000000..62f4ff1 --- /dev/null +++ b/Pleroma/Models/Attachment.cs @@ -0,0 +1,79 @@ +namespace Uwaa.Pleroma; + +public class Attachment +{ + /// + /// The ID of the attachment in the database. + /// + [JsonPropertyName("id")] + public string ID { get; set; } = null!; + + /// + /// Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// Additional pleroma-specific data. + /// + [JsonPropertyName("pleroma")] + public PleromaAttachmentData Pleroma { get; set; } = null!; + + /// + /// The location of a scaled-down preview of the attachment + /// + [JsonPropertyName("preview_url")] + public string PreviewURL { get; set; } = null!; + + /// + /// The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local + /// + [JsonPropertyName("remote_url")] + public string? RemoteURL { get; set; } + + /// + /// A shorter URL for the attachment + /// + [JsonPropertyName("text_url")] + public string? TextURL { get; set; } + + /// + /// The type of the attachment + /// + [JsonPropertyName("type")] + public AttachmentType Type { get; set; } + + /// + /// The location of the original full-size attachment + /// + [JsonPropertyName("url")] + public string URL { get; set; } = null!; +} + +[JsonConverter(typeof(EnumLowerCaseConverter))] +public enum AttachmentType +{ + Unknown, + Image, + Video, + Audio, +} + +/// +/// Additional pleroma-specific data for an . +/// +public class PleromaAttachmentData +{ + /// + /// MIME type of the attachment + /// + [JsonPropertyName("mime_type")] + public string Content { get; set; } = null!; + + /// + /// Name of the attachment, typically the filename + /// + [JsonPropertyName("name")] + public string Name { get; set; } = null!; +} \ No newline at end of file diff --git a/Pleroma/Models/PleromaObject.cs b/Pleroma/Models/PleromaObject.cs deleted file mode 100644 index afdffe3..0000000 --- a/Pleroma/Models/PleromaObject.cs +++ /dev/null @@ -1,16 +0,0 @@ -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/Relationship.cs b/Pleroma/Models/Relationship.cs new file mode 100644 index 0000000..4dbff5f --- /dev/null +++ b/Pleroma/Models/Relationship.cs @@ -0,0 +1,52 @@ +namespace Uwaa.Pleroma; + +/// +/// A relationship with another account. +/// +public class Relationship +{ + /// + /// 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!; + + [JsonPropertyName("blocked_by")] + public bool BlockedBy { get; set; } + + [JsonPropertyName("blocking")] + public bool Blocking { get; set; } + + [JsonPropertyName("domain_blocking")] + public bool DomainBlocking { get; set; } + + [JsonPropertyName("endorsed")] + public bool Endorsed { get; set; } + + [JsonPropertyName("followed_by")] + public bool FollowedBy { get; set; } + + [JsonPropertyName("following")] + public bool Following { get; set; } + + [JsonPropertyName("muting")] + public bool Muting { get; set; } + + [JsonPropertyName("muting_notifications")] + public bool MutingNotifications { get; set; } + + [JsonPropertyName("note")] + public string Note { get; set; } = null!; + + [JsonPropertyName("notifying")] + public bool Notifying { get; set; } + + [JsonPropertyName("requested")] + public bool Requested { get; set; } + + [JsonPropertyName("showing_reblogs")] + public bool ShowingReblogs { get; set; } + + [JsonPropertyName("subscribing")] + public bool Subscribing { get; set; } +} diff --git a/Pleroma/Models/Status.cs b/Pleroma/Models/Status.cs index 5bbe3a0..d3c6b9b 100644 --- a/Pleroma/Models/Status.cs +++ b/Pleroma/Models/Status.cs @@ -26,8 +26,17 @@ public class Status [JsonPropertyName("created_at")] public DateTime CreatedAt { get; set; } - [JsonPropertyName("pleroma")] - public PleromaObject Pleroma { get; set; } = null!; + /// + /// Have you favourited this status? + /// + [JsonPropertyName("favourited")] + public bool Favourited { get; set; } + + /// + /// How many favourites this status has received + /// + [JsonPropertyName("favourites_count")] + public int FavouriteCount { get; set; } /// /// ID of the account being replied to @@ -39,13 +48,7 @@ public class Status /// 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(); + public string? ReplyTo { get; set; } = null; /// /// Primary language of this status @@ -53,12 +56,84 @@ public class Status [JsonPropertyName("language")] public string? Language { get; set; } + /// + /// Media that is attached to this status + /// + [JsonPropertyName("media_attachments")] + public Attachment[] Attachments { get; set; } = Array.Empty(); + + /// + /// Mentions of users within the status content + /// + [JsonPropertyName("mentions")] + public Mention[] Mentions { get; set; } = Array.Empty(); + /// /// Have you pinned this status? Only appears if the status is pinnable. /// [JsonPropertyName("pinned")] public bool Pinned { get; set; } + /// + /// Additional pleroma-specific data. + /// + [JsonPropertyName("pleroma")] + public PleromaStatusData Pleroma { get; set; } = null!; + + /// + /// The status being reblogged + /// + [JsonPropertyName("reblog")] + public Status? Reblog { get; set; } + + /// + /// Have you boosted this status? + /// + [JsonPropertyName("reblogged")] + public bool Reblogged { get; set; } + + /// + /// How many boosts this status has received + /// + [JsonPropertyName("reblogs_count")] + public int ReblogCount { get; set; } + + /// + /// How many replies this status has received + /// + [JsonPropertyName("replies_count")] + public int ReplyCount { get; set; } + + /// + /// Is this status marked as sensitive content? + /// + [JsonPropertyName("sensitive")] + public bool Sensitive { get; set; } + + /// + /// Subject or summary line, below which status content is collapsed until expanded + /// + [JsonPropertyName("spoiler_text")] + public string SpoilerText { get; set; } = null!; + + /// + /// Hashtags included in the status. + /// + [JsonPropertyName("tags")] + public Hashtag[] Tags { get; set; } = Array.Empty(); + + /// + /// URI of the status used for federation + /// + [JsonPropertyName("uri")] + public string URI { get; set; } = null!; + + /// + /// A link to the status's HTML representation + /// + [JsonPropertyName("url")] + public string? URL { get; set; } + /// /// Visibility of this status /// @@ -71,7 +146,11 @@ public class Status [JsonIgnore] public string Content => Pleroma?.Content?.Plain ?? HtmlContent; - public bool CheckMention(string id) + /// + /// Returns true if a status mentions or replies to a user. + /// + /// The ID of the user. + public bool IsInteracting(string id) { return ReplyToAccount == id || Mentions.Any(m => m.ID == id); } @@ -109,3 +188,48 @@ public class Mention public override string ToString() => $"@{Account}"; } + +/// +/// Additional pleroma-specific data for a . +/// +public class PleromaStatusData +{ + [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!; +} + +[JsonConverter(typeof(StatusIDConverter))] +public readonly struct StatusID(string id) +{ + public static implicit operator StatusID(string id) => new StatusID(id); + + public static implicit operator StatusID(Status status) => new StatusID(status.ID); + + public readonly string ID = id; + + public override string ToString() => ID; +} + +class StatusIDConverter : JsonConverter +{ + public override bool CanConvert(Type type) => type.IsAssignableTo(typeof(StatusID)); + + public override StatusID Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return new StatusID(reader.GetString() ?? throw new NullReferenceException("Expected a string, got null")); + } + + public override void Write(Utf8JsonWriter writer, StatusID value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ID); + } +} \ No newline at end of file diff --git a/Pleroma/Pleroma.cs b/Pleroma/Pleroma.cs index 8f6a752..d6dc489 100644 --- a/Pleroma/Pleroma.cs +++ b/Pleroma/Pleroma.cs @@ -96,6 +96,80 @@ public class Pleroma } } + /// + /// 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. /// @@ -177,82 +251,166 @@ public class Pleroma } /// - /// Fetches the latest statuses from the public timeline. + /// Reposts/boosts/shares a status. /// - public Task GetTimeline() + /// The status to reblog. + /// The reblogged status, if it exists. + public Task Reblog(StatusID status, StatusVisibility? visibility = null) { - //TODO: Parameters and selecting different timelines (home, public, bubble) + MemoryStream mem = new MemoryStream(); - return Retry(new HttpRequestMessage(HttpMethod.Get, "/api/v1/timelines/public"))!; + { + 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); } - /// - /// Fetches the latest statuses from a user's timeline. + /// Removes a reblog. /// - public Task GetTimeline(Account account) - => GetTimeline(account.ID); + /// 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(string account_id) + 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/{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}")); + 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(Status status) - => GetContext(status.ID); + public Task GetContext(StatusID status) => Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/context")); /// - /// Fetches the context of a status. + /// Fetches a status by ID. /// - 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}")); - } + public Task GetStatus(string id) => Retry(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(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}")); - } + 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. diff --git a/Pleroma/ReplyVisibility.cs b/Pleroma/ReplyVisibility.cs new file mode 100644 index 0000000..2cbfc01 --- /dev/null +++ b/Pleroma/ReplyVisibility.cs @@ -0,0 +1,8 @@ +namespace Uwaa.Pleroma; + +[JsonConverter(typeof(EnumLowerCaseConverter))] +public enum ReplyVisibility +{ + Following, + Self, +} \ No newline at end of file diff --git a/Pleroma/Timeline.cs b/Pleroma/Timeline.cs new file mode 100644 index 0000000..36aad74 --- /dev/null +++ b/Pleroma/Timeline.cs @@ -0,0 +1,32 @@ +namespace Uwaa.Pleroma; + +/// +/// Special timelines. +/// +public enum Timeline +{ + /// + /// Statuses with a "direct" scope addressed to the account. + /// + Direct, + + /// + /// Statuses from followed users. + /// + Home, + + /// + /// Statuses from the local instance. + /// + Local, + + /// + /// Statuses from instances in the instance's bubble. + /// + Bubble, + + /// + /// All statuses visible to the instance. + /// + Public, +}