Table of Contents

Writing a watcher bot

Watcher bots monitor actions performed in the Bluesky network and take action based upon those actions. For example a bot might watch for a new post containing a particular hashtag and flag that post in a CRM system so a support team could monitor them and reply to any posts that indicate a problem.

For this example we're going to write a bot that watches for posts that contain certain keywords.

Note

You must already have created an account for your bot to run as, and generated a app password for that account.

Create a .NET project and add the idunno.Bluesky NuGet package

Let's start by creating a .NET project for our bot and adding the idunno.Bluesky package. The StreamAsync API used below requires idunno.Bluesky 8.0.0 or newer. Until that version is published, the five example projects in this repository build against the SDK source directly.

  1. In a console run the following commands
    dotnet new console -n WatcherBot
    cd WatcherBot
    dotnet add package idunno.Bluesky
    

Listen to the Jetstream

Warning

Treat data received from Bluesky, AT Protocol services, and streams such as Jetstream and the firehose as untrusted. This includes post and message text, profile and list metadata, labels, error details such as AtErrorDetail, and raw JSON. Before displaying it, encode it for the output context (for example, HTML-encode it for a web page). Before using a URL in a link or other active context, check its scheme against an allow-list of the schemes your application expects, such as https; a well-formed URL can still use an active scheme such as javascript:, which can lead to cross-site scripting. Validate other values before using them in active contexts.

The sample code on this page is for illustration. It does not sanitize or otherwise validate the input it uses; apply the validation and encoding appropriate for your application.

Now let's listen to the Jetstream. The Jetstream is a streaming service that provides information on activity on the ATProto network. It lists commits to records (for example creating a post, favoriting or unfavoriting a post, following or unfollowing a user), updates to an identity (for example changing a handle) and account operations (for example an account takedown). It encompasses the entire ATProto network, not just Bluesky operations, so you might see WhiteWind blog records or Tangled collaboration messages if you watch the commit stream. The v2 StreamAsync API opens a live connection when await foreach begins, yields JetstreamEvent values, and automatically reconnects after transient disconnections. This step prints commit events; other event kinds are ignored. await using disposes the client and closes the connection when the loop ends. Pressing CTRL+C cancels the enumeration rather than leaving an empty loop running.

  1. Open Program.cs in the editor of your choice and replace its contents with the following
    using System.Globalization;
    using System.Text;
    
    using idunno.AtProto.Jetstream;
    
    using CancellationTokenSource cancellation = new();
    Console.CancelKeyPress += (_, e) =>
    {
        e.Cancel = true;
        cancellation.Cancel();
    };
    
    Console.OutputEncoding = Encoding.UTF8;
    
    await using var jetStream = new AtProtoJetstream();
    
    try
    {
        await foreach (JetstreamEvent evt in jetStream.StreamAsync(cancellationToken: cancellation.Token))
        {
            if (evt is JetstreamCommitEvent commitEvent)
            {
                string timeStamp = evt.DateTimeOffset.ToLocalTime().ToString("G", CultureInfo.CurrentCulture);
                Console.WriteLine($"{commitEvent.Did} executed a {commitEvent.Commit.Operation} in {commitEvent.Commit.Collection} at {timeStamp}");
            }
        }
    }
    catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
    {
        // Ctrl+C stops the live stream.
    }
    
  2. At the command line enter dotnet run to start watching the jetstream.
  3. Exit the program by pressing CTRL+C
Tip

You may have noticed there's no authentication. The live Jetstream, and its older sibling the Firehose, are open access. No authentication is needed. This is part of why Bluesky is described as a public network.

Warning

Stream events and sequence values come from the server and are not proof of event authenticity. Jetstream v2 delivery is at least once; StreamAsync suppresses inclusive cursor repeats within one enumeration, but cannot ensure exactly-once processing across application restarts or failures. Persist progress only after successfully processing the event and make processing idempotent. The Firehose reader rejects unexpected duplicate or out-of-order stream sequences, but does not verify per-repository commit order; applications that rely on it must track repository revisions themselves.

Move to the application host model

Next, change our console application to a hosted service, using .NET's HostApplicationBuilder. This will allow us to use configuration, application startup and shutdown and dependency injection (DI).

  1. In a console run the following commands
    dotnet add package Microsoft.Extensions.Hosting
    
  2. Open up Program.cs and replace the contents with the following
    using System.Globalization;
    using System.Text;
    
    using idunno.AtProto.Jetstream;
    
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    
    Console.OutputEncoding = Encoding.UTF8;
    
    HostApplicationBuilder builder = new(args);
    builder.Services.AddHostedService<Worker>();
    
    using IHost host = builder.Build();
    await host.RunAsync();
    
    internal sealed class Worker : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            await using var jetStream = new AtProtoJetstream();
    
            try
            {
                await foreach (JetstreamEvent evt in jetStream.StreamAsync(cancellationToken: stoppingToken))
                {
                    if (evt is JetstreamCommitEvent commitEvent)
                    {
                        string timeStamp = evt.DateTimeOffset.ToLocalTime().ToString("G", CultureInfo.CurrentCulture);
                        Console.WriteLine($"{commitEvent.Did} executed a {commitEvent.Commit.Operation} in {commitEvent.Commit.Collection} at {timeStamp}");
                    }
                }
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Host shutdown stops the live stream.
            }
        }
    }
    
  3. Save program.cs and at the command line enter dotnet run to start watching the jetstream.
  4. Exit the program by pressing CTRL+C

We have moved the await foreach loop into BackgroundService.ExecuteAsync and created a host to run it. The host supplies the stopping token and handles CTRL+C, so we no longer need our own console cancellation handler. StreamAsync uses that token to stop the live connection during host shutdown; it also retries transient disconnections while the host is running.

Add a settings file to contain watch words

This time around we're going to have a setting, WatchWords, which will be words that the bot will watch for and react to.

  1. If you are using Windows run the following commands in PowerShell

    New-Item -Path . -Name "appsettings.json"
    New-Item -Path . -Name "BotOptions.cs"
    New-Item -Path . -Name "ValidateBotOptions.cs"
    

    If you are using Linux or MacOS run the following commands

    touch appsettings.json
    touch BotOptions.cs
    touch ValidateBotOptions.cs
    
  2. Open appsettings.json in your editor of choice and add the following with the editor of your choice

    {
      "Bot": {
        "WatchWords": [
          "beans",
          "beansz"
        ]
      }
    }
    
  3. Open BotOptions.cs in your editor of choice and change the contents to the following

    using System.ComponentModel.DataAnnotations;
    
    namespace WatcherBot
    {
        public sealed class BotOptions
        {
            public const string ConfigurationSectionName = "Bot";
    
            [Required]
            public required string[] WatchWords { get; set; }
        }
    }
    
  4. Open ValidateBotOptions.cs in your editor of choice and change the contents to the following

    using Microsoft.Extensions.Options;
    
    namespace WatcherBot
    {
        [OptionsValidator]
        public partial class ValidateBotOptions : IValidateOptions<BotOptions>
        {
        }
    }
    
  5. Open WatcherBot.csproj in the editor of choice and add the following lines before the closing </project>

    <ItemGroup>
      <None Update="appsettings.json">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
    </ItemGroup>
    
  6. Still in WatcherBot.csproj add the following lines before the closing </project>

    <!-- Enable property binding generator for AOT support -->
    <PropertyGroup>
      <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
    </PropertyGroup>
    
  7. Click on Program.cs and make the following changes

    using System.Globalization;
    using System.Text;
    
    using idunno.AtProto.Jetstream;
    
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Microsoft.Extensions.Options;
    
    using WatcherBot;
    
    Console.OutputEncoding = Encoding.UTF8;
    
    HostApplicationBuilder builder = new(args);
    
    builder.Services
        .AddOptions<BotOptions>()
        .Bind(builder.Configuration.GetSection(BotOptions.ConfigurationSectionName));
    
    builder.Services
        .AddSingleton<IValidateOptions<BotOptions>, ValidateBotOptions>();
    
    builder.Services.AddHostedService<Worker>();
    
    using IHost host = builder.Build();
    await host.RunAsync();
    
    internal sealed class Worker : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            await using var jetStream = new AtProtoJetstream();
    
            try
            {
                await foreach (JetstreamEvent evt in jetStream.StreamAsync(cancellationToken: stoppingToken))
                {
                    if (evt is JetstreamCommitEvent commitEvent)
                    {
                        string timeStamp = evt.DateTimeOffset.ToLocalTime().ToString("G", CultureInfo.CurrentCulture);
                        Console.WriteLine($"{commitEvent.Did} executed a {commitEvent.Commit.Operation} in {commitEvent.Commit.Collection} at {timeStamp}");
                    }
                }
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Host shutdown stops the live stream.
            }
        }
    }
    
  8. At the command line enter dotnet build to make sure there aren't any mistakes.

This step binds the watch words to BotOptions; the worker starts using them in the next step.

Examine and react to new Bluesky posts that contain a watch word

Jetstream commit events can be limited by collection NSIDs or by the DID of the actor performing the event. As we want to watch for posts we will limit commit events to the app.bsky.feed.post collection. Collection filtering does not exclude identity, account, or sync events, so the bot still checks for a JetstreamCommitEvent with a create operation and a record before trying to deserialize a Post. The options monitor reads the current watch words from appsettings.json; it can supply updated values without restarting the worker.

  1. Open Program.cs and change the Worker class to take IOptionsMonitor<BotOptions> through its primary constructor.
    internal sealed class Worker(IOptionsMonitor<BotOptions> options) : BackgroundService
    
  2. In ExecuteAsync, filter for new post commits, deserialize each record, and compare its text with the watch words. Use StreamAsync with the host's stopping token.
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await using var jetStream = new AtProtoJetstream(collections: ["app.bsky.feed.post"]);
    
        try
        {
            await foreach (JetstreamEvent evt in jetStream.StreamAsync(cancellationToken: stoppingToken))
            {
                if (evt is not JetstreamCommitEvent { Commit.Operation: JetstreamCommitOperation.Create } commitEvent ||
                    commitEvent.Commit.Record is not JsonElement record)
                {
                    continue;
                }
    
                Post? post;
                try
                {
                    post = JsonSerializer.Deserialize<Post>(record, BlueskyServer.BlueskyJsonSerializerOptions);
                }
                catch (JsonException ex)
                {
                    Console.Error.WriteLine($"Skipping invalid post from {commitEvent.Did}: {ex.Message}");
                    continue;
                }
                catch (ArgumentException ex)
                {
                    Console.Error.WriteLine($"Skipping invalid post from {commitEvent.Did}: {ex.Message}");
                    continue;
                }
    
                if (post is not null && !string.IsNullOrEmpty(post.Text) &&
                    options.CurrentValue.WatchWords.Any(word =>
                        post.Text.Contains(word, StringComparison.OrdinalIgnoreCase)))
                {
                    string timeStamp = evt.DateTimeOffset.ToLocalTime().ToString("G", CultureInfo.CurrentCulture);
                    Console.WriteLine($"{commitEvent.Did} posted in {commitEvent.Commit.Collection} at {timeStamp}");
                    Console.WriteLine(post.Text);
                }
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // Host shutdown stops the live stream.
        }
    }
    
  3. Save Program.cs
  4. At the command line enter dotnet run and watch for posts containing your watch words. Press CTRL+C to stop.

Now that you have your bot detecting posts with your watched words in them you can expand it to flag the post for examination, or even to send a reply to the post, or whatever else you want to do.

StreamAsync reconnects after transient interruptions and suppresses the repeated event from its inclusive cursor during this run. It does not persist a cursor between restarts; after a restart this tutorial bot starts at the live tail, so it can miss posts while stopped. For durable processing, save the sequence after handling each event and see resuming where you left off. An expired live cursor is surfaced rather than skipped; use archive-to-live replay if you need to recover beyond the live service's lookback window. The example checks for case-insensitive substrings in post text, not word boundaries.

Tip

If you want to watch for hash tags, or links to web sites, or mentions you should look at the Post's facets, not the post text.