pleroma: use System.Net.Http

This commit is contained in:
uwaa 2024-12-16 03:50:54 +00:00
parent 8932c05860
commit 40459b9ad8
5 changed files with 161 additions and 256 deletions

View file

@ -7,8 +7,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Pleroma.Test", "Pleroma.Tes
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
@ -23,10 +21,6 @@ Global
{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

View file

@ -12,13 +12,7 @@ static class Program
static async Task MainInline()
{
Pleroma client = new Pleroma("localhost", "Bearer abcdefghijklmnopqrstuvwxyz");
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)
static void PrintStatus(Status status)
{
Console.Write('\t');
Console.Write(status.CreatedAt.ToShortDateString());
@ -27,6 +21,30 @@ static class Program
Console.Write(' ');
Console.WriteLine(status);
}
Pleroma client = new Pleroma("localhost", "Bearer abcdefghijklmnopqrstuvwxyz");
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)
PrintStatus(status);
Console.WriteLine();
SearchResults search = await client.Search("bepis", type: SearchType.Statuses, limit: 10);
if (search.Statuses == null || search.Statuses.Length == 0)
{
Console.WriteLine("No search results");
}
else
{
Console.WriteLine("Search results for \"bepis\":");
foreach (Status status in search.Statuses)
PrintStatus(status);
}
Console.ReadKey();
}
}

View file

@ -1,110 +0,0 @@
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>
/// Called after the status has been successfully published.
/// </summary>
[JsonIgnore]
public Action? OnPublish;
public PublishStatus(string content)
{
Content = content ?? throw new ArgumentNullException(nameof(content));
}
}
class PublishStatusConverter : JsonConverter<PublishStatus>
{
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.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");
writer.WriteStringValue(status.Visibility.ToString().ToLowerInvariant());
}
writer.WriteEndObject();
}
}

View file

