When working on the bot-zero-sharp project, we had the need to decouple the request handler and the creation of the chatbot. We like our request handlers to be automatically detected and added to our pipeline. In this article we'll discuss how we use background services to create a chatbot that uses dependency injection.
Our chatbot is based on the Slack.NetStandard implementation of the Slack API. We've borrowed a lot of our setup from Running a Socket Mode Slack App via an ASP.Net Core website by Steven Pears, the creator of the Slack.NetStandard package.
Config
Our chatbot will use Socket Mode to listen to incoming messages. It uses the web API to communicate back to Slack. To make things easier, we'll configure the bot using this class:
public class SlackConfiguration
{
public string? Token { get; set; }
public string? AppToken { get; set; }
public int MaxConcurrency { get; set; } = 32;
}
Decoupling the response pipeline
We want to decouple the response to a Slack interaction and the implementing that response. In Slack.NetStandard we can do this by adding a SlackPipeline
. To make things easier, we would love to scan the assembly for request handlers and inject them into the bot. So let's create a locator class:
using Slack.NetStandard.RequestHandler;
public class SlackRequestHandlerLocator
{
public ISlackRequestHandler<object?>[] Handlers { get; }
public SlackRequestHandlerLocator(ISlackRequestHandler<object?>[] handlers)
{
Handlers = handlers;
}
}
Now we need to scan the assembly for ISlackRequestHandler
implementation classes and register them into the SlackRequestHandlerLocator
. We'll use these extension methods to hook everything up using dependency injection:
using BotZero.Common.Commands;
using Microsoft.Extensions.DependencyInjection;
using Slack.NetStandard.RequestHandler;
using System.Reflection;
public static class Extensions
{
public static IServiceCollection AddSlackRequestHandlers(this IServiceCollection provider, bool skipCommands)
{
return provider.AddSlackRequestHandlers(skipCommands, Assembly.GetCallingAssembly());
}
public static IServiceCollection AddSlackRequestHandlers(this IServiceCollection provider, bool skipCommands, params Assembly[] assemblies)
{
var types = assemblies
.SelectMany(x => x.GetTypes())
.Where(x => !x.IsAbstract && x.IsAssignableTo(typeof(ISlackRequestHandler<object?>)))
.Where(x => !skipCommands || !x.IsAssignableTo(typeof(ICommand)))
.ToList();
foreach (var type in types)
{
provider.AddTransient(type);
}
provider.AddTransient(services =>
{
var handlers = new List<ISlackRequestHandler<object?>>();
foreach (var type in types)
{
handlers.Add((ISlackRequestHandler<object?>)services.GetRequiredService(type));
}
var commandHandler = services.GetService<CommandHandler>();
if (commandHandler != null)
{
// make sure the command handler is executed first,
// this helps to display errors in the UI
handlers.Insert(0, commandHandler);
}
return new SlackRequestHandlerLocator(handlers.ToArray());
});
return provider;
}
}
Now we can inject a SlackRequestHandlerLocator
into our bot, which can use to populate the SlackPipeLine
.
Note how the CommandHandler
is prioritized in our pipeline. More on commands and chat interactions with the bot, can be read here: Chatting with bot-zero-sharp.
Receiving messages from SocketMode
So, we borrowed most of the code from the original blog, but we've made a few changes:
- We're using an
IOptions<SlackConfiguration>
to inject theAppToken
into the class, which we use to construct ourSocketModeClient
. - Instead of directly acknowledging an envelope we wait for 200 milliseconds. This helps a bit with the user interaction, as it will keep the Slack spinner somewhat longer visible. Remember: we don't respond through the envelope, but communicating directly through the web API, so updates land in a different way into the Slack interface.
This is the SocketModeService
background service:
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Slack.NetStandard.AsyncEnumerable;
using Slack.NetStandard.Socket;
using System.Net.WebSockets;
using System.Threading.Channels;
public class SocketModeService : BackgroundService
{
private SocketModeClient? _client;
private readonly SlackConfiguration _config;
private readonly Channel<Envelope> _channel;
public SocketModeService(IOptions<SlackConfiguration> config, Channel<Envelope> channel)
{
_config = config?.Value ?? throw new ArgumentNullException(nameof(config));
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_client = new SocketModeClient();
await _client.ConnectAsync(_config.AppToken, cancellationToken);
await base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_client == null) throw new Exception("Slack client was not initialized.");
while (!stoppingToken.IsCancellationRequested)
{
await foreach (var envelope in _client.EnvelopeAsyncEnumerable(stoppingToken))
{
await PassEnvelope(envelope, stoppingToken);
Ack(envelope, stoppingToken);
}
}
}
private async Task PassEnvelope(Envelope envelope, CancellationToken token)
{
await _channel.Writer.WriteAsync(envelope, token);
}
private void Ack(Envelope envelope, CancellationToken stoppingToken)
{
// TODO: ack right away or wait a bit?
// when we ack, the spinner goes away
var task = new Task(async () =>
{
await Task.Delay(200);
var ack = new Acknowledge { EnvelopeId = envelope.EnvelopeId };
if (_client != null && _client.WebSocket != null && _client.WebSocket.State == WebSocketState.Open)
{
await _client.Send(JsonConvert.SerializeObject(ack), stoppingToken);
}
});
task.Start();
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
if (_client != null && _client.WebSocket != null && _client.WebSocket.State == WebSocketState.Open)
{
await _client.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "App shutting down", cancellationToken);
}
}
}
The Chatbot
Now we are ready to consume the envelop channel and dispatch our message. Again, we made some changes to the code of the original blog:
- We added support for scoped dependency injections. Why? Well, we're injecting our
ISlackRequestHandler
to decouple our request handlers from our bot. They might depend on something that requires a scoped lifetime, like Entity Framework. That's why we need to create a new scope, every time we fire off an event. - We process pipelines on a new task. This helps us to run multiple requests at the same time.
- We're using a
SemaphoreSlim
to limit the number of concurrent requests, as not to overwhelm te bot. This number can be changed throughSlackConfiguration.MaxConcurrency
.
Here is the code for the ChatBot:
using BotZero.Common.Slack;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Slack.NetStandard.RequestHandler;
using Slack.NetStandard.Socket;
using System.Threading.Channels;
/// <summary>
/// This background service acts as the chat bot, responding input.
/// The commands will respond to typed text.
/// </summary>
public class ChatBot : BackgroundService
{
private readonly Channel<Envelope> _channel;
private readonly ILogger<ChatBot> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly SlackConfiguration _config;
public ChatBot(
Channel<Envelope> channel,
ILogger<ChatBot> logger,
IOptions<SlackConfiguration> config,
IServiceScopeFactory scopeFactory)
{
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_config = config?.Value ?? throw new ArgumentNullException(nameof(config));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var semaphore = new SemaphoreSlim(_config.MaxConcurrency);
while (!stoppingToken.IsCancellationRequested)
{
await _channel.Reader.WaitToReadAsync(stoppingToken);
if (_channel.Reader.TryRead(out var envelope))
{
Process(semaphore, envelope);
}
}
}
private void Process(SemaphoreSlim semaphore, Envelope? envelope)
{
var task = new Task(async () =>
{
semaphore.Wait();
try
{
// Use a new scope for executing the handlers. Some might
// need a scoped instance (like when it depends on EF)
using var scope = _scopeFactory.CreateScope();
var locator = scope.ServiceProvider.GetRequiredService<SlackRequestHandlerLocator>();
var pipeline = new SlackPipeline<object?>(locator.Handlers);
await pipeline.Process(envelope);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error while processing the pipeline.");
//an error here should not break/kill the application
//throw;
}
finally
{
semaphore.Release();
}
});
task.Start();
}
}
Here we see the usage of the SlackRequestHandlerLocator
in action.
Now, what we are doing might be a bit vague, so let's create a diagram of how our classes work:
socket mode service -> channel <- chatbot
-> pipeline
-> command handler
-> process chat command
-> process commands that implement a request handler
-> process other slack request handlers
Dependency Injection
The last thing we need to discuss is how to configure our dependency injection layer for our bot. We've added a AddChatBot
extension method to make things easier. We'll need to do:
- Create a Slack.NetStandard
SlackWebApiClient
for our interaction with Slack. - Add our commands, so we can chat with the chatbot.
- Add our request handler locator, so we can create the pipeline.
- Add our channel communication between the
ChatBot
andSocketModeService
. - Add our
SocketModeService
background service. - Add our
ChatBot
background service.
Here's the implementation:
public static IServiceCollection AddChatBot(this IServiceCollection provider)
{
return provider.AddChatBot(Assembly.GetCallingAssembly());
}
public static IServiceCollection AddChatBot(this IServiceCollection provider, params Assembly[] assemblies)
{
return provider
.AddTransient(provider =>
{
var config = provider.GetRequiredService<IOptions<SlackConfiguration>>()?.Value;
return new SlackWebApiClient(config?.Token);
})
.AddCommands(assemblies)
.AddSlackRequestHandlers(true, assemblies)
.AddSingleton(System.Threading.Channels.Channel.CreateUnbounded<Envelope>())
.AddHostedService<SocketModeService>()
.AddHostedService<ChatBot>();
}
Conclusion
Dependency injection rules! It can make our chatbot very configurable and helps to decouple our bot from the implementation of handlers. With assembly scanning, we'll make it easier for our users to create new handlers: just add your handler and the system will pick it up.