Chatting with bot-zero-sharp

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 on BaseCommand 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 a Callback 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:

ActionLong commandShort versionNotes
Add "tic" to a todo list.todo add tictodo ticWe would like to omit the add statement to make things easier.
Show the todo list.todo listtodoWhen we omit the list statement, we need to fix a collision with the add action.
Remove "tic" from the todo list.todo remove tictodo rm ticThe 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.
A simple todo list command: add, remove and list.

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:

ParameterNotes
int item1The input will be scanned for a required integer.
int item1 = 42The 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 item2The 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:

  1. Listen for MessageCallbackEvent events, but we would like to skip edits and deletes.
  2. Listen for AppMention events.
  3. 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.

expand_less