pleroma: cleanups and new stuff

This commit is contained in:
uwaa 2024-12-18 09:56:02 +00:00
parent c8f1924bd0
commit 460564b2f9
10 changed files with 576 additions and 86 deletions

View file

@ -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:");

View file

@ -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<AccountField>();
[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<AccountID>
{
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);
}
}

View file

@ -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; }
}

View file

@ -0,0 +1,79 @@
namespace Uwaa.Pleroma;
public class Attachment
{
/// <summary>
/// The ID of the attachment in the database.
/// </summary>
[JsonPropertyName("id")]
public string ID { get; set; } = null!;
/// <summary>
/// Alternate text that describes what is in the media attachment, to be used for the visually impaired or when media attachments do not load
/// </summary>
[JsonPropertyName("description")]
public string? Description { get; set; }
/// <summary>
/// Additional pleroma-specific data.
/// </summary>
[JsonPropertyName("pleroma")]
public PleromaAttachmentData Pleroma { get; set; } = null!;
/// <summary>
/// The location of a scaled-down preview of the attachment
/// </summary>
[JsonPropertyName("preview_url")]
public string PreviewURL { get; set; } = null!;
/// <summary>
/// The location of the full-size original attachment on the remote website. String (URL), or null if the attachment is local
/// </summary>
[JsonPropertyName("remote_url")]
public string? RemoteURL { get; set; }
/// <summary>
/// A shorter URL for the attachment
/// </summary>
[JsonPropertyName("text_url")]
public string? TextURL { get; set; }
/// <summary>
/// The type of the attachment
/// </summary>
[JsonPropertyName("type")]
public AttachmentType Type { get; set; }
/// <summary>
/// The location of the original full-size attachment
/// </summary>
[JsonPropertyName("url")]
public string URL { get; set; } = null!;
}
[JsonConverter(typeof(EnumLowerCaseConverter<AttachmentType>))]
public enum AttachmentType
{
Unknown,
Image,
Video,
Audio,
}
/// <summary>
/// Additional pleroma-specific data for an <see cref="Attachment"/>.
/// </summary>
public class PleromaAttachmentData
{
/// <summary>
/// MIME type of the attachment
/// </summary>
[JsonPropertyName("mime_type")]
public string Content { get; set; } = null!;
/// <summary>
/// Name of the attachment, typically the filename
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = null!;
}

View file

@ -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!;
}

View file

@ -0,0 +1,52 @@
namespace Uwaa.Pleroma;
/// <summary>
/// A relationship with another account.
/// </summary>
public class Relationship
{
/// <summary>
/// Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings
/// </summary>
[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; }
}

View file