@ -1,8 +1,11 @@
using Uwaa.HTTP;
using Uwaa.Pleroma.API;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace Uwaa.Pleroma;
delegate void AddPair(string key, string value);
/// <summary>
/// A pleroma client.
/// </summary>
@ -11,101 +14,63 @@ public class Pleroma
static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
static readonly MIMEType JsonMIMEType = new("application", "json");
static string CreateQuery(Action<AddPair> generator)
{
StringBuilder sb = new StringBuilder();
void addPair(string key, string value)
{
if (sb.Length == 0)
sb.Append('?');
else
sb.Append('&');
sb.Append(WebUtility.UrlEncode(key));
sb.Append('=');
sb.Append(WebUtility.UrlEncode(value));
}
generator(addPair);
return sb.ToString();
}
/// <summary>
/// The hostname of the pleroma instance.
/// </summary>
public string Host;
/// <summary>
/// The full token, including the "Bearer" string.
/// </summary>
public string Authorization;
/// <summary>
/// The user agent string.
/// </summary>
public string UserAgent = "Uwaa.Pleroma/0.0";
public Pleroma(string host, string authorization)
public HttpClient HttpClient;
public Pleroma(string host, string? authorization)
{
Host = host;
Authorization = authorization;
UriBuilder builder = new UriBuilder();
builder.Scheme = "https";
builder.Host = host;
HttpClient = new HttpClient();
HttpClient.DefaultRequestHeaders.Add("User-agent", UserAgent);
HttpClient.BaseAddress = builder.Uri;
HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (authorization != null)
HttpClient.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authorization);
}
async Task RequestJSON(HttpRequest req)
async Task<T?> Retry<T>(HttpRequestMessage req)
{
req.Fields.UserAgent = UserAgent;
req.Fields.Authorization = Authorization;
while (true)
{
HttpResponse res = await HttpClient.Request(Host, true, req);
HttpResponseMessage res = await HttpClient.SendAsync(req);
if (res.StatusCode == 404)
return;
if (res.StatusCode == HttpStatusCode.NotFound)
return default;
if (res.Content.HasValue)
{
if (res.Fields.ContentType.HasValue && !res.Fields.ContentType.Value.Match(JsonMIMEType))
throw new HttpException(res.Content.Value.AsText);
if (res.Content == null)
throw new HttpRequestException("Server responded with no content");
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
}
}
}
string text = await res.Content.ReadAsStringAsync();
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;
req.Fields.Accept = [JsonMIMEType];
while (true)
{
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)
if (res.StatusCode is >= (HttpStatusCode)400 and < (HttpStatusCode)600)
{
try
{
@ -129,10 +94,10 @@ public class Pleroma
}
}
if (res.StatusCode is >= 200 and < 300)
return JsonSerializer.Deserialize<T>(text, SerializerOptions) ?? throw new HttpException("Couldn't deserialize response");
if (res.StatusCode is >= (HttpStatusCode)200 and < (HttpStatusCode)300)
return JsonSerializer.Deserialize<T>(text, SerializerOptions) ?? throw new HttpRequestException("Couldn't deserialize response");
else
throw new HttpException("Unknown error occurred");
throw new HttpRequestException("Unknown error occurred");
}
}
@ -140,16 +105,74 @@ public class Pleroma
/// Posts a status.
/// </summary>
/// <param name="status">The status data to send, including the content, visibility, etc.</param>
/// <exception cref="HttpException">Thrown if something goes wrong while uploading the status.</exception>
/// <exception cref="HttpRequestException">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)
public Task<Status> PostStatus(string content,
bool sensitive = false,
int? expiresIn = null,
string? replyTo = null,
string? quoting = null,
string? language = null,
StatusVisibility visibility = StatusVisibility.Public)
{
HttpRequest req = new HttpRequest(HttpMethod.POST, "/api/v1/statuses");
req.Content = new HttpContent(JsonMIMEType, JsonSerializer.SerializeToUtf8Bytes(status, SerializerOptions));
req.Fields.Accept = [JsonMIMEType];
Status result = (await RequestJSON<Status>(req))!;
status.OnPublish?.Invoke();
return result;
MemoryStream mem = new MemoryStream();
{
Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true });
writer.WriteStartObject();
writer.WritePropertyName("status");
writer.WriteStringValue(content);
writer.WritePropertyName("content-type");
writer.WriteStringValue("text/plain");
if (sensitive)
{
writer.WritePropertyName("sensitive");
writer.WriteBooleanValue(true);
}
if (expiresIn.HasValue)
{
writer.WritePropertyName("expires_in");
writer.WriteNumberValue(expiresIn.Value);
}
if (replyTo != null)
{
writer.WritePropertyName("in_reply_to_id");
writer.WriteStringValue(replyTo);
}
if (quoting != null)
{
writer.WritePropertyName("quote_id");
writer.WriteStringValue(quoting);
}
if (language != null)
{
writer.WritePropertyName("language");
writer.WriteStringValue(language);
}
if (visibility != StatusVisibility.Public)
{
writer.WritePropertyName("visibility");
writer.WriteStringValue(visibility.ToString().ToLowerInvariant());
}
writer.WriteEndObject();
writer.Flush();
}
mem.Position = 0;
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/statuses");
req.Content = new StreamContent(mem);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return Retry<Status>(req)!;
}
/// <summary>
@ -158,11 +181,11 @@ public class Pleroma
public Task<Status[]> 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<Status[]>(req)!;
return Retry<Status[]>(new HttpRequestMessage(HttpMethod.Get, "/api/v1/timelines/public"))!;
}
/// <summary>
/// Fetches the latest statuses from a user's timeline.
/// </summary>
@ -174,9 +197,7 @@ public class Pleroma
/// </summary>
public Task<Status[]> GetTimeline(string account_id)
{
HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{account_id}/statuses");
req.Fields.Accept = [JsonMIMEType];
return RequestJSON<Status[]>(req)!;
return Retry<Status[]>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{account_id}/statuses"))!;
}
/// <summary>
@ -184,9 +205,7 @@ public class Pleroma
/// </summary>
public Task<Account> GetAccount()
{
HttpRequest req = new HttpRequest(HttpMethod.GET, "/api/v1/accounts/verify_credentials");
req.Fields.Accept = [JsonMIMEType];
return RequestJSON<Account>(req)!;
return Retry<Account>(new HttpRequestMessage(HttpMethod.Get, "/api/v1/accounts/verify_credentials"))!;
}
/// <summary>
@ -194,9 +213,7 @@ public class Pleroma
/// </summary>
public Task<Account?> GetAccount(string id)
{
HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/accounts/{id}");
req.Fields.Accept = [JsonMIMEType];
return RequestJSON<Account>(req);
return Retry<Account?>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/accounts/{id}"));
}
/// <summary>
@ -210,9 +227,7 @@ public class Pleroma
/// </summary>
public Task<Context?> GetContext(string status_id)
{
HttpRequest req = new HttpRequest(HttpMethod.GET, $"/api/v1/statuses/{status_id}/context");
req.Fields.Accept = [JsonMIMEType];
return RequestJSON<Context>(req);
return Retry<Context?>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{status_id}/context"));
}
/// <summary>
@ -220,9 +235,7 @@ public class Pleroma
/// </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);
return Retry<Status?>(new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{status_id}"));
}
/// <summary>
@ -236,9 +249,7 @@ public class Pleroma
/// </summary>
public Task Delete(string status_id)
{
HttpRequest req = new HttpRequest(HttpMethod.DELETE, $"/api/v1/statuses/{status_id}");
req.Fields.Accept = [JsonMIMEType];
return RequestJSON(req);
return Retry<object?>(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/statuses/{status_id}"));
}
/// <summary>
@ -266,18 +277,19 @@ public class Pleroma
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)!;
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Get, "/api/v2/search" + CreateQuery(addPair =>
{
addPair("q", query);
if (account_id != null) addPair("account_id", account_id);
if (type != SearchType.All) addPair("type", type.ToString().ToLowerInvariant());
if (resolve) addPair("resolve", "true");
if (following) addPair("following", "true");
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());
}));
return Retry<SearchResults>(req)!;
}
}

View file

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ImplicitUsings>disable</ImplicitUsings>
<ImplicitUsings>true</ImplicitUsings>
<RootNamespace>Uwaa.Pleroma</RootNamespace>
<AssemblyName>Uwaa.Pleroma</AssemblyName>
</PropertyGroup>
@ -8,15 +8,6 @@
<Import Project="../Common.targets"/>
<ItemGroup>
<ProjectReference Include="..\HTTP\HTTP.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="System" />
<Using Include="System.Collections.Generic" />
<Using Include="System.IO" />
<Using Include="System.Linq" />
<Using Include="System.Threading.Tasks" />
<Using Include="System.Text.Json" />
<Using Include="System.Text.Json.Serialization" />
</ItemGroup>