961 lines
No EOL
42 KiB
C#
961 lines
No EOL
42 KiB
C#
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>
|
|
public class Pleroma
|
|
{
|
|
static readonly JsonSerializerOptions SerializerOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
|
};
|
|
|
|
internal 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();
|
|
}
|
|
|
|
|
|
public HttpClient HttpClient;
|
|
|
|
public Pleroma(string host, string? authorization, string? userAgent = "Uwaa.Pleroma/0.0")
|
|
{
|
|
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);
|
|
}
|
|
|
|
public Pleroma(HttpClient client)
|
|
{
|
|
HttpClient = client;
|
|
}
|
|
|
|
internal Task Retry(Func<HttpRequestMessage> reqFactory)
|
|
{
|
|
return Retry<object?>(reqFactory);
|
|
}
|
|
|
|
internal async Task<T?> Retry<T>(Func<HttpRequestMessage> reqFactory)
|
|
{
|
|
while (true)
|
|
{
|
|
HttpResponseMessage res = await HttpClient.SendAsync(reqFactory());
|
|
|
|
if (res.StatusCode == HttpStatusCode.NotFound)
|
|
return default;
|
|
|
|
if (res.Content == null)
|
|
{
|
|
if (typeof(T) == typeof(object))
|
|
return default;
|
|
else
|
|
throw new HttpRequestException("Server responded with no content");
|
|
}
|
|
|
|
string text = await res.Content.ReadAsStringAsync();
|
|
|
|
if (res.StatusCode is >= (HttpStatusCode)300)
|
|
{
|
|
try
|
|
{
|
|
PleromaSimpleException? err = JsonSerializer.Deserialize<PleromaSimpleException>(text, SerializerOptions);
|
|
if (err != null && err.Text != null)
|
|
{
|
|
if (err.Text == "Throttled")
|
|
{
|
|
await Task.Delay(5000);
|
|
continue; //Retry
|
|
}
|
|
else
|
|
{
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
//Not a simple error
|
|
}
|
|
|
|
try
|
|
{
|
|
PleromaAggregateException? err = JsonSerializer.Deserialize<PleromaAggregateException>(text, SerializerOptions);
|
|
if (err != null && err.Text != null)
|
|
throw err;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
//Not an aggregate error
|
|
}
|
|
|
|
throw new HttpRequestException(text);
|
|
}
|
|
|
|
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 HttpRequestException("Unknown error occurred");
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// Gets accounts which the given account is following, if network is not hidden by the account owner.
|
|
/// </summary>
|
|
/// <param name="account">Account to get the follows of.</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>
|
|
public Task<Account[]> GetFollowing(AccountID account,
|
|
string? max_id = null,
|
|
string? min_id = null,
|
|
string? since_id = null,
|
|
int offset = 0,
|
|
int limit = 20)
|
|
{
|
|
string path = $"/api/v1/accounts/{account.ID}/following" + CreateQuery(addPair =>
|
|
{
|
|
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<Account[]>(() => new HttpRequestMessage(HttpMethod.Get, path))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets a private note for the given account.
|
|
/// </summary>
|
|
/// <param name="account">Account ID</param>
|
|
/// <param name="comment">Account note body</param>
|
|
/// <returns>The new relationship with the given account.</returns>
|
|
public Task<Relationship?> SetUserNote(AccountID account, string comment)
|
|
{
|
|
MemoryStream mem = new MemoryStream();
|
|
using (Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true }))
|
|
{
|
|
writer.WriteStartObject();
|
|
|
|
writer.WritePropertyName("comment");
|
|
writer.WriteStringValue(comment);
|
|
|
|
writer.WriteEndObject();
|
|
writer.Flush();
|
|
}
|
|
|
|
return Retry<Relationship>(() =>
|
|
{
|
|
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 req;
|
|
})!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the account's relationship with the given account.
|
|
/// </summary>
|
|
/// <param name="account">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>
|
|
/// <param name="content">Text content of the status.</param>
|
|
/// <param name="content_type">The MIME type of the <paramref name="content"/>.</param>
|
|
/// <param name="sensitive">Mark status and attached media as sensitive?</param>
|
|
/// <param name="expiresIn">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.</param>
|
|
/// <param name="replyTo">ID of the status being replied to, if status is a reply</param>
|
|
/// <param name="quoting"> ID of the status being quoted.</param>
|
|
/// <param name="language">ISO 639 language code for this status.</param>
|
|
/// <param name="attachments">Array of Attachment ids to be attached as media.</param>
|
|
/// <param name="to">A list of nicknames (like <c>lain@soykaf.club</c> or <c>lain</c> on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the <paramref name="content"/>, only the people in the <paramref name="to"/> list will be addressed. The normal rules for post visibility are not affected by this and will still apply</param>
|
|
/// <param name="visibility">Visibility of the posted status.</param>
|
|
/// <exception cref="HttpRequestException">Thrown if something goes wrong while publishing the status.</exception>
|
|
/// <returns>The newly published status if posting was successful.</returns>
|
|
public Task<Status> PostStatus(string? content = null,
|
|
string content_type = "text/plain",
|
|
bool sensitive = false,
|
|
int? expiresIn = null,
|
|
StatusID? replyTo = null,
|
|
StatusID? quoting = null,
|
|
string? language = null,
|
|
MediaID[]? attachments = null,
|
|
string[]? to = null,
|
|
StatusVisibility visibility = StatusVisibility.Public)
|
|
{
|
|
if (content == null && (attachments == null || attachments.Length == 0))
|
|
throw new ArgumentException("Cannot post nothing. Content and/or attachments must be provided.");
|
|
|
|
MemoryStream mem = new MemoryStream();
|
|
using (Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true }))
|
|
{
|
|
writer.WriteStartObject();
|
|
|
|
if (content != null)
|
|
{
|
|
writer.WritePropertyName("status");
|
|
writer.WriteStringValue(content);
|
|
|
|
writer.WritePropertyName("content_type");
|
|
writer.WriteStringValue(content_type);
|
|
}
|
|
|
|
if (sensitive)
|
|
{
|
|
writer.WritePropertyName("sensitive");
|
|
writer.WriteBooleanValue(true);
|
|
}
|
|
|
|
if (expiresIn.HasValue)
|
|
{
|
|
writer.WritePropertyName("expires_in");
|
|
writer.WriteNumberValue(expiresIn.Value);
|
|
}
|
|
|
|
if (replyTo.HasValue)
|
|
{
|
|
writer.WritePropertyName("in_reply_to_id");
|
|
writer.WriteStringValue(replyTo.Value.ID);
|
|
}
|
|
|
|
if (quoting.HasValue)
|
|
{
|
|
writer.WritePropertyName("quote_id");
|
|
writer.WriteStringValue(quoting.Value.ID);
|
|
}
|
|
|
|
if (language != null)
|
|
{
|
|
writer.WritePropertyName("language");
|
|
writer.WriteStringValue(language);
|
|
}
|
|
|
|
if (attachments != null)
|
|
{
|
|
writer.WritePropertyName("media_ids");
|
|
writer.WriteStartArray();
|
|
foreach (MediaID media in attachments)
|
|
writer.WriteStringValue(media.ID);
|
|
writer.WriteEndArray();
|
|
}
|
|
|
|
if (to != null)
|
|
{
|
|
writer.WritePropertyName("to");
|
|
writer.WriteStartArray();
|
|
foreach (string name in to)
|
|
writer.WriteStringValue(name);
|
|
writer.WriteEndArray();
|
|
}
|
|
|
|
if (visibility != StatusVisibility.Public)
|
|
{
|
|
writer.WritePropertyName("visibility");
|
|
writer.WriteStringValue(visibility.ToString().ToLowerInvariant());
|
|
}
|
|
|
|
writer.WriteEndObject();
|
|
writer.Flush();
|
|
}
|
|
|
|
return Retry<Status>(() =>
|
|
{
|
|
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 req;
|
|
})!;
|
|
}
|
|
|
|
static string CommonMIMEString(CommonMIMEs mime) => mime switch
|
|
{
|
|
CommonMIMEs.PNG => "image/png",
|
|
CommonMIMEs.JPEG => "image/jpeg",
|
|
CommonMIMEs.GIF => "image/gif",
|
|
CommonMIMEs.MP4 => "video/mp4",
|
|
CommonMIMEs.WEBM => "video/webm",
|
|
CommonMIMEs.MOV => "video/quicktime",
|
|
CommonMIMEs.WAV => "audio/vnd.wav",
|
|
CommonMIMEs.MP3 => "audio/mpeg",
|
|
CommonMIMEs.OGG => "audio/ogg",
|
|
CommonMIMEs.Text => "text/plain",
|
|
_ => throw new ArgumentException("Unknown common MIME", nameof(mime)),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
|
|
/// </summary>
|
|
/// <param name="mime">The MIME type of the file.</param>
|
|
/// <param name="file">An array containing the file's contents.</param>
|
|
/// <returns>A handle of the uploaded file, including its ID and URLs.</returns>
|
|
public Task<Attachment> Upload(CommonMIMEs mime, byte[] file, string name = "file") => Upload(CommonMIMEString(mime), file, name);
|
|
|
|
/// <summary>
|
|
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
|
|
/// </summary>
|
|
/// <param name="mime">The MIME type of the file.</param>
|
|
/// <param name="file">An array containing the file's contents.</param>
|
|
/// <returns>A handle of the uploaded file, including its ID and URLs.</returns>
|
|
public Task<Attachment> Upload(string mime, byte[] file, string name = "file") => Upload(mime, new ByteArrayContent(file), name);
|
|
|
|
/// <summary>
|
|
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
|
|
/// </summary>
|
|
/// <param name="mime">The MIME type of the file.</param>
|
|
/// <param name="stream">A stream of the file's contents.</param>
|
|
/// <returns>A handle of the uploaded file, including its ID and URLs.</returns>
|
|
public Task<Attachment> Upload(CommonMIMEs mime, Stream stream, string name = "file") => Upload(CommonMIMEString(mime), stream, name);
|
|
|
|
/// <summary>
|
|
/// Uploads a file to the server. The resulting attachment can then be included in a status as an attachment.
|
|
/// </summary>
|
|
/// <param name="mime">The MIME type of the file.</param>
|
|
/// <param name="stream">A stream of the file's contents.</param>
|
|
/// <returns>A handle of the uploaded file, including its ID and URLs.</returns>
|
|
public Task<Attachment> Upload(string mime, Stream stream, string name = "file") => Upload(mime, new StreamContent(stream), name);
|
|
|
|
async Task<Attachment> Upload(string mime, HttpContent content, string name)
|
|
{
|
|
return (await Retry<Attachment>(() =>
|
|
{
|
|
content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
|
content.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
|
|
{
|
|
Name = "\"file\"",
|
|
FileName = $"\"{name}\"",
|
|
};
|
|
|
|
MultipartFormDataContent form = [content];
|
|
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/media")
|
|
{
|
|
Content = form
|
|
};
|
|
return req;
|
|
}))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Feature one of your own public statuses at the top of your profile
|
|
/// </summary>
|
|
/// <param name="status">The status to pin.</param>
|
|
/// <returns>The pinned status, if it exists.</returns>
|
|
public Task<Status?> Pin(StatusID status)
|
|
{
|
|
return Retry<Status?>(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/pin"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unfeature a status from the top of your profile
|
|
/// </summary>
|
|
/// <param name="status">The status to unpin.</param>
|
|
/// <returns>The unpinned status, if it exists.</returns>
|
|
public Task<Status?> Unpin(StatusID status)
|
|
{
|
|
return Retry<Status?>(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/unpin"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reposts/boosts/shares a status.
|
|
/// </summary>
|
|
/// <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)
|
|
{
|
|
MemoryStream mem = new MemoryStream();
|
|
|
|
using (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();
|
|
}
|
|
|
|
return Retry<Status>(() =>
|
|
{
|
|
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 req;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a reblog.
|
|
/// </summary>
|
|
/// <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>
|
|
/// <param name="timeline">The timeline to fetch.</param>
|
|
/// <param name="local">Show only local statuses?</param>
|
|
/// <param name="remote">Show only remote statuses?</param>
|
|
/// <param name="instance">Show only statuses from the given domain</param>
|
|
/// <param name="only_media">Show only statuses with media attached?</param>
|
|
/// <param name="with_muted">Include activities by muted users</param>
|
|
/// <param name="reply_visibility">Filter replies.</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>
|
|
public Task<Status[]> GetTimeline(Timeline timeline,
|
|
bool local = false,
|
|
bool remote = false,
|
|
string? instance = null,
|
|
bool only_media = false,
|
|
bool with_muted = false,
|
|
ReplyVisibility reply_visibility = ReplyVisibility.All,
|
|
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.All:
|
|
timelineName = "public";
|
|
break;
|
|
|
|
default:
|
|
throw new ArgumentException("Invalid timeline", nameof(timeline));
|
|
}
|
|
|
|
string path = $"/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 (reply_visibility != ReplyVisibility.All) addPair("reply_visibility", reply_visibility.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());
|
|
});
|
|
return Retry<Status[]>(() => new HttpRequestMessage(HttpMethod.Get, path))!;
|
|
}
|
|
|
|
|
|
/// <summary>
|
|
/// Fetches the latest statuses from a user's timeline.
|
|
/// </summary>
|
|
/// <param name="account">The user to fetch the timeline of.</param>
|
|
/// <param name="pinned">Include only pinned statuses</param>
|
|
/// <param name="tagged">With tag</param>
|
|
/// <param name="only_media">Show only statuses with media attached?</param>
|
|
/// <param name="with_muted">Include activities by muted users</param>
|
|
/// <param name="exclude_reblogs">Exclude reblogs</param>
|
|
/// <param name="exclude_replies">Exclude replies</param>
|
|
/// <param name="exclude_visibilities">Exclude the statuses with the given visibilities</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>
|
|
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)
|
|
{
|
|
string path = $"/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)
|
|
foreach (StatusVisibility visibility in exclude_visibilities)
|
|
addPair("exclude_visibilities[]", visibility.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());
|
|
});
|
|
return Retry<Status[]>(() => new HttpRequestMessage(HttpMethod.Get, path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fetches the context of a status.
|
|
/// </summary>
|
|
public Task<Context?> GetContext(StatusID status) => Retry<Context>(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/statuses/{WebUtility.UrlEncode(status.ID)}/context"));
|
|
|
|
/// <summary>
|
|
/// Fetches a status by ID.
|
|
/// </summary>
|
|
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<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.
|
|
/// </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>
|
|
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)
|
|
{
|
|
string path = "/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>(() => new HttpRequestMessage(HttpMethod.Get, path))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads an attachment.
|
|
/// </summary>
|
|
public async Task<byte[]> Download(Attachment attachment)
|
|
{
|
|
HttpResponseMessage res = await HttpClient.GetAsync(attachment.URL);
|
|
res.EnsureSuccessStatusCode();
|
|
return await res.Content.ReadAsByteArrayAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads an attachment as a <see cref="Stream"/>.
|
|
/// </summary>
|
|
public async Task<Stream> DownloadStream(Attachment attachment)
|
|
{
|
|
HttpResponseMessage res = await HttpClient.GetAsync(attachment.URL);
|
|
res.EnsureSuccessStatusCode();
|
|
return await res.Content.ReadAsStreamAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and <c>id</c> values.
|
|
/// </summary>
|
|
/// <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>
|
|
public Task<Notification[]> GetNotifications(NotificationType[]? exclude_types = null,
|
|
string? account_id = null,
|
|
StatusVisibility[]? exclude_visibilities = null,
|
|
NotificationType[]? types = null,
|
|
bool with_muted = false,
|
|
string? max_id = null,
|
|
string? min_id = null,
|
|
string? since_id = null,
|
|
int offset = 0,
|
|
int limit = 20)
|
|
{
|
|
string path = "/api/v1/notifications" + CreateQuery(addPair =>
|
|
{
|
|
if (exclude_types != null)
|
|
foreach (NotificationType type in exclude_types)
|
|
addPair("exclude_types[]", NotificationTypes.ToString(type));
|
|
|
|
if (account_id != null) addPair("account_id", account_id);
|
|
|
|
if (exclude_visibilities != null)
|
|
foreach (StatusVisibility visibility in exclude_visibilities)
|
|
addPair("exclude_visibilities[]", visibility.ToString().ToLowerInvariant());
|
|
|
|
if (types != null)
|
|
foreach (NotificationType type in types)
|
|
addPair("types[]", NotificationTypes.ToString(type));
|
|
|
|
if (with_muted) addPair("with_muted", "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<Notification[]>(() => new HttpRequestMessage(HttpMethod.Get, path))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// View information about a notification with a given ID.
|
|
/// </summary>
|
|
/// <param name="id">Notification ID</param>
|
|
public Task<Notification?> GetNotification(NotificationID id)
|
|
{
|
|
return Retry<Notification?>(() => new HttpRequestMessage(HttpMethod.Get, $"/api/v1/notifications/{WebUtility.UrlEncode(id.ID)}"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clear a single notification from the server.
|
|
/// </summary>
|
|
/// <param name="notification">Notification ID</param>
|
|
public Task Dismiss(NotificationID notification)
|
|
{
|
|
return Retry(() => new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notifications/{WebUtility.UrlEncode(notification.ID)}/dismiss"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears multiple notifications from the server.
|
|
/// </summary>
|
|
/// <param name="notifications">Array of notification IDs to dismiss</param>
|
|
public Task Dismiss(NotificationID[] notifications)
|
|
{
|
|
if (notifications.Length == 0)
|
|
return Task.CompletedTask;
|
|
|
|
string path = $"/api/v1/notifications/destroy_multiple" + CreateQuery(addPair =>
|
|
{
|
|
foreach (NotificationID id in notifications)
|
|
addPair("ids[]", id.ID);
|
|
});
|
|
return Retry(() => new HttpRequestMessage(HttpMethod.Delete, path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears multiple notifications from the server.
|
|
/// </summary>
|
|
/// <param name="notifications">Array of notifications to dismiss</param>
|
|
public Task Dismiss(Notification[] notifications)
|
|
{
|
|
if (notifications.Length == 0)
|
|
return Task.CompletedTask;
|
|
|
|
string path = $"/api/v1/notifications/destroy_multiple" + CreateQuery(addPair =>
|
|
{
|
|
foreach (NotificationID id in notifications)
|
|
addPair("ids[]", id.ID);
|
|
});
|
|
return Retry(() => new HttpRequestMessage(HttpMethod.Delete, path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a reaction to a status.
|
|
/// </summary>
|
|
/// <param name="status">The status to react to.</param>
|
|
/// <param name="emoji">The unicode emoji or custom emoji.</param>
|
|
/// <returns>The new state of the status, if it exists.</returns>
|
|
public Task<Status?> React(StatusID status, string emoji)
|
|
{
|
|
return Retry<Status?>(() => new HttpRequestMessage(HttpMethod.Put, $"/api/v1/pleroma/statuses/{WebUtility.UrlEncode(status.ID)}/reactions/{WebUtility.UrlEncode(emoji)}"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A pleroma client permitted to use the admin scope.
|
|
/// </summary>
|
|
public class PleromaAdmin : Pleroma
|
|
{
|
|
public PleromaAdmin(string host, string? authorization, string? userAgent = "Uwaa.Pleroma/0.0") : base(host, authorization, userAgent)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets users known to the instance.
|
|
/// </summary>
|
|
/// <param name="type">Filter by local/external type</param>
|
|
/// <param name="status">Filter by account status</param>
|
|
/// <param name="query">Search users query</param>
|
|
/// <param name="name">Search by display name</param>
|
|
/// <param name="email">Search by email</param>
|
|
/// <param name="page">Page Number</param>
|
|
/// <param name="page_size">Number of users to return per page</param>
|
|
/// <param name="actor_types">Filter by actor type</param>
|
|
/// <param name="tags">Filter by tags</param>
|
|
public Task<UsersPage> GetUsers(AccountType? type = null,
|
|
AccountStatus? status = null,
|
|
string? query = null,
|
|
string? name = null,
|
|
string? email = null,
|
|
int? page = null,
|
|
int? page_size = null,
|
|
ActorType[]? actor_types = null,
|
|
string[]? tags = null)
|
|
{
|
|
string path = "/api/v1/pleroma/admin/users" + CreateQuery(addPair =>
|
|
{
|
|
string? typeStr = type switch
|
|
{
|
|
AccountType.Local => "local",
|
|
AccountType.External => "external",
|
|
null => null,
|
|
_ => throw new ArgumentException("Invalid account type", nameof(type)),
|
|
};
|
|
string? statusStr = status switch
|
|
{
|
|
AccountStatus.Active => "active",
|
|
AccountStatus.Deactivated => "deactivated",
|
|
AccountStatus.PendingApproval => "need_approval",
|
|
AccountStatus.Unconfirmed => "unconfirmed",
|
|
null => null,
|
|
_ => throw new ArgumentException("Invalid account status", nameof(type)),
|
|
};
|
|
if (type != null && status != null)
|
|
addPair("filters", typeStr + "," + statusStr);
|
|
else if (typeStr != null)
|
|
addPair("filters", typeStr);
|
|
else if (statusStr != null)
|
|
addPair("filters", statusStr);
|
|
|
|
if (query != null) addPair("query", query);
|
|
if (name != null) addPair("name", name);
|
|
if (email != null) addPair("email", email);
|
|
if (page != null) addPair("page", page.Value.ToString());
|
|
if (page_size != null) addPair("page_size", page_size.Value.ToString());
|
|
if (actor_types != null)
|
|
foreach (ActorType type in actor_types)
|
|
addPair("actor_types[]", type.ToString());
|
|
if (tags != null)
|
|
foreach (string tag in tags)
|
|
addPair("tags[]", tag);
|
|
});
|
|
return Retry<UsersPage>(() => new HttpRequestMessage(HttpMethod.Get, path))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Modifies the sensitive and/or visibility of a status.
|
|
/// </summary>
|
|
/// <param name="status">The status ID to modify.</param>
|
|
/// <param name="sensitive">If non-null, the status will be made sensitive (if true) or not sensitive (if false).</param>
|
|
/// <param name="visibility">If non-null, the status's visibility will be set to this.</param>
|
|
/// <returns>Returns the new state of the status. Returns null if the status doesn't exist.</returns>
|
|
public Task<Status?> ChangeScope(StatusID status, bool? sensitive = null, StatusVisibility? visibility = null)
|
|
{
|
|
MemoryStream mem = new MemoryStream();
|
|
using (Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true }))
|
|
{
|
|
writer.WriteStartObject();
|
|
|
|
if (sensitive.HasValue)
|
|
{
|
|
writer.WritePropertyName("sensitive");
|
|
writer.WriteBooleanValue(sensitive.Value);
|
|
}
|
|
|
|
if (visibility.HasValue)
|
|
{
|
|
writer.WritePropertyName("visibility");
|
|
writer.WriteStringValue(visibility.Value.ToString().ToLowerInvariant());
|
|
}
|
|
|
|
writer.WriteEndObject();
|
|
writer.Flush();
|
|
}
|
|
|
|
return Retry<Status>(() =>
|
|
{
|
|
mem.Position = 0;
|
|
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/pleroma/admin/statuses/{WebUtility.UrlEncode(status.ID)}");
|
|
req.Content = new StreamContent(mem);
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
return req;
|
|
});
|
|
}
|
|
|
|
public Task<ModerationLog> GetModerationLog(int page = 1)
|
|
{
|
|
string path = "/api/v1/pleroma/admin/moderation_log" + CreateQuery(addPair =>
|
|
{
|
|
addPair("page", page.ToString());
|
|
});
|
|
return Retry<ModerationLog>(() => new HttpRequestMessage(HttpMethod.Get, path))!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deactivates or deletes one or more users by nickname.
|
|
/// </summary>
|
|
/// <remarks>If the user is pending approval, this will delete the user entirely. If the user is active, the user will be deactivated. Does nothing if the user is already deactivated.</remarks>
|
|
/// <param name="nicknames">The nicknames of the users to deactivate or delete.</param>
|
|
/// <returns>An array of nicknames which were successfully deactivated or deleted.</returns>
|
|
public Task<string[]> DeleteUsers(params string[] nicknames)
|
|
{
|
|
MemoryStream mem = new MemoryStream();
|
|
using (Utf8JsonWriter writer = new Utf8JsonWriter(mem, new JsonWriterOptions() { SkipValidation = true }))
|
|
{
|
|
writer.WriteStartObject();
|
|
writer.WriteStartArray("nicknames");
|
|
foreach (string nickname in nicknames)
|
|
writer.WriteStringValue(nickname);
|
|
writer.WriteEndArray();
|
|
writer.WriteEndObject();
|
|
writer.Flush();
|
|
}
|
|
|
|
return Retry<string[]>(() =>
|
|
{
|
|
mem.Position = 0;
|
|
HttpRequestMessage req = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/pleroma/admin/users");
|
|
req.Content = new StreamContent(mem);
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
return req;
|
|
})!;
|
|
}
|
|
}
|
|
|
|
[JsonConverter(typeof(EnumLowerCaseConverter<ActorType>))]
|
|
public enum ActorType
|
|
{
|
|
Application,
|
|
Group,
|
|
Organization,
|
|
Person,
|
|
Service
|
|
}
|
|
|
|
[JsonConverter(typeof(EnumLowerCaseConverter<AccountType>))]
|
|
public enum AccountType
|
|
{
|
|
Local,
|
|
External,
|
|
}
|
|
|
|
[JsonConverter(typeof(EnumLowerCaseConverter<AccountStatus>))]
|
|
public enum AccountStatus
|
|
{
|
|
Active,
|
|
Deactivated,
|
|
PendingApproval,
|
|
Unconfirmed,
|
|
} |