@ -26,8 +26,17 @@ public class Status
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("pleroma")]
public PleromaObject Pleroma { get; set; } = null!;
/// <summary>
/// Have you favourited this status?
/// </summary>
[JsonPropertyName("favourited")]
public bool Favourited { get; set; }
/// <summary>
/// How many favourites this status has received
/// </summary>
[JsonPropertyName("favourites_count")]
public int FavouriteCount { get; set; }
/// <summary>
/// ID of the account being replied to
@ -39,13 +48,7 @@ public class Status
/// ID of the status being replied
/// </summary>
[JsonPropertyName("in_reply_to_id")]
public string? ReplyToStatus { get; set; } = null;
/// <summary>
/// Mentions of users within the status content
/// </summary>
[JsonPropertyName("mentions")]
public Mention[] Mentions { get; set; } = Array.Empty<Mention>();
public string? ReplyTo { get; set; } = null;
/// <summary>
/// Primary language of this status
@ -53,12 +56,84 @@ public class Status
[JsonPropertyName("language")]
public string? Language { get; set; }
/// <summary>
/// Media that is attached to this status
/// </summary>
[JsonPropertyName("media_attachments")]
public Attachment[] Attachments { get; set; } = Array.Empty<Attachment>();
/// <summary>
/// Mentions of users within the status content
/// </summary>
[JsonPropertyName("mentions")]
public Mention[] Mentions { get; set; } = Array.Empty<Mention>();
/// <summary>
/// Have you pinned this status? Only appears if the status is pinnable.
/// </summary>
[JsonPropertyName("pinned")]
public bool Pinned { get; set; }
/// <summary>
/// Additional pleroma-specific data.
/// </summary>
[JsonPropertyName("pleroma")]
public PleromaStatusData Pleroma { get; set; } = null!;
/// <summary>
/// The status being reblogged
/// </summary>
[JsonPropertyName("reblog")]
public Status? Reblog { get; set; }
/// <summary>
/// Have you boosted this status?
/// </summary>
[JsonPropertyName("reblogged")]
public bool Reblogged { get; set; }
/// <summary>
/// How many boosts this status has received
/// </summary>
[JsonPropertyName("reblogs_count")]
public int ReblogCount { get; set; }
/// <summary>
/// How many replies this status has received
/// </summary>
[JsonPropertyName("replies_count")]
public int ReplyCount { get; set; }
/// <summary>
/// Is this status marked as sensitive content?
/// </summary>
[JsonPropertyName("sensitive")]
public bool Sensitive { get; set; }
/// <summary>
/// Subject or summary line, below which status content is collapsed until expanded
/// </summary>
[JsonPropertyName("spoiler_text")]
public string SpoilerText { get; set; } = null!;
/// <summary>
/// Hashtags included in the status.
/// </summary>
[JsonPropertyName("tags")]
public Hashtag[] Tags { get; set; } = Array.Empty<Hashtag>();
/// <summary>
/// URI of the status used for federation
/// </summary>
[JsonPropertyName("uri")]
public string URI { get; set; } = null!;
/// <summary>
/// A link to the status's HTML representation
/// </summary>
[JsonPropertyName("url")]
public string? URL { get; set; }
/// <summary>
/// Visibility of this status
/// </summary>
@ -71,7 +146,11 @@ public class Status
[JsonIgnore]
public string Content => Pleroma?.Content?.Plain ?? HtmlContent;
public bool CheckMention(string id)
/// <summary>
/// Returns true if a status mentions or replies to a user.
/// </summary>
/// <param name="id">The ID of the user.</param>
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}";
}
/// <summary>
/// Additional pleroma-specific data for a <see cref="Status"/>.
/// </summary>
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<StatusID>
{
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);
}
}

View file

@ -96,6 +96,80 @@ public class Pleroma
}
}
/// <summary>
/// Fetches the account of the pleroma client.
/// </summary>
public Task<Account> GetAccount() => Retry<Account>(new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!;
/// <summary>
/// Fetches an account by ID.
/// </summary>
/// <param name="id">Account ID to fetch</param>
public Task<Account?> GetAccountByID(string id) => Retry<Account>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{WebUtility.UrlEncode(id)}"));
/// <summary>
/// Fetches an account by nickname.
/// </summary>
/// <param name="acct">User nickname</param>
public Task<Account?> GetAccountByName(string acct) => Retry<Account>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/lookup?acct={WebUtility.UrlEncode(acct)}"));
/// <summary>
/// Follows the given account.
/// </summary>
/// <param name="id">Account to follow</param>
/// <returns>The new relationship, if the provided account exists.</returns>
public Task<Relationship?> Follow(AccountID account) => Retry<Relationship>(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/follow"));
/// <summary>
/// Unfollows the given account.
/// </summary>
/// <param name="id">Account to unfollow</param>
/// <returns>The new state of the relationship, if the provided account exists.</returns>
public Task<Relationship?> Unfollow(AccountID account) => Retry<Relationship>(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/accounts/{WebUtility.UrlEncode(account.ID)}/unfollow"));
/// <summary>
/// Sets a private note for the given account.
/// </summary>
/// <param name="id">Account ID</param>
/// <param name="comment">Account note body</param>
/// <returns></returns>
public Task<Relationship?> 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<Relationship>(req)!;
}
/// <summary>
/// Gets the account's relationship with the given account.
/// </summary>
/// <param name="id">Account ID</param>
/// <returns>A relationship object for the requested account, if the account exists.</returns>
public async Task<Relationship?> GetRelationship(AccountID account)
{
Relationship[]? rels = await Retry<Relationship[]>(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];
}
/// <summary>
/// Posts a status.
/// </summary>
@ -177,82 +251,166 @@ public class Pleroma
}
/// <summary>
/// Fetches the latest statuses from the public timeline.
/// Reposts/boosts/shares a status.
/// </summary>
public Task<Status[]> GetTimeline()
/// <param name="status">The status to reblog.</param>
/// <returns>The reblogged status, if it exists.</returns>
public Task<Status?> Reblog(StatusID status, StatusVisibility? visibility = null)
{
//TODO: Parameters and selecting different timelines (home, public, bubble)
MemoryStream mem = new MemoryStream();
return Retry<Status[]>(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<Status>(req);
}
/// <summary>
/// Fetches the latest statuses from a user's timeline.
/// Removes a reblog.
/// </summary>
public Task<Status[]> GetTimeline(Account account)
=> GetTimeline(account.ID);
/// <param name="status">The reblogged status to undo.</param>
/// <returns>The status, if it exists.</returns>
public Task<Status?> Unreblog(StatusID status) => Retry<Status>(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unreblog"))!;
/// <summary>
/// Likes/favourites a status, adding it to the account's favourite list.
/// </summary>
/// <param name="status">The status to favourite.</param>
/// <returns>The favourited status, if it exists.</returns>
public Task<Status?> Favourite(StatusID status) => Retry<Status>(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/favourite"))!;
/// <summary>
/// Unfavourites a favorited status, removing it from the account's favourite list.
/// </summary>
/// <param name="status">The status to unfavourite.</param>
/// <returns>The status, if it exists.</returns>
public Task<Status?> Unfavourite(StatusID status) => Retry<Status>(new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unfavourite"))!;
/// <summary>
/// Fetches the latest statuses from a standard timeline.
/// </summary>
public Task<Status[]> 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<Status[]>(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());
})))!;
}
/// <summary>
/// Fetches the latest statuses from a user's timeline.
/// </summary>
public Task<Status[]> GetTimeline(string account_id)
public Task<Status[]?> 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<Status[]>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{account_id}/statuses"))!;
}
/// <summary>
/// Fetches the account of the pleroma client.
/// </summary>
public Task<Account> GetAccount()
{
return Retry<Account>(new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!;
}
/// <summary>
/// Fetches an account.
/// </summary>
public Task<Account?> GetAccount(string id)
{
return Retry<Account?>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{id}"));
return Retry<Status[]>(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());
})));
}
/// <summary>
/// Fetches the context of a status.
/// </summary>
public Task<Context?> GetContext(Status status)
=> GetContext(status.ID);
public Task<Context?> GetContext(StatusID status) => Retry<Context>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/context"));
/// <summary>
/// Fetches the context of a status.
/// Fetches a status by ID.
/// </summary>
public Task<Context?> GetContext(string status_id)
{
return Retry<Context?>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{status_id}/context"));
}
/// <summary>
/// Fetches a status.
/// </summary>
public Task<Status?> GetStatus(string status_id)
{
return Retry<Status?>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{status_id}"));
}
public Task<Status?> GetStatus(string id) => Retry<Status>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(id)}"));
/// <summary>
/// Deletes a status.
/// </summary>
public Task Delete(Status status)
=> Delete(status.ID);
/// <summary>
/// Deletes a status by ID.
/// </summary>
public Task Delete(string status_id)
{
//TODO: Test
return Retry<object?>(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{status_id}"));
}
public Task<Status?> Delete(StatusID status) => Retry<Status>(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}"));
/// <summary>
/// Searches for an accounts, hashtags, and/or statuses.

View file

@ -0,0 +1,8 @@
namespace Uwaa.Pleroma;
[JsonConverter(typeof(EnumLowerCaseConverter<ReplyVisibility>))]
public enum ReplyVisibility
{
Following,
Self,
}

32
Pleroma/Timeline.cs Normal file
View file

@ -0,0 +1,32 @@
namespace Uwaa.Pleroma;
/// <summary>
/// Special timelines.
/// </summary>
public enum Timeline
{
/// <summary>
/// Statuses with a "direct" scope addressed to the account.
/// </summary>
Direct,
/// <summary>
/// Statuses from followed users.
/// </summary>
Home,
/// <summary>
/// Statuses from the local instance.
/// </summary>
Local,
/// <summary>
/// Statuses from instances in the instance's bubble.
/// </summary>
Bubble,
/// <summary>
/// All statuses visible to the instance.
/// </summary>
Public,
}