Skip to content

Commit

Permalink
implement bluesky external media publisher for announcements (#2057)
Browse files Browse the repository at this point in the history
this supports clickable links, but there are no embeds yet. just like links, embeds need to be added manually.
  • Loading branch information
Masterjun3 authored Dec 15, 2024
1 parent dd84d94 commit 87dcef8
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 0 deletions.
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

0 comments on commit 87dcef8

Please sign in to comment.