I want to be able to chat with a bot like I chat with normal people. I don't like those /command
actions Slack gives us. This means we need to find a way to parse interactions with the bot. There are basically two types of messages:
- 1-on-1 conversations with the bot: "ping"
- mentions in a channel to the bot: "@baymax ping"
Now, let's see how we can use commands to implement a "pong" response. Later we will discuss how we can add these commands to a SlackPipeline
object.
This article is part of a series on our new bot-zero-sharp project, that aims to be a jump start for C# Slack bot development.
Bare metal: implement ICommand
We use commands to respond to chat messages. The interface ICommand
is easy to implement. The Message
property can be inspected to see if the message matches your command. If you return true
, you indicate you've handled the message, so the system will stop processing the other commands.
using BotZero.Common.Commands;
using Slack.NetStandard;
public class PingCommand : ICommand
{
private readonly SlackWebApiClient _client;
public PingCommand(SlackWebApiClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
public string[] GetHelpLines() => new string[] { "ping - pings the bot" };
public async Task<bool> Process(CommandContext context)
{
if (context.Message != "ping")
{
return false;
}
var msg = "pong!";
if (!context.IsDirectMessage)
{
msg = $"<@{context.UserId}>, {msg}";
}
await _client.Chat.PostMarkdownMessage(context.ChannelId, msg);
return true;
}
}
It is easy to implement an ICommand
, but it has some boiler plate. The project has a special HelpCommand
class that will use the GetHelpLines()
of each command to compile a list of help items that can be queried by the user.
Note: the PostMarkdownMessage
is an extension method on the Slack client that makes it easier to send markdown messages.
Deriving from CommandBase
You can make the implementation easier by deriving from the CommandBase
class. This base class provides the Client
property and the functions Send
, Reply
and CreateUpdatableMessage
to help you send messages back to the user.
using BotZero.Common.Commands;
using Slack.NetStandard;
public class PingCommand : CommandBase
{
public PingCommand(SlackWebApiClient client) : base(client)
{
}
public override string[] GetHelpLines() => new string[] { "ping - pings the bot" };
public async override Task<bool> Process(CommandContext context)
{
if (context.Message != "ping")
{
return false;
}
await Reply(context, "pong!");
return true;
}
}
We've lost the boilerplate code for reply, but we still need to do some message parsing.
Using regular expressions with RegexCommandBase
What if your expressions become a bit more detailed? You might need a regular expression to capture some input. In the following example, we use a named group to capture how many times we should "pong". To make regular expression handling easier, we derive from RegexCommandBase
.
using BotZero.Common.Commands;
using BotZero.Slack.Common.Commands;
using Slack.NetStandard;
using System.Text.RegularExpressions;
public class PingCommand : RegexCommandBase
{
private readonly static Regex _regex = new("^ping( (?<times>\\d+))?$", RegexOptions.IgnoreCase);
public PingCommand(SlackWebApiClient client) : base(client, _regex)
{
}
public override string[] GetHelpLines() => new string[] {
"ping - pings the bot",
"ping n - pings the bot n times"
};
protected override async Task Callback(Match m, CommandContext context)
{
int times = 1;
if (Int32.TryParse(m.Groups["times"]?.Value, out int t))
{
times = t;
}
for (var i = 0; i < times; i++)
{
await Reply("Pong!");
}
}
}
Pretty cool, right? Well... it is one thing to write a regular expression, but to maintain it, is a whole different game! You might need something more developer friendly.
Enter the CommandMapper
The CommandMapper
is the friendliest of all ICommand
implementations. It will do the matching for you and execute a callback method with parameters:
using BotZero.Commands.Mapping;
using BotZero.Commands.Mapping.Attributes;
using Slack.NetStandard;
[Help("ping - pings the bot", "ping n - pings the bot n times")]
public class PingCommand : CommandMapper
{
public PingCommand(SlackWebApiClient client) : base(client)
{
}
protected async Task Callback(int times = 1)
{
for (var i = 0; i < times; i++)
{
await Reply("Pong!");
}
}
}
The style is inspired by how we interact with .NET API Controllers. We'll use attributes to decorate the class with features:
[Help]
will contain the help strings. Note: can also be used onBaseCommand
implementation.[Command]
can be used to overwrite the name of a command. By default the class name minus the "Command" is used, but you code specify[Command("pang")]
to override that behavior.[Action]
can be used that a method is an action, if no such attribute is present, the system looks for aCallback
method. It can also be used to add an alias to the method.[Authorization]
can be used on the class or the method. Only people with the specified email address can trigger the action(s).
The implementation is a lot cleaner.
A Todo List Example
The CommandMapper
really shines when you have a command with multiple actions. Imagine a todo list action with these actions:
Action | Long command | Short version | Notes |
Add "tic" to a todo list. | todo add tic | todo tic | We would like to omit the add statement to make things easier. |
Show the todo list. | todo list | todo | When we omit the list statement, we need to fix a collision with the add action. |
Remove "tic" from the todo list. | todo remove tic | todo rm tic | The long version of the command may collide with the short version of the add action. When we alias with rm, we also need to solve the collision problem. |
In the implementation we'll see the [Action("")]
multiple times; this means that the name of the action can be omitted from the user input. The [RestParameter]
will capture all the data till the end of the line.
using BotZero.Commands.Mapping;
using BotZero.Commands.Mapping.Attributes;
using BotZero.Commands.Mapping.Parameters;
using Slack.NetStandard;
[Help(
"todo - shows the todo list.",
"todo `{item}` - adds a new item to the list.",
"todo remove `{item}` - removes items that partially match the input."
)]
public class TodoCommand : CommandMapper
{
private static readonly List<string> _todos = new();
public TodoCommand(SlackWebApiClient client) : base(client)
{
}
[Action("")]
protected async Task Add([RestParameter]string item)
{
_todos.Add(item);
await Reply($"Added _{item}_ to the list.");
}
[Action("rm", "del", "rem", "delete")]
protected async Task Remove([RestParameter]string item)
{
var length = _todos.Count;
_todos
.Where(x => x.Contains(item, StringComparison.OrdinalIgnoreCase))
.ToList()
.ForEach(x => _todos.Remove(x));
var i = length - _todos.Count;
if (i == 1)
{
await Reply("1 item was removed.");
}
else
{
await Reply($"{i} item were removed.");
}
}
[Action("", "ls", "dir", "ls")]
protected async Task List()
{
if(_todos.Count == 0)
{
await Reply("The list is empty.");
return;
}
var i = 0;
var str = "The following items are on the list:";
_todos.ForEach(t => str += $"\n{++i}. {t}");
await Reply(str);
}
}
The CommandMapper
will automatically detect the Add
, Remove
and List
actions, because they are decorated with the Action
attribute.
Parameters & defaults
When mapping the action, it also inspects the parameters of the method. For int
and string
parameters it will create some magic to match the input values. We can decorate the parameter to influence the matching logic. Here is a list with examples on what we can do with parameters:
Parameter | Notes |
int item1 | The input will be scanned for a required integer. |
int item1 = 42 | The input will be scanned for an optional integer. When the integer is not present, the method will receive value 42 . |
[IntParameter(42)] | The input will be scanned for an optional integer. When the integer is not present, the method will receive value 42 . You need this if you cannot use an optional parameter, because it is the first parameter in a sequence. |
string item2 | The input will be scanned for a required string. A string cannot contain spaces. If you need spaces in your input, you need to use quotes, like: "value with spaces" . |
string item2 = "1337" | The input will be scanned for an optional string. When the string is not present, the method will receive value "1337" . |
[IpParameter] | The input will be scanned for an required ipv4 address. |
[LabelParameter("feels like")] | The input will be scanned for the required string "feels like" . |
[LabelParameter("feels like", true)] | The input will be scanned for the optional string "feels like" . The value will default to the specified label. |
[ChoiceParameter("home", "school"] | The input will be scanned for one of the required strings. |
[ChoiceParameter(new[] { "home", "school" }, "home")] | The input will be scanned for one of the required strings. If no input is found, it will default to "home" . Note: it is either a match or it is omitted entirely; something else will not match. |
[RegexParameter(".{3}")] | The input is scanned for the specified regular expression and the result is the value. Note: be careful with begin too greedy and the ^$ symbols. |
[RestParameter] | Will capture anything till the end of the string (e.g. .+ ), input is required. |
[RestParameter(true)] | Will capture anything till the end of the string (e.g. .+ ), but input is optional. |
Bringing Commands To The Pipeline
We need a bridge between ICommand
and ISlackRequestHandler
, so we can add commands to a SlackPipeline
. We need to:
- Listen for
MessageCallbackEvent
events, but we would like to skip edits and deletes. - Listen for
AppMention
events. - Strip the text from a bot mention and remove any markdown, as this will mess with our regular expressions.
To tell our commands what is happening, we need to send them a CommandContext
. It is a nice way of harmonizing MessageCallbackEvent
and AppMention
events.
using MarkdownDeep;
using Slack.NetStandard;
using Slack.NetStandard.EventsApi.CallbackEvents;
using System.Text.RegularExpressions;
public class CommandContext
{
private static readonly Markdown _markdownRemover = new()
{
SummaryLength = -1
};
public CommandContext()
{
}
public CommandContext(MessageCallbackEvent message)
{
// remove markdown and whitespaces
Message = _markdownRemover.Transform(message.Text).Trim();
UserId = message.User;
ChannelId = message.Channel;
Timestamp = message.Timestamp;
IsDirectMessage = true;
}
public CommandContext(AppMention mention)
{
// remove mention
Message = Regex.Replace(mention.Text, "^<[^ >]+>\\s+", "");
// remove markdown and whitespaces
Message = _markdownRemover.Transform(Message).Trim();
UserId = mention.User;
ChannelId = mention.Channel;
Timestamp = mention.Timestamp;
IsDirectMessage = false;
}
/// <summary>
/// Indicates the context is from a direct message.
/// </summary>
public bool IsDirectMessage { get; set; } = false;
/// <summary>
/// The message.
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// The user that sent the message.
/// </summary>
public string UserId { get; set; } = string.Empty;
/// <summary>
/// The channel from which the message was sent.
/// </summary>
public string ChannelId { get; set; } = string.Empty;
/// <summary>
/// The ID of the message that was sent.
/// </summary>
public Timestamp Timestamp { get; set; } = new Timestamp();
}
So far, so good, now let's create a CommandHandler
that will act as a bridge between the world of the command and the Slack.NetStandard. It will listen for MessageCallbackEvent
and AppMention
events. It even checks if the ICommand
is an ISlackRequestHandler
that should be executed (so we have the same way of error handling).
using BotZero.Commands;
using BotZero.Common.Commands.Mapping;
using BotZero.Common.Slack;
using Microsoft.Extensions.Logging;
using Slack.NetStandard;
using Slack.NetStandard.EventsApi;
using Slack.NetStandard.EventsApi.CallbackEvents;
using Slack.NetStandard.RequestHandler;
using System.Text.RegularExpressions;
public sealed class CommandHandler : ISlackRequestHandler<object?>
{
private static string? _botUserId = null;
private readonly SlackWebApiClient _client;
private readonly SlackProfileService _profileService;
private readonly ILogger<CommandHandler> _logger;
private readonly List<ICommand> _commands = new();
public CommandHandler(
SlackWebApiClient client,
SlackProfileService profileService,
ILogger<CommandHandler> logger,
ICommand[] commands)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_profileService = profileService ?? throw new ArgumentNullException(nameof(profileService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
HelpCommand = new HelpCommand(client, _commands);
_commands.AddRange(commands);
_commands.Add(HelpCommand);
}
public HelpCommand HelpCommand { get;}
/// <summary>
/// Gets the UserId of the bot user.
/// </summary>
/// <returns>The UserId.</returns>
private async Task<string> GetBotUserId()
{
if (string.IsNullOrEmpty(_botUserId))
{
var auth = await _client.Auth.Test();
auth.EnsureSuccess();
_botUserId = auth.UserId;
}
return _botUserId;
}
bool ISlackRequestHandler<object?>.CanHandle(SlackContext context)
{
if (context.Event is EventCallback evc)
{
if (evc.Event is MessageCallbackEvent message)
{
var skip = message is MessageChanged || message is MessageDeleted;
if (!skip)
{
// remove mention of bot from direct message
if (message.Text.StartsWith("<@"))
{
var botUserId = GetBotUserId().Result;
message.Text = Regex.Replace(message.Text, $"^<@{Regex.Escape(botUserId)}>\\s+", "");
}
var user = _profileService.GetUser(message.User).Result;
var ctx = new CommandContext(message, user);
var task = ProcessCommand(ctx);
return task.Result;
}
}
else if (evc.Event is AppMention mention)
{
var user = _profileService.GetUser(mention.User).Result;
var ctx = new CommandContext(mention, user);
var task = ProcessCommand(ctx);
return task.Result;
}
}
foreach (var command in _commands)
{
if (command is ISlackRequestHandler<object?> srh)
{
try
{
if (srh.CanHandle(context))
{
var task = srh.Handle(context);
task.Wait();
return true;
}
}
catch(Exception ex)
{
var ac = new ActionContext(context);
var task = HandleError(ac, command, ex);
task.Wait();
return true;
}
}
}
return false;
}
async Task<object?> ISlackRequestHandler<object?>.Handle(SlackContext context)
{
await Task.CompletedTask;
return null;
}
private async Task<bool> ProcessCommand(CommandContext context)
{
foreach (var command in _commands)
{
try
{
if (await command.Process(context))
{
return true;
}
}
catch (Exception ex)
{
await HandleError(context, command, ex);
return true;
}
}
return false;
}
private async Task HandleError(CommandContext context, ICommand command, Exception ex)
{
_logger.LogError(ex, $"Error executing command: {context.Message}");
var err = ex.Message.Contains('\n') ? $"```{ex.Message}```" : $"`{ex.Message}`";
var msg = $"Something went wrong! All I got was: {err} from `{command.GetType().FullName}` :scream:";
if (context.IsDirectMessage)
{
msg = $"<@{context.UserId}>, " + msg;
}
await _client.Chat.PostMarkdownMessage(context.ChannelId, msg);
}
}
Also good to note: the HelpCommand
is created at this point. As it needs a list of ICommands
to function, the CommandHelper
is the perfect place for the creation of it. This feature was inspired by hubot-help.
Assembly scanning for commands
But how do we discover all the commands? Assembly scanning! The following extension methods will scan the assembly and register all the ICommand
implementations to our CommandHandler
class. Now we only need to add the handler to our pipeline and our commands will automatically be detected and executed.
using BotZero.Common.Commands;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
public static class Extensions
{
public static IServiceCollection AddCommands(this IServiceCollection provider)
{
return provider.AddCommands(Assembly.GetCallingAssembly());
}
public static IServiceCollection AddCommands(this IServiceCollection provider, params Assembly[] assemblies)
{
var types = assemblies
.SelectMany(x => x.GetTypes())
.Where(x => !x.IsAbstract && x.IsAssignableTo(typeof(ICommand)))
.ToList();
foreach (var type in types)
{
provider.AddTransient(type);
}
provider.AddTransient(services =>
{
var commands = new List<ICommand>();
foreach (var type in types)
{
commands.Add((ICommand)services.GetRequiredService(type));
}
var profileService = services.GetRequiredService<SlackProfileService>();
return new CommandHandler(
services.GetRequiredService<SlackWebApiClient>(),
profileService,
services.GetRequiredService<ILogger<CommandHandler>>(),
commands.ToArray());
});
provider.AddTransient(services =>
{
var handler = services.GetRequiredService<CommandHandler>();
return handler.HelpCommand;
});
return provider;
}
}
Now we can resolve the CommandHandler
and use it to init the pipeline.
Conclusions
With the CommandMapper
it becomes super easy to make a chatbot that will react to your input (without having to fiddle around with regular expressions). Using dependency injection and our CommandHandler
, we can automatically turn our commands into something Slack.NetStandard can understand. The HelpCommand
gives us a nice way of exposing help to our users.