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.
- In a console run the following commands
dotnet new console -n WatcherBot
cd WatcherBot
dotnet add package idunno.Bluesky
- Create a new .NET Command Line project by opening the File menu, and choosing New ▶ Project.
- In the "Create a new project" dialog select C# as the language, choose Console App as the project type then click Next.
- In the "Configure your new project" dialog name the project
WatcherBot and click Next.
- In the "Additional information" dialog choose the Framework as .NET 10.0, uncheck the "Do not use top level statements" check box then click Create.
- Under the Project menu Select Manage NuGet packages, select the Browse tab. Search for
idunno.Bluesky, and click Install.
- Close the Manage NuGet packages dialog.
- Create a new .NET Command Line project by opening the Command Palette (Ctrl + Shift + P) and then search for and select .NET New Project
- In the Create a new .NET Project template search for and select Console App
- Select the folder you want to save your project in
- Name your project
WatcherBot
- Choose the solution format you prefer.
- Press Enter to create the solution.
- Select the
WatcherBot.csproj file in Explorer window.
- Open the Command Palette (Ctrl + Shift + P) and then search for and select Nuget: Add
- Enter
idunno.Bluesky in the package search dialog and choose the latest version.
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.
- Open
Program.cs in the editor of your choice and replace its contents with the followingusing 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.
}
- At the command line enter
dotnet run to start watching the jetstream.
- Exit the program by pressing
CTRL+C
- Click on
Program.cs and replace its contents with the followingusing 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.
}
- Press
f5 to compile and run the program to start watching the jetstream.
- Exit the program by pressing
CTRL+C
- Click on
Program.cs and replace its contents with the followingusing 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.
}
- Run the project by pressing
F5 to start watching the jetstream.
- 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).
- In a console run the following commands
dotnet add package Microsoft.Extensions.Hosting
- Open up
Program.cs and replace the contents with the followingusing 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.
}
}
}
- Save
program.cs and at the command line enter dotnet run to start watching the jetstream.
- Exit the program by pressing
CTRL+C
- Under the Project menu Select Manage NuGet packages, select the Browse tab, ensure that the Include prerelease checkbox is unchecked.
Search for
Microsoft.Extensions.Hosting, and click Install.
- Close the Manage NuGet packages dialog.
- Click on
Program.cs and replace its contents with the followingusing 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.
}
}
}
- Press
f5 to compile and run the program to start watching the jetstream.
- Exit the program by pressing
CTRL+C
- Open the Command Palette (Ctrl + Shift + P) and then search for and select Nuget: Add
- Enter
Microsoft.Extensions.Hosting in the package search dialog and choose the latest 10.x version.
- Click on
Program.cs and replace its contents with the followingusing 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.
}
}
}
- Press
f5 to compile and run the program to start watching the jetstream.
- 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.
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
Open appsettings.json in your editor of choice and add the following with the editor of your choice
{
"Bot": {
"WatchWords": [
"beans",
"beansz"
]
}
}
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; }
}
}
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>
{
}
}
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>
Still in WatcherBot.csproj add the following lines before the closing </project>
<!-- Enable property binding generator for AOT support -->
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
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.
}
}
}
At the command line enter dotnet build to make sure there aren't any mistakes.
- Right click on the
WatcherBot.csproj file in Solution explorer and choose Add ▶ New Item.
- Search for and select
JSON file
- In the Name input box enter
appsettings.json
- Replace the generated contents with the following
{
"Bot": {
"WatchWords": [
"beans",
"beansz"
]
}
}
- Right click on
appsettings.json in Solution Explorer and choose Properties
- Change the
Copy to Output Directory to Copy always. Close the Properties dialog.
- Right click on the
WatcherBot.csproj file in Solution Explorer and choose Add ▶ Class.
- In the name input box enter
BotOptions.cs
- Replace the generated contents with the following
using System.ComponentModel.DataAnnotations;
namespace WatcherBot
{
public sealed class BotOptions
{
public const string ConfigurationSectionName = "Bot";
[Required]
public required string[] WatchWords { get; set; }
}
}
- In the name input box enter
ValidateBotOptions.cs
- Replace the generated contents with the following
using Microsoft.Extensions.Options;
namespace WatcherBot
{
[OptionsValidator]
public partial class ValidateBotOptions : IValidateOptions<BotOptions>
{
}
}
- Click on the WatcherBot project file to open it and add the following lines before the closing
</Project><!-- Enable property binding generator for AOT support -->
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
- Click on
Program.cs and make the following changesusing 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.
}
}
}
- Choose File ▶ Save All
- In the main VS menu choose Build ▶ Build Solution to make sure there aren't any mistakes.
- Right click on the WatcherBot folder in the Explorer window and choose New File.., then call the new file
appsettings.json
- Replace the generated contents with the following
{
"Bot": {
"WatchWords": [
"beans",
"beansz"
]
}
}
- Open the
WatcherBot.csproj file to open it and add the following before the </Project> line<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
- Right click on the WatcherBot folder in the Explorer window and choose New File.., then call the new file
BotOptions.cs
- Replace the generated contents with the following
using System.ComponentModel.DataAnnotations;
namespace WatcherBot
{
public sealed class BotOptions
{
public const string ConfigurationSectionName = "Bot";
[Required]
public required string[] WatchWords { get; set; }
}
}
- Right click on the WatcherBot folder in the Explorer window and choose New File.., then call the new file
ValidateBotOptions.cs
- Replace the generated contents with the following
using Microsoft.Extensions.Options;
namespace WatcherBot
{
[OptionsValidator]
public partial class ValidateBotOptions : IValidateOptions<BotOptions>
{
}
}
- Open
WatcherBot.csproj add the following lines before the closing </project><!-- Enable property binding generator for AOT support -->
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
- Click on
Program.cs and make the following changesusing 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.
}
}
}
- Choose File ▶ Save All
- Open the Command Palette (Ctrl + Shift + P) and search for, and select .NET: 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.
- Open
Program.cs and change the Worker class to take IOptionsMonitor<BotOptions> through its primary constructor.internal sealed class Worker(IOptionsMonitor<BotOptions> options) : BackgroundService
- 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.
}
}
- Save
Program.cs
- At the command line enter
dotnet run and watch for posts containing your watch words. Press CTRL+C to stop.
- Open
Program.cs and give Worker a primary constructor that takes IOptionsMonitor<BotOptions>.internal sealed class Worker(IOptionsMonitor<BotOptions> options) : BackgroundService
- 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.
}
}
- Choose File ▶ Save All
- Press
F5 to compile and run the program, and watch for matching posts. Stop debugging to exit.
- Open
Program.cs and give Worker a primary constructor that takes IOptionsMonitor<BotOptions>.internal sealed class Worker(IOptionsMonitor<BotOptions> options) : BackgroundService
- 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.
}
}
- Choose File ▶ Save All
- Press
F5 to compile and run the program, and watch for matching posts. Stop debugging to exit.
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.