using System.Net;
using collAnon.Client.Services;
using decePubClient.Extensions;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using PrivaPub.ClientModels;
namespace decePubClient.Services
{
/// What the current persona does to and learns of other accounts.
public enum AccountAction
{
Follow,
Unfollow,
Block,
Unblock,
Mute,
Unmute
}
/// Which of an account's posts its profile lists.
public enum ProfileTab
{
Posts,
PostsAndReplies,
Media
}
///
/// 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.
///
public class AccountService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
const int PageSize = 20;
public async Task 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(cancellationToken);
await LogFailure(response, nameof(Get), cancellationToken);
return null;
}
/// An account by its handle (name or name@host), resolved by PrivaPub if it has not met it yet.
public async Task 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(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(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>(cancellationToken) ?? []).FirstOrDefault();
await LogFailure(response, nameof(Relationship), cancellationToken);
return null;
}
/// The relationships of many accounts at once, 40 a request.
public async Task> Relationships(IReadOnlyList accountIds, CancellationToken cancellationToken = default)
{
var relationships = new List();
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>(cancellationToken) ?? []);
}
return relationships;
}
/// Follows the account, its boosts shown in the home feed or not; Data is the new Relationship.
public async Task Follow(string accountId, bool showReblogs, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, "follow"),
new[] { new KeyValuePair("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(cancellationToken) };
}
/// Follows, unfollows, blocks, unblocks, mutes or unmutes; Data is the new Relationship.
public async Task 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(cancellationToken) };
}
/// A page of the account's posts, newest first; Data is a FeedPage.
public async Task Statuses(string accountId, ProfileTab tab, string maxId, CancellationToken cancellationToken = default)
{
var parameters = new List { $"limit={PageSize}" };
parameters.AddRange(tab switch
{
ProfileTab.Posts => ["exclude_replies=true"],
ProfileTab.Media => ["only_media=true"],
_ => Array.Empty()
});
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>(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
}
};
}
/// The accounts waiting for the persona to accept their follow.
public async Task> 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>(cancellationToken) ?? [];
await LogFailure(response, nameof(FollowRequests), cancellationToken);
return [];
}
/// Accepts (authorize) or refuses (reject) a follow request.
public async Task 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(cancellationToken) };
}
/// Accounts, posts and hashtags matching the query; a handle or a URL is resolved when `resolve`.
public async Task 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(cancellationToken) ?? new SearchResults();
await LogFailure(response, nameof(Search), cancellationToken);
return new SearchResults();
}
/// Saves the persona's profile; Data is its account as PrivaPub now describes it (with its Source).
public async Task UpdateProfile(ProfileForm form, CancellationToken cancellationToken = default)
{
using var response = await httpService.Patch(APIs.Mastodon.PATCH_UpdateCredentials, (Func)form.Content,
cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
var account = await response.MastodonReadFromJsonAsync(cancellationToken);
return account?.Id is null
? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway)
: new WebResult { Data = account };
}
/// A page of the accounts the persona follows (only the persona itself may read it); Data is an AccountPage.
public Task Following(string accountId, string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_M_AccountFollowing(accountId), maxId, cancellationToken);
/// A page of the accounts the persona muted; Data is an AccountPage.
public Task Mutes(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Mutes, maxId, cancellationToken);
/// A page of the accounts the persona blocked; Data is an AccountPage.
public Task Blocks(string maxId, CancellationToken cancellationToken = default) =>
Accounts(APIs.Mastodon.GET_Blocks, maxId, cancellationToken);
async Task Accounts(string route, string maxId, CancellationToken cancellationToken)
{
var parameters = new List { "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>(cancellationToken) ?? [];
var (nextMaxId, _) = response.PageCursors();
return new WebResult { Data = new AccountPage { Accounts = accounts, MaxId = accounts.Count == 0 ? null : nextMaxId } };
}
/// The servers the persona blocked: nothing from them reaches it, and its followers there are removed.
public async Task> 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>(cancellationToken) ?? [];
await LogFailure(response, nameof(DomainBlocks), cancellationToken);
return [];
}
public async Task BlockDomain(string domain, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.Mastodon.POST_DomainBlocks, new[] { new KeyValuePair("domain", domain.Trim()) },
cancellationToken: cancellationToken);
return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
}
public async Task 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);
}
}
}