Creating a Bluesky bot in C#

5 min read

I recently blogged about us adding a new Bluesky tips bot to UMB.FYI and so I thought I’d share a bit more detail on how I went about it.

Scope

From the UMB.FYI tips bot perspective, we’re referring to a bot designed to monitor an account for mention notifications. When the bot is mentioned, it retrieves and processes the referenced post, then responds to the tipper with a thank-you message.

Setup

The first thing you will need is a Bluesky account. Once you have your account go to the App Passwords settings section and click the Add App Password button.

Bluesky app passwords dashboard

In the presented dialog, give your app password a name and click the Create App Password button.

Bluesky create app password modal

You’ll then be presented with your app password. Save this in a safe place as once you close the dialog you won’t be able to access it again. Once stored safely, click the Done button to close the dialog.

Bluesky app password

Firehose, Jetstream or Polling

Given the monitoring nature of our bot, we need to decide how we are going to watch for notifications. When it comes to Bluesky you currently have one of three options.

  1. Firehose - The firehose is effectively a constant stream of all posts occurring on Bluesky in real-time. To monitor this you’d connect to it via a websocket and look for messages that mention our bot account.
  2. Jetstream - The jetstream is a simplified layer over the firehose removing a lot of security measures in the name of simplicity and bandwidth efficiencies. Monitoring would be the same as the firehose, using a websocket to retrieve all posts looking for bot mentions.
  3. Polling - With polling, instead of having posts pushed to us through an open socket, we instead loop on a set interval to query the Bluesky notifications endpoint to check if there are any new notification since the last time we checked.

Ordinarily I would much prefer to use a socket connection and have notifications pushed to our bot (this is how our Discord and Mastodon bots work), however given there is very little control over the filtering of both the Firehose and Jetstream connections at this time, receiving every single post occurring on the network to find the few that are of actual interest just seemed too much of a waste. Even using Jetstream which is designed to heavily reduce bandwidth consumption, we are still talking approximately 50Gb per month in data which is just way too much.

So for the UMB.FYI bot it seemed like going the polling route was the more sustainable option.

Base

The first thing we’ll define for our bot is a base class to encapsulate the logic of polling on a set interval. Given we’ll be hosting our bot in ASP.NET we’ll define it as a IHostedService

public abstract class PollingBotServiceBase(int pollInterval) : IHostedService, IDisposable
{
    private CancellationTokenSource _cts;
    private Task _backgroundTask;
    
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Create a linked CancellationTokenSource to support both application shutdown and service-specific cancellation.
        _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        
        // Start the background task.
        _backgroundTask = Task.Run(() => PollAsync(_cts.Token), _cts.Token);

        return Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        if (_cts != null)
        {
            // Signal the background task to stop
            await _cts.CancelAsync();

            // Wait for the background task to complete gracefully
            if (_backgroundTask != null)
            {
                await Task.WhenAny(_backgroundTask, Task.Delay(Timeout.Infinite, cancellationToken));
            }
        }
    }

    private async Task PollAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                await DoPollAsync(cancellationToken);
            }
            catch (OperationCanceledException)
            {
                // Graceful shutdown: stop polling when cancellation is requested.
                break;
            }
            catch (Exception ex)
            {
                // Log or handle other errors.
            }

            // Delay before the next poll, respecting the cancellation token.
            try
            {
                await Task.Delay(TimeSpan.FromSeconds(pollInterval), cancellationToken);
            }
            catch (OperationCanceledException)
            {
                // Break out of the loop if cancellation occurs during the delay.
                break;
            }
        }
    }
    
    protected abstract Task DoPollAsync(CancellationToken cancellationToken);
    
    public void Dispose()
    {
        _cts?.Cancel();
        _cts?.Dispose();
    }
}

From this we can define our actual BlueskyBotService class with an initial polling interval of 5 seconds, and set out in pseudo code the actions we need to perform in our poll routine.

public class BlueskyBotService() : PollingBotServiceBase(5)
{
    protected override Task DoPollAsync(CancellationToken cancellationToken)
    {
        // Fetch unseen notifications
        // Get the root most post for the notification
        // Check the post is a tip
        // Submit the tip to the UMB.FYI endpoint
        // Respond to the tipper with a thank you message
        return Task.CompletedTask;
    }
}

Client

We could just implement all the code in the DoPollAsync method, but to keep things clean we’ll define a BlueskyClient class to encapsulate the Bluesky specific tasks we need to perform which are:

  1. Fetch unseen notifications
  2. Fetch the notifications root post
  3. Reply to the notification

In addition, our BlueskyClient can also handle authenticating our requests and maintaining a session. To make working with the Bluesky API easier, we’ll make use of the FishyFlip library.

