2026-10-05 01:23:18 +02:00
|
|
|
using System.Net;
|
|
|
|
|
|
|
|
|
|
using collAnon.Client.Services;
|
|
|
|
|
|
|
|
|
|
using decePubClient.Extensions;
|
|
|
|
|
using decePubClient.Models;
|
|
|
|
|
using decePubClient.Models.Mastodon;
|
2026-10-06 16:47:19 +02:00
|
|
|
using decePubClient.Models.Types;
|
2026-10-05 01:23:18 +02:00
|
|
|
|
|
|
|
|
using PrivaPub.ClientModels;
|
|
|
|
|
|
|
|
|
|
namespace decePubClient.Services
|
|
|
|
|
{
|
|
|
|
|
/// <summary>What the current persona does to and learns of other accounts.</summary>
|
|
|
|
|
public enum AccountAction
|
|
|
|
|
{
|
|
|
|
|
Follow,
|
|
|
|
|
Unfollow,
|
|
|
|
|
Block,
|
|
|
|
|
Unblock,
|
|
|
|
|
Mute,
|
|
|
|
|
Unmute
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Which of an account's posts its profile lists.</summary>
|
|
|
|
|
public enum ProfileTab
|
|
|
|
|
{
|
|
|
|
|
Posts,
|
|
|
|
|
PostsAndReplies,
|
|
|
|
|
Media
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Accounts as the current persona sees them: a profile, its relationship to the persona, its posts, following,
|
|
|
|
|
/// blocking and muting it, the persona's follow requests, and search across the fediverse (a remote handle or URL is
|
|
|
|
|
/// resolved by PrivaPub, never by the browser). Lists come back empty and values null on failure; nothing throws.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
|
|
|
|
{
|
|
|
|
|
const int PageSize = 20;
|
|
|
|
|
|
|
|
|
|
public async Task<Account> Get(string accountId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_Account(accountId), cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(Get), cancellationToken);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>An account by its handle (name or name@host), resolved by PrivaPub if it has not met it yet.</summary>
|
|
|
|
|
public async Task<Account> Lookup(string acct, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
acct = acct?.Trim().TrimStart('@');
|
|
|
|
|
if (string.IsNullOrEmpty(acct))
|
|
|
|
|
return null;
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_AccountLookup, [$"acct={Uri.EscapeDataString(acct)}"],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
|
|
|
|
|
|
|
|
|
var found = await Search(acct, resolve: true, cancellationToken);
|
|
|
|
|
return found.Accounts.FirstOrDefault(a => string.Equals(a.Acct, acct, StringComparison.OrdinalIgnoreCase))
|
|
|
|
|
?? found.Accounts.FirstOrDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<Relationship> Relationship(string accountId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [$"id[]={Uri.EscapeDataString(accountId)}"],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return (await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []).FirstOrDefault();
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(Relationship), cancellationToken);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 06:20:18 +02:00
|
|
|
/// <summary>The relationships of many accounts at once, 40 a request.</summary>
|
|
|
|
|
public async Task<List<Relationship>> Relationships(IReadOnlyList<string> accountIds, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var relationships = new List<Relationship>();
|
|
|
|
|
foreach (var batch in accountIds.Chunk(40))
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_Relationships, [.. batch.Select(id => $"id[]={Uri.EscapeDataString(id)}")],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
await LogFailure(response, nameof(Relationships), cancellationToken);
|
|
|
|
|
return relationships;
|
|
|
|
|
}
|
|
|
|
|
relationships.AddRange(await response.MastodonReadFromJsonAsync<List<Relationship>>(cancellationToken) ?? []);
|
|
|
|
|
}
|
|
|
|
|
return relationships;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.</summary>
|
|
|
|
|
public async Task<WebResult> Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
|
|
|
|
|
new[] { new KeyValuePair<string, string>("reblogs", showReblogs ? "true" : "false") }, cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
/// <summary>Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.</summary>
|
|
|
|
|
public async Task<WebResult> Act(string accountId, AccountAction action, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action.ToString().ToLowerInvariant()),
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>A page of the account's posts, newest first; Data is a FeedPage.</summary>
|
|
|
|
|
public async Task<WebResult> Statuses(string accountId, ProfileTab tab, string maxId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var parameters = new List<string> { $"limit={PageSize}" };
|
|
|
|
|
parameters.AddRange(tab switch
|
|
|
|
|
{
|
|
|
|
|
ProfileTab.Posts => ["exclude_replies=true"],
|
|
|
|
|
ProfileTab.Media => ["only_media=true"],
|
|
|
|
|
_ => Array.Empty<string>()
|
|
|
|
|
});
|
|
|
|
|
if (maxId is not null)
|
|
|
|
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), [.. parameters],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
var statuses = await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? [];
|
|
|
|
|
var (nextMaxId, _) = response.PageCursors();
|
|
|
|
|
return new WebResult
|
|
|
|
|
{
|
|
|
|
|
Data = new FeedPage
|
|
|
|
|
{
|
|
|
|
|
Posts = [.. statuses.Select(FeedPost.Of)],
|
|
|
|
|
MaxId = statuses.Count < PageSize ? null : nextMaxId ?? statuses.LastOrDefault()?.Id
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 23:04:31 +02:00
|
|
|
/// <summary>The posts the account pins, in its order: what it features, for an account elsewhere.</summary>
|
|
|
|
|
public async Task<List<FeedPost>> Pinned(string accountId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_AccountStatuses(accountId), ["pinned=true"], cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return [.. (await response.MastodonReadFromJsonAsync<List<Status>>(cancellationToken) ?? []).Select(FeedPost.Of)];
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(Pinned), cancellationToken);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
/// <summary>The accounts waiting for the persona to accept their follow.</summary>
|
|
|
|
|
public async Task<List<Account>> FollowRequests(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_FollowRequests, ["limit=80"], cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(FollowRequests), cancellationToken);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Accepts (authorize) or refuses (reject) a follow request.</summary>
|
|
|
|
|
public async Task<WebResult> Answer(string accountId, bool accept, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_M_FollowRequest(accountId, accept ? "authorize" : "reject"),
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<Relationship>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Accounts, posts and hashtags matching the query; a handle or a URL is resolved when `resolve`.</summary>
|
|
|
|
|
public async Task<SearchResults> Search(string query, bool resolve, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
|
|
|
return new SearchResults();
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_Search,
|
|
|
|
|
[$"q={Uri.EscapeDataString(query.Trim())}", $"resolve={(resolve ? "true" : "false")}", "limit=20"],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return await response.MastodonReadFromJsonAsync<SearchResults>(cancellationToken) ?? new SearchResults();
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(Search), cancellationToken);
|
|
|
|
|
return new SearchResults();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 06:20:18 +02:00
|
|
|
/// <summary>Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).</summary>
|
|
|
|
|
public async Task<WebResult> UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Patch(APIs.Mastodon.PATCH_UpdateCredentials, (Func<HttpContent>)form.Content,
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
var account = await response.MastodonReadFromJsonAsync<Account>(cancellationToken);
|
|
|
|
|
return account?.Id is null
|
|
|
|
|
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
|
|
|
|
|
: new WebResult { Data = account };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.</summary>
|
|
|
|
|
public Task<WebResult> Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
|
|
|
|
|
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
|
|
|
|
|
|
|
|
|
|
/// <summary>A page of the accounts the persona muted; Data is an AccountPage.</summary>
|
|
|
|
|
public Task<WebResult> Mutes(string maxId, CancellationToken cancellationToken = default) =>
|
|
|
|
|
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
|
|
|
|
|
|
|
|
|
|
/// <summary>A page of the accounts the persona blocked; Data is an AccountPage.</summary>
|
|
|
|
|
public Task<WebResult> Blocks(string maxId, CancellationToken cancellationToken = default) =>
|
|
|
|
|
Accounts(APIs.Mastodon.GET_Blocks, maxId, cancellationToken);
|
|
|
|
|
|
|
|
|
|
async Task<WebResult> Accounts(string route, string maxId, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var parameters = new List<string> { "limit=40" };
|
|
|
|
|
if (maxId is not null)
|
|
|
|
|
parameters.Add($"max_id={Uri.EscapeDataString(maxId)}");
|
|
|
|
|
using var response = await httpService.Get(route, [.. parameters], cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
var accounts = await response.MastodonReadFromJsonAsync<List<Account>>(cancellationToken) ?? [];
|
|
|
|
|
var (nextMaxId, _) = response.PageCursors();
|
|
|
|
|
return new WebResult { Data = new AccountPage { Accounts = accounts, MaxId = accounts.Count == 0 ? null : nextMaxId } };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>The servers the persona blocked: nothing from them reaches it, and its followers there are removed.</summary>
|
|
|
|
|
public async Task<List<string>> DomainBlocks(CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_DomainBlocks, ["limit=200"], cancellationToken: cancellationToken);
|
|
|
|
|
if (response.IsSuccessStatusCode)
|
|
|
|
|
return await response.MastodonReadFromJsonAsync<List<string>>(cancellationToken) ?? [];
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> BlockDomain(string domain, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_DomainBlocks, new[] { new KeyValuePair<string, string>("domain", domain.Trim()) },
|
|
|
|
|
cancellationToken: cancellationToken);
|
2026-10-06 16:47:19 +02:00
|
|
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server
|
|
|
|
|
/// gets the report too, from PrivaPub's actor and never naming the persona.</summary>
|
|
|
|
|
public async Task<WebResult> Report(string accountId, List<string> statusIds, string comment, ReportCategory category, bool forward,
|
|
|
|
|
CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest
|
|
|
|
|
{
|
|
|
|
|
AccountId = accountId,
|
|
|
|
|
StatusIds = statusIds,
|
|
|
|
|
Comment = comment?.Trim(),
|
|
|
|
|
Category = category.ToString().ToLowerInvariant(),
|
|
|
|
|
Forward = forward
|
|
|
|
|
}, cancellationToken: cancellationToken);
|
2026-10-05 06:20:18 +02:00
|
|
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> UnblockDomain(string domain, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Delete(APIs.Mastodon.DELETE_DomainBlocks, [$"domain={Uri.EscapeDataString(domain)}"],
|
|
|
|
|
cancellationToken: cancellationToken);
|
|
|
|
|
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
|
|
|
|
|
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AccountService)}.{caller}", cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|