Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Bluesky support as an external media publisher (via AT Protocol) #2057

Merged
merged 1 commit into from
Dec 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TASVideos.Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal static class HttpClients
public const string GoogleAuth = "GoogleAuth";
public const string Youtube = "Youtube";
public const string Discord = "Discord";
public const string Bluesky = "Bluesky";
}

internal static class PlayerPointConstants
Expand Down
5 changes: 5 additions & 0 deletions TASVideos.Core/Extensions/HttpClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ public static void SetBasicAuth(this HttpClient client, string basicAuthHeader)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuthHeader);
}

public static void ResetAuthorization(this HttpClient client)
{
client.DefaultRequestHeaders.Authorization = null;
}
}
10 changes: 10 additions & 0 deletions TASVideos.Core/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ public static IServiceCollection AddTasvideosCore<T1, T2>(this IServiceCollectio
{
client.BaseAddress = new Uri("https://www.googleapis.com/youtube/v3/");
});
services
.AddHttpClient(HttpClients.Bluesky, client =>
{
client.BaseAddress = new Uri("https://bsky.social/xrpc/");
});

return services.AddServices(settings);
}
Expand Down Expand Up @@ -131,6 +136,11 @@ private static IServiceCollection AddExternalMediaPublishing(this IServiceCollec
services.AddScoped<IPostDistributor, DiscordDistributor>();
}

if (settings.Bluesky.IsEnabled())
{
services.AddScoped<IPostDistributor, BlueskyDistributor>();
}

return services.AddScoped<IPostDistributor, DistributorStorage>();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using System.Globalization;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using TASVideos.Core.HttpClientExtensions;
using TASVideos.Core.Settings;

namespace TASVideos.Core.Services.ExternalMediaPublisher.Distributors;
public sealed class BlueskyDistributor(
AppSettings appSettings,
IHttpClientFactory httpClientFactory,
ILogger<BlueskyDistributor> logger) : IPostDistributor
{
private readonly HttpClient _client = httpClientFactory.CreateClient(HttpClients.Bluesky);
private readonly AppSettings.BlueskyConnection _settings = appSettings.Bluesky;

public IEnumerable<PostType> Types => [PostType.Announcement];

public async Task Post(IPostable post)
{
if (!_settings.IsEnabled())
{
return;
}

_client.ResetAuthorization();
var sessionResponse = await _client.PostAsync("com.atproto.server.createSession", new BlueskyCreateSessionRequest(_settings.Identifier, _settings.Password).ToStringContent());
if (!sessionResponse.IsSuccessStatusCode)
{
logger.LogError("Failed to create Bluesky session");
return;
}

var session = await sessionResponse.ReadAsync<BlueskyCreateSessionResponse>();
_client.SetBearerToken(session.AccessJwt);

var postResponse = await _client.PostAsync("com.atproto.repo.createRecord", new BlueskyCreateRecordRequest(session.Did, post).ToStringContent());
if (!postResponse.IsSuccessStatusCode)
{
logger.LogError("Failed to create Bluesky post");
}
}

public class BlueskyCreateSessionRequest(string identifier, string password)
{
[JsonPropertyName("identifier")]
public string Identifier { get; set; } = identifier;

[JsonPropertyName("password")]
public string Password { get; set; } = password;
}

public class BlueskyCreateSessionResponse
{
[JsonPropertyName("accessJwt")]
public string AccessJwt { get; set; } = "";
[JsonPropertyName("did")]
public string Did { get; set; } = "";
}

public class BlueskyCreateRecordRequest(string repo, IPostable post)
{
[JsonPropertyName("repo")]
public string Repo { get; set; } = repo;

[JsonPropertyName("collection")]
public string Collection { get; } = "app.bsky.feed.post";

[JsonPropertyName("record")]
public BlueskyPost Record { get; set; } = new BlueskyPost(post);
}

public class BlueskyPost
{
public BlueskyPost(IPostable post)
{
var body = post.Group switch
{
PostGroups.Submission => post.Title,
PostGroups.Publication => post.Title,
_ => post.Body
};

if (!string.IsNullOrWhiteSpace(post.Link))
{
body = body.CapAndEllipse(300 - (post.Link.Length + 2));

body += '\n';
var bodyLengthUtf8 = Encoding.UTF8.GetByteCount(body);
var linkLengthUtf8 = Encoding.UTF8.GetByteCount(post.Link);

body += post.Link;

int byteStart = bodyLengthUtf8;
int byteEnd = byteStart + linkLengthUtf8;

Facets.Add(new BlueskyFacet(byteStart, byteEnd, post.Link));
}
else
{
body = body.CapAndEllipse(300);
}

Text = body;
CreatedAt = DateTime.UtcNow.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture);
}

[JsonPropertyName("$type")]
public string Type { get; } = "app.bsky.feed.post";

[JsonPropertyName("text")]
public string Text { get; set; }

[JsonPropertyName("facets")]
public List<BlueskyFacet> Facets { get; set; } = [];

[JsonPropertyName("langs")]
public List<string> Langs { get; } = ["en-US"];

[JsonPropertyName("createdAt")]
public string CreatedAt { get; set; }
}

public class BlueskyFacet(int byteStart, int byteEnd, string uri)
{
[JsonPropertyName("index")]
public BlueskyFacetIndex Index { get; set; } = new BlueskyFacetIndex(byteStart, byteEnd);

[JsonPropertyName("features")]
public List<BlueskyFacetFeature> Features { get; set; } = [new BlueskyFacetFeature(uri)];

public class BlueskyFacetIndex(int byteStart, int byteEnd)
{
[JsonPropertyName("byteStart")]
public int ByteStart { get; set; } = byteStart;

[JsonPropertyName("byteEnd")]
public int ByteEnd { get; set; } = byteEnd;
}

public class BlueskyFacetFeature(string uri)
{
[JsonPropertyName("$type")]
public string Type { get; } = "app.bsky.richtext.facet#link";

[JsonPropertyName("uri")]
public string Uri { get; set; } = uri;
}
}
}
11 changes: 11 additions & 0 deletions TASVideos.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class AppSettings

public IrcConnection Irc { get; set; } = new();
public DiscordConnection Discord { get; set; } = new();
public BlueskyConnection Bluesky { get; set; } = new();

public JwtSettings Jwt { get; set; } = new();
public GoogleAuthSettings YouTube { get; set; } = new();
Expand Down Expand Up @@ -76,6 +77,16 @@ public bool IsPrivateChannelEnabled() => IsEnabled()
&& !string.IsNullOrWhiteSpace(PrivateUserChannelId);
}

public class BlueskyConnection : DistributorConnection
{
public string Identifier { get; set; } = "";
public string Password { get; set; } = "";

public bool IsEnabled() => Disable != true
&& !string.IsNullOrWhiteSpace(Identifier)
&& !string.IsNullOrWhiteSpace(Password);
}

public class DistributorConnection
{
public bool? Disable { get; set; }
Expand Down
Loading