2026-10-04 11:03:14 +02:00
|
|
|
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
|
|
|
|
|
{
|
|
|
|
|
/// <summary>The Mastodon actions a status has, by their route segment.</summary>
|
|
|
|
|
public enum StatusAction
|
|
|
|
|
{
|
|
|
|
|
Favourite,
|
|
|
|
|
Unfavourite,
|
|
|
|
|
Reblog,
|
|
|
|
|
Unreblog,
|
|
|
|
|
Bookmark,
|
2026-10-05 23:04:31 +02:00
|
|
|
Unbookmark,
|
|
|
|
|
Pin,
|
|
|
|
|
Unpin
|
2026-10-04 11:03:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class StatusService(IHttpService httpService, MediaService mediaService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
|
|
|
|
{
|
|
|
|
|
/// <summary>Publishes the form as the persona; its media are uploaded first.</summary>
|
|
|
|
|
public async Task<WebResult> 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<string>();
|
|
|
|
|
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,
|
2026-10-05 07:06:12 +02:00
|
|
|
Language = form.Language ?? CultureInfo.CurrentUICulture.TwoLetterISOLanguageName,
|
2026-10-05 04:42:42 +02:00
|
|
|
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
|
2026-10-04 11:03:14 +02:00
|
|
|
}, cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
2026-10-05 03:53:51 +02:00
|
|
|
if (form.ScheduledAtUtc is not null)
|
|
|
|
|
return new WebResult { Data = await response.MastodonReadFromJsonAsync<ScheduledStatus>(cancellationToken) };
|
2026-10-04 11:03:14 +02:00
|
|
|
var status = await response.MastodonReadFromJsonAsync<Status>(cancellationToken);
|
|
|
|
|
return new WebResult { Data = FeedPost.Of(status) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 04:42:42 +02:00
|
|
|
/// <summary>The persona's own text of a post, to edit it; null when it cannot be read.</summary>
|
|
|
|
|
public async Task<StatusSource> 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<StatusSource>(cancellationToken) : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-06 13:03:19 +02:00
|
|
|
/// <summary>A post's versions, the oldest first and the one showing last; [] when they cannot be read.</summary>
|
|
|
|
|
public async Task<List<StatusEdit>> History(string statusId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Get(APIs.Mastodon.GET_M_StatusHistory(statusId), cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
{
|
|
|
|
|
var failure = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
await logger.ProcessWarning(failure.ToString(localizer), $"{nameof(StatusService)}.{nameof(History)}", cancellationToken);
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
return await response.MastodonReadFromJsonAsync<List<StatusEdit>>(cancellationToken) ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 04:42:42 +02:00
|
|
|
/// <summary>Edits the persona's own post: new words and warning, the media kept; Data is the edited Status.</summary>
|
|
|
|
|
public async Task<WebResult> 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<Status>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 03:53:51 +02:00
|
|
|
/// <summary>The persona's posts waiting for their time, soonest first.</summary>
|
|
|
|
|
public async Task<List<ScheduledStatus>> 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<List<ScheduledStatus>>(cancellationToken) ?? []).OrderBy(s => s.ScheduledAt)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Moves a scheduled post to another time; Data is the ScheduledStatus.</summary>
|
|
|
|
|
public async Task<WebResult> 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<ScheduledStatus>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Drops a scheduled post: it is never published.</summary>
|
|
|
|
|
public async Task<WebResult> 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
/// <summary>A located or group post: text only, through /clientapi/post/insert.</summary>
|
|
|
|
|
async Task<WebResult> 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<ViewPost>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<WebResult> 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
/// <summary>Reacts with an emoji (or takes the reaction back); Data is the updated Status.</summary>
|
|
|
|
|
public async Task<WebResult> 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 15:19:07 +02:00
|
|
|
/// <summary>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.</summary>
|
|
|
|
|
public async Task<WebResult> 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 01:23:18 +02:00
|
|
|
/// <summary>Votes in a poll as the persona (each option by its index); Data is the updated Poll.</summary>
|
|
|
|
|
public async Task<WebResult> Vote(string pollId, IReadOnlyCollection<int> 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<Poll>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
/// <summary>Deletes the persona's own status.</summary>
|
|
|
|
|
public async Task<WebResult> 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<Status> 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<Status>(cancellationToken);
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(Get), cancellationToken);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>The thread around a status, or null when it cannot be read.</summary>
|
|
|
|
|
public async Task<StatusContext> 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<StatusContext>(cancellationToken);
|
|
|
|
|
|
|
|
|
|
await LogFailure(response, nameof(Context), cancellationToken);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Mutes or blocks an account for the current persona only (block federates, as PrivaPub's owner decided).</summary>
|
|
|
|
|
public async Task<WebResult> 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<Relationship>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Task<WebResult> StatusResult(HttpResponseMessage response, CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
var status = await response.MastodonReadFromJsonAsync<Status>(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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|