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 posts the account pins, in its order: what it features, for an account elsewhere. public async Task> 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>(cancellationToken) ?? []).Select(FeedPost.Of)]; await LogFailure(response, nameof(Pinned), cancellationToken); return []; } /// 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); } } }