public class BlueskyClient(string handle, string appPassword)
{
    private readonly ATProtocol _protocol = new ATProtocolBuilder()
        .WithInstanceUrl(new Uri("https://bsky.social"))
        .EnableAutoRenewSession(true)
        .Build();
    
    private Session _session;
    
    private DateTime _lastDateTime = DateTime.UtcNow;
    
    public async Task<IEnumerable<Notification>> GetUnseenMentionsAsync(CancellationToken cancellationToken)
    {
        await EnsureSession(cancellationToken);
        
        var result = await _protocol.Notification.ListNotificationsAsync(limit: 5, cancellationToken: cancellationToken);
        var notifications = result.HandleResult().Notifications;
        
        // Filter the notifications to only include mentions that are newer than the last time we checked
        var unseenMentions = notifications.Where(x => x.Reason == "mention" && x.IndexedAt > _lastDateTime).ToList();
        
        // Update the last time we checked to the newest notification date
        _lastDateTime = notifications.Max(x => x.IndexedAt);
        
        return unseenMentions;
    }
    
    public async Task<PostView> GetNotificationRootPostAsync(Notification notification, CancellationToken cancellationToken)
    {
        // If the notification post is in reply to another post, get the root post of the thread, otherwise, get the post itself
        var postUri = notification.Record is Post post && post.Reply != null ? post.Reply.Root.Uri : notification.Uri;
        var result = await _protocol.Feed.GetPostThreadAsync(postUri, 0, 0, cancellationToken: cancellationToken);
        return result.HandleResult().Thread.Post;
    }
    
    public async Task ReplyToNotificationAsync(Notification notification, string message, CancellationToken cancellationToken)
    {
        await EnsureSession(cancellationToken);
        
        var postRef = new ReplyRef(notification.Cid, notification.Uri);
        var rootRef = notification.Record is Post post && post.Reply != null ? new ReplyRef(post.Reply.Root.Cid, post.Reply.Root.Uri) : postRef;
        
        await _protocol.Repo.CreatePostAsync(message, new Reply(rootRef, postRef), cancellationToken:cancellationToken), cancellationToken)
    }

    private async Task EnsureSession(CancellationToken cancellationToken)
    {
        _session ??= await _protocol.AuthenticateWithPasswordAsync(handle, appPassword, cancellationToken);
    }
}

Bot

With our client defined, we can now go back and implement our bot. We define an instance of our client, passing in our handle and app password that we saved from earlier, and then complete our logic to process the posts, submitting a tip and then replying to our tipper.

public class BlueskyBotService(MediaTipService tipService) : PollingBotServiceBase(5)
{
    private readonly BlueskyClient _client = new("umb.fyi", "MY_APP_PASSWORD");

    protected override async Task DoPollAsync(CancellationToken cancellationToken)
    {
        // Fetch unseen mentions
        var unseenMentions = await _client.GetUnseenMentionsAsync(cancellationToken);
        
        // Iterate over each mention
        foreach (var mention in unseenMentions)
        {
            // Get the root post
            var mentionRootPost = await _client.GetNotificationRootPostAsync(mention, cancellationToken);
            
            // Ensure we have a valid record
            if (mentionRootPost?.Record == null)
            {
                continue;
            }
            
            // Extract the post text
            var text = mentionRootPost.Record.Text ?? string.Empty;
            
            // Check the mention is a tip
            if (Regex.IsMatch(text, $"@umb.fyi\\s+tip", RegexOptions.IgnoreCase))
            {
                // Submit the tip
                tipService.SubmitTip(new MediaTip
                {
                    Link = $"https://bsky.app/profile/{mentionRootPost.Author.Handle}/post/{mentionRootPost.Uri.Rkey}",
                    Message = mentionRootPost.Record.Text,
                    Source = $"https://bsky.app/profile/{mentionRootPost.Author.Handle}",
                });
                
                // Reply to the post
                await _client.ReplyToNotificationAsync(mention, "Thank you for the tip!", cancellationToken);
            }
        }
    }
}

Registration

The final step is to register our bot service with the DI container and then we are good to go.

builder.Services.AddHostedService<BlueskyBotService>();

Wrapping Up

Whilst polling isn’t that optimal a solution, the way we have it setup should mean it’s a maximum of 5 seconds before tippers get a confirmation, which I think is acceptable. Hopefully in the future Bluesky create some more filterable feeds that can be connected to via websockets to improve the efficiency. For now at least. I’m pretty pleased with the fact we could bring an UMB.FYI bot to Bluesky and I hope the code helps other bot builders in the future do the same.

Until next time 👋