pleroma: cleanups and stuff

This commit is contained in:
uwaa 2024-12-11 02:49:46 +00:00
parent f61957e19c
commit 39bf03ae95
10 changed files with 326 additions and 114 deletions

View file

@ -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();

View file

@ -6,14 +6,39 @@ namespace Uwaa.Pleroma.API;
[JsonConverter(typeof(PublishStatusConverter))]
public class PublishStatus
{
/// <summary>
/// Text content of the status.
/// </summary>
public string Content { get; set; }
/// <summary>
/// Mark status and attached media as sensitive?
/// </summary>
public bool Sensitive { get; set; }
/// <summary>
/// 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.
/// </summary>
public int? ExpiresIn { get; set; } = null!;
/// <summary>
/// ID of the status being replied to, if status is a reply
/// </summary>
public string? ReplyTo { get; set; }
/// <summary>
/// ID of the status being quoted.
/// </summary>
public string? Quoting { get; set; }
/// <summary>
/// ISO 639 language code for this status.
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Visibility of the posted status.
/// </summary>
public StatusVisibility Visibility { get; set; } = StatusVisibility.Public;
/// <summary>
@ -65,6 +90,18 @@ class PublishStatusConverter : JsonConverter<PublishStatus>
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");

View file

@ -0,0 +1,22 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Uwaa.Pleroma;
/// <summary>
/// Converts to and from enum values in lowercase.
/// </summary>
class EnumLowerCaseConverter<T> : JsonConverter<T> 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<T>(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());
}
}

View file

@ -4,6 +4,9 @@ namespace Uwaa.Pleroma;
public class Account
{
/// <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!;

20
Pleroma/Models/Hashtag.cs Normal file
View file

@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
namespace Uwaa.Pleroma;
public class Hashtag
{
/// <summary>
/// The value of the hashtag after the # sign
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = null!;
/// <summary>
/// A link to the hashtag on the instance
/// </summary>
[JsonPropertyName("url")]
public string URL { get; set; } = null!;
public override string ToString() => $"#{Name}";
}

View file

@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Uwaa.Pleroma;
public class SearchResults
{
/// <summary>
/// Accounts which match the search query.
/// </summary>
[JsonPropertyName("accounts")]
public Account[]? Accounts { get; set; }
/// <summary>
/// Hashtags which match the search query.
/// </summary>
[JsonPropertyName("hashtags")]
public Hashtag[]? Hashtags { get; set; }
/// <summary>
/// Statuses which match the search query.
/// </summary>
[JsonPropertyName("statuses")]
public Status[]? Statuses { get; set; }
}

View file

@ -4,36 +4,72 @@ namespace Uwaa.Pleroma;
public class Status
{
/// <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!;
/// <summary>
/// HTML-encoded status content
/// </summary>
[JsonPropertyName("content")]
public string HtmlContent { get; set; } = null!;
/// <summary>
/// The account that authored this status
/// </summary>
[JsonPropertyName("account")]
public Account Account { get; set; } = null!;
/// <summary>
/// The date when this status was created
/// </summary>
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("pleroma")]
public PleromaObject Pleroma { get; set; } = null!;
/// <summary>
/// ID of the account being replied to
/// </summary>
[JsonPropertyName("in_reply_to_account_id")]
public string? ReplyToAccount { get; set; } = null;
/// <summary>
/// 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>();
/// <summary>
/// Primary language of this status
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; set; }
/// <summary>
/// Have you pinned this status? Only appears if the status is pinnable.
/// </summary>
[JsonPropertyName("pinned")]
public bool Pinned { get; set; }
/// <summary>
/// Visibility of this status
/// </summary>
[JsonPropertyName("visibility")]
public StatusVisibility Visibility { get; set; }
/// <summary>
/// Plain text content of that status.
/// </summary>
[JsonIgnore]
public string Content => Pleroma?.Content?.Plain ?? HtmlContent;
@ -49,6 +85,29 @@ public class Mention
{
public static implicit operator string(Mention mention) => mention.ID;
/// <summary>
/// The webfinger acct: URI of the mentioned user. Equivalent to <c>username</c> for local users, or <c>username@domain</c> for remote users.
/// </summary>
[JsonPropertyName("acct")]
public string Account { get; set; } = null!;
/// <summary>
/// The account id of the mentioned user
/// </summary>
[JsonPropertyName("id")]
public string ID { get; set; } = null!;
/// <summary>
/// The location of the mentioned user's profile
/// </summary>
[JsonPropertyName("url")]
public string URL { get; set; } = null!;
/// <summary>
/// The username of the mentioned user
/// </summary>
[JsonPropertyName("username")]
public string Username { get; set; } = null!;
public override string ToString() => $"@{Account}";
}

View file

@ -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<T?> RequestJSONRetry<T>(HttpRequest req) where T : class
{
while (true)
{
try
{
return await RequestJSON<T>(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<PleromaException>(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<PleromaException>(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<T?> RequestJSON<T>(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<PleromaException>(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<T>(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<PleromaException>(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<T>(text, SerializerOptions) ?? throw new HttpException("Couldn't deserialize response");
else
throw new HttpException("Unknown error occurred");
}
}
/// <summary>
/// Posts a status.
/// </summary>
/// <param name="status">The status data to send, including the content, visibility, etc.</param>
/// <returns>The status, if posting was successful.</returns>
public Task<Status> PostStatus(PublishStatus status)
/// <exception cref="HttpException">Thrown if something goes wrong while uploading the status.</exception>
/// <returns>The status if posting was successful.</returns>
public async Task<Status> 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<Status>(req)!;
Status result = (await RequestJSON<Status>(req))!;
status.OnPublish?.Invoke();
return result;
}
/// <summary>
@ -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<Status[]>(req)!;
return RequestJSON<Status[]>(req)!;
}
/// <summary>
@ -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<Status[]>(req)!;
return RequestJSON<Status[]>(req)!;
}
/// <summary>
@ -204,7 +188,7 @@ public class Pleroma
{
HttpRequest req = new HttpRequest(HttpMethod.GET, "/api/v1/accounts/verify_credentials");
req.Fields.Accept = [JsonMIMEType];
return RequestJSONRetry<Account>(req)!;
return RequestJSON<Account>(req)!;
}
/// <summary>
@ -214,7 +198,7 @@ public class Pleroma
{
HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{id}");
req.Fields.Accept = [JsonMIMEType];
return RequestJSONRetry<Account>(req);
return RequestJSON<Account>(req);
}
/// <summary>
@ -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<Context>(req);
return RequestJSON<Context>(req);
}
/// <summary>
/// Fetches a status.
/// </summary>
public Task<Status?> GetStatus(string status_id)
{
HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/statuses/{status_id}");
req.Fields.Accept = [JsonMIMEType];
return RequestJSON<Status>(req);
}
/// <summary>
@ -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);
}
/// <summary>
/// Searches for an accounts, hashtags, and/or statuses.
/// </summary>
/// <param name="query">What to search for</param>
/// <param name="account_id">If provided, statuses returned will be authored only by this account</param>
/// <param name="type">Search type</param>
/// <param name="resolve">Attempt WebFinger lookup</param>
/// <param name="following">Only include accounts that the user is following</param>
/// <param name="max_id">Return items older than this ID</param>
/// <param name="min_id">Return the oldest items newer than this ID</param>
/// <param name="since_id">Return the newest items newer than this ID</param>
/// <param name="offset">Return items past this number of items</param>
/// <param name="limit">Maximum number of items to return. Will be ignored if it's more than 40</param>
/// <returns></returns>
public Task<SearchResults> 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<SearchResults>(req)!;
}
}

9
Pleroma/SearchType.cs Normal file
View file

@ -0,0 +1,9 @@
namespace Uwaa.Pleroma;
public enum SearchType
{
All,
Accounts,
Hashtags,
Statuses
}

View file

@ -1,10 +1,14 @@
namespace Uwaa.Pleroma;
using System.Text.Json.Serialization;
namespace Uwaa.Pleroma;
[JsonConverter(typeof(EnumLowerCaseConverter<StatusVisibility>))]
public enum StatusVisibility
{
Public,
Unlisted,
Private,
Local,
Private,
Direct,
List
}