using System.Globalization; using System.Net; using collAnon.Client.Services; using decePubClient.Extensions; using decePubClient.Models; using decePubClient.Models.Mastodon; using decePubClient.Models.Types; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Post; namespace decePubClient.Services { /// The Mastodon actions a status has, by their route segment. public enum StatusAction { Favourite, Unfavourite, Reblog, Unreblog, Bookmark, Unbookmark, Pin, Unpin } /// /// Writing as the current persona: publishing (through the Mastodon API, or /clientapi for located and group posts), /// favourites, boosts, bookmarks, deleting, threads, and muting or blocking an author. Results are WebResults whose /// Data is the updated Status (a FeedPost when publishing); nothing throws. /// public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer) { /// Publishes the form as the persona; its media are uploaded first. public async Task Publish(ComposeForm form, string personaId, CancellationToken cancellationToken = default) { if (form.IsLocated || form.GroupId is not null) return await PublishThroughClientApi(form, personaId, cancellationToken); var mediaIds = new List(); foreach (var media in form.Media) { var uploaded = await mediaService.Upload(media, cancellationToken); if (uploaded.IsInvalid) return uploaded; mediaIds.Add(((MediaAttachment)uploaded.Data).Id); } using var response = await httpService.Post(APIs.Mastodon.POST_Statuses, new PostStatusForm { Status = form.Content ?? string.Empty, MediaIds = mediaIds, InReplyToId = form.InReplyToId, Sensitive = form.HasContentWarning, SpoilerText = form.HasContentWarning ? form.SpoilerText : null, Visibility = form.Visibility, Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName, ScheduledAt = form.ScheduledAtUtc?.ToString("o", CultureInfo.InvariantCulture), Poll = form.HasPoll ? new PollForm { Options = [.. form.PollOptions.Select(o => o.Trim()).Where(o => o.Length > 0)], ExpiresIn = form.PollExpiresIn, Multiple = form.PollMultiple } : null, QuotedStatusId = form.QuotedStatusId }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); if (form.ScheduledAtUtc is not null) return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; var status = await response.MastodonReadFromJsonAsync(cancellationToken); return new WebResult { Data = FeedPost.Of(status) }; } /// The persona's own text of a post, to edit it; null when it cannot be read. public async Task Source(string statusId, CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusSource(statusId), cancellationToken: cancellationToken); return response.IsSuccessStatusCode ? await response.MastodonReadFromJsonAsync(cancellationToken) : null; } /// Edits the persona's own post: new words and warning, the media kept; Data is the edited Status. public async Task Edit(string statusId, ComposeForm form, CancellationToken cancellationToken = default) { using var response = await httpService.Put(APIs.Mastodon.PUT_M_Status(statusId), new StatusEditForm { Status = form.Content ?? string.Empty, SpoilerText = form.HasContentWarning ? form.SpoilerText : null, Sensitive = form.HasContentWarning, Language = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; } /// The persona's posts waiting for their time, soonest first. public async Task> Scheduled(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.Mastodon.GET_ScheduledStatuses, ["limit=40"], cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(Scheduled)}", cancellationToken); return []; } return [.. (await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)]; } /// Moves a scheduled post to another time; Data is the ScheduledStatus. public async Task Reschedule(string scheduledId, DateTime atUtc, CancellationToken cancellationToken = default) { using var response = await httpService.Put(APIs.Mastodon.PUT_M_ScheduledStatus(scheduledId), new ScheduleForm { ScheduledAt = atUtc.ToString("o", CultureInfo.InvariantCulture) }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; } /// Drops a scheduled post: it is never published. public async Task Unschedule(string scheduledId, CancellationToken cancellationToken = default) { using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_ScheduledStatus(scheduledId), cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult(); } /// A located or group post: text only, through /clientapi/post/insert. async Task PublishThroughClientApi(ComposeForm form, string personaId, CancellationToken cancellationToken) { if (form.IsLocated && (form.Latitude is null || form.Longitude is null)) return new WebResult().Invalidate(localizer["A located post needs this device's position."]); using var response = await httpService.Post(APIs.ClientApi.POST_InsertPost, new InsertPostForm { AvatarId = personaId, Text = form.Content ?? string.Empty, GroupId = form.GroupId, AnsweringToPostId = form.InReplyToId, HasContentWarning = form.HasContentWarning, SpoilerText = form.HasContentWarning ? form.SpoilerText : null, Visibility = form.MessageType switch { MessageType.FollowersOnly => "followersonly", MessageType.Unlisted => "unlisted", _ => "public" }, Latitude = form.IsLocated ? form.Latitude : null, Longitude = form.IsLocated ? form.Longitude : null, RangeKm = form.IsLocated ? form.RangeKm : null }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; } public async Task Act(string statusId, StatusAction action, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.Mastodon.POST_M_StatusAction(statusId, action.ToString().ToLowerInvariant()), cancellationToken: cancellationToken); return await StatusResult(response, cancellationToken); } /// Reacts with an emoji (or takes the reaction back); Data is the updated Status. public async Task React(string statusId, string emoji, bool add, CancellationToken cancellationToken = default) { var route = APIs.Mastodon.M_Reaction(statusId, emoji); using var response = add ? await httpService.Put(route, cancellationToken: cancellationToken) : await httpService.Delete(route, cancellationToken: cancellationToken); return await StatusResult(response, cancellationToken); } /// Joins a remote event as the persona (its server is told, and shows who takes part), or leaves it; Data is /// the updated Status, whose event says how the participation stands. public async Task Participate(string statusId, bool join, CancellationToken cancellationToken = default) { using var response = await httpService.Post(join ? APIs.PrivaPub.POST_M_Join(statusId) : APIs.PrivaPub.POST_M_Leave(statusId), cancellationToken: cancellationToken); return await StatusResult(response, cancellationToken); } /// Votes in a poll as the persona (each option by its index); Data is the updated Poll. public async Task Vote(string pollId, IReadOnlyCollection choices, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.Mastodon.POST_M_PollVotes(pollId), new PollVoteForm { Choices = [.. choices] }, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; } /// Deletes the persona's own status. public async Task Delete(string statusId, CancellationToken cancellationToken = default) { using var response = await httpService.Delete(APIs.Mastodon.DELETE_M_Status(statusId), cancellationToken: cancellationToken); return await StatusResult(response, cancellationToken); } public async Task Get(string statusId, CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.Mastodon.GET_M_Status(statusId), cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) return await response.MastodonReadFromJsonAsync(cancellationToken); await LogFailure(response, nameof(Get), cancellationToken); return null; } /// The thread around a status, or null when it cannot be read. public async Task Context(string statusId, CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.Mastodon.GET_M_Context(statusId), cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) return await response.MastodonReadFromJsonAsync(cancellationToken); await LogFailure(response, nameof(Context), cancellationToken); return null; } /// Mutes or blocks an account for the current persona only (block federates, as PrivaPub's owner decided). public async Task AccountAction(string accountId, string action, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.Mastodon.POST_M_AccountAction(accountId, action), cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) }; } async Task StatusResult(HttpResponseMessage response, CancellationToken cancellationToken) { if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); var status = await response.MastodonReadFromJsonAsync(cancellationToken); return status is null ? new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway) : new WebResult { Data = status }; } 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) await logger.ProcessWarning(result.ToString(localizer), $"{nameof(StatusService)}.{caller}", cancellationToken); } } }