Files
decePubClient/Services/AccountService.cs
T
thepraandClaude Opus 5.5 a0f07abf8c Reports, and who liked or boosted a post
Another's profile and posts can be reported from the client (/api/v1/reports): a category, words, and for a remote
account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. Who liked or boosted
a post opens from its card's menu (favourited_by, reblogged_by). e2e: a report from a Mastodon account's profile
reaches PrivaPub's moderators forwarded, and a post's likes and boosts list the persona who gave them; the admin
helper the tests share moves to Support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-06 16:47:19 +02:00

285 lines
14 KiB
C#

using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
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;
}
/// <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) };
}
/// <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
}
};
}
/// <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 [];
}
/// <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();
}
/// <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);
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);
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);
}
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);
}
}
}