Reading Time: 6 minutes

In this post, I want to show you my personal approach to how I solved the “Single-File OOP-Challenge” that I posted last week. Disclaimer before we start: this is one approach and surely you can do more or you could do things differently. So, don’t take it as a “this is the best approach” or “this is the truth” but read it with an open mind and think how you (would) have done it differently.

Getting an overview

First, we should have a look at what the code base is trying to achieve. This does not involve any writing or changing of the code yet! Even though we might find unused variables, inefficiencies, etc. We have a single file index.php that initializes a BotMan instance with 3 commands (!hello, !projectdump, and !help) to be available in Slack. The code is running on PHP 7.4, with a ngrok container inside the docker-compose.yml to make our available to Slack as a webhook. The secrets and configs are defined in an array directly inside our code.

Before we do anything with the code, I would like to have the answer to one question: is it even running? So I’m configuring the endpoint in Slack, struggle with configuring ngrok and finally, make the bot work.

Starting with the obvious

When starting to refactor code, I usually go from more general aspects to little details. Yet, sometimes it’s a good start to tackle the most obvious thing. In this case, for me, it’s the sharing of secrets and configurations as hard-coded, committed PHP arrays (https://12factor.net/config).

So, my first step is to install a DotEnv library (composer require vlucas/phpdotenv) and extract the secrets to my local .env.local. Why not .env? Honestly, just a personal convention 🤷. Others might choose to use a .env.dist / .env.example and a .env file. Basically, I want to be fine with committing some configurations (e.g., for docker) but keep my secret tokens for my local machine.

require_once 'vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__, '.env.local');
$dotenv->safeLoad();

$config = [
    "slack" => [
        "token" => $_ENV['SLACK_TOKEN']
    ],
    'GITLAB_TOKEN' => $_ENV['GITLAB_TOKEN'],
		// ...   
];

https://github.com/moritzwachter/refactoring-challenge-2/commit/95ff37ad44f96e62e2714a27d93fc5955c70277c

Getting an overview

When we have an abstract look on the index.php, we will find 2 or 3 sections:

  • Initialization
  • Commands logic
  • Botman→listen() call.

Let’s start with moving all three commands into a separate class Chatbot and temporarily store them there. By the way, after each little step, I make sure that my bot is still running in Slack! “Baby-steps” as Kent Beck likes to say (https://twitter.com/kentbeck/status/1260938674440515585). It’s a bit tedious to do it manually but it’s fine for now.

I adjust the needed dependencies and inject them into our Chatbot class. Finally, I moved the listen() call into the Chatbot too, as it shouldn’t stay in our entry point file which is only there for initializing our application.

https://github.com/moritzwachter/refactoring-challenge-2/commit/233f49ac95abe38ad8905ef7201d76f12d4c4d2a

Exploring options

The callback

Next, let’s have a look at how these commands are actually working!

/**
 * @param array|string $pattern the pattern to listen for
 * @param Closure|string $callback the callback to execute. Either a closure or a Class@method notation
 * @param string $in the channel type to listen to (either direct message or public channel)
 * @return Command
 */
public function hears($pattern, $callback, $in = null)

Interesting! So $callback could either be an anonymous function or we can use a string to point to the class and method. The BotMan documentation gives us an example for the latter:

https://botman.io/2.0/receiving

$botman->hears('foo', 'Some\Namespace\MyBotCommands@handleFoo');

class MyBotCommands {
    public function handleFoo($bot) {
        $bot->reply('Hello World');
    }
}

But I’m not a big fan of the string annotation there. If we could at least use MyBotCommands::class, we wouldn’t have issues with namespace changes later on. Can we use __invoke()? Let’s dig a little deeper in the BotMan library!

/**
 * Make an action for an invokable controller.
 *
 * @param string $action
 * @return string
 * @throws UnexpectedValueException
 */
protected function makeInvokableAction($action)
{
    if (! method_exists($action, '__invoke')) {
        throw new UnexpectedValueException(sprintf(
            'Invalid hears action: [%s]', $action
        ));
    }

    return $action.'@__invoke';
}

/**
 * @param mixed $callback
 * @return mixed
 * @throws UnexpectedValueException
 * @throws NotFoundExceptionInterface
 */
protected function getCallable($callback)
{
    if (is_callable($callback)) {
        return $callback;
    }

    if (strpos($callback, '@') === false) {
        $callback = $this->makeInvokableAction($callback);
    }

    [$class, $method] = explode('@', $callback);

    $command = $this->container ? $this->container->get($class) : new $class($this);

    return [$command, $method];
}

Cool! So we could actually create a class per command and use the __invoke method to call it. Let’s give it a try with an easy one:

$this->botman->hears('!hello', HelloCommand::class);
<?php

namespace App\Command;

use BotMan\BotMan\BotMan;

class HelloCommand
{
    public function __invoke(BotMan $bot)
    {
        $bot->reply('Hello yourself.');
    }
}

It works, but it might be tricky when adding additional parameters like we need them for the !projectdump command.

The command pattern

As we saw at the beginning of the last chapter, besides the $callback parameter, the hears(...) method has another interesting one: $pattern! Indeed, when we dig a little deeper into the library, we can verify that $pattern can be a regular expression:

/**
 * @param IncomingMessage $message
 * @param Answer $answer
 * @param string $pattern
 * @param Matching[] $middleware
 * @return int
 */
public function isPatternValid(IncomingMessage $message, Answer $answer, $pattern, $middleware = [])
{
    $this->matches = [];

    $answerText = $answer->getValue();
    if (is_array($answerText)) {
        $answerText = '';
    }

    $pattern = str_replace('/', '\/', $pattern);
    $text = '/^'.preg_replace(self::PARAM_NAME_REGEX, '(?<$1>.*)', $pattern).' ?$/miu';

    $regexMatched = (bool) preg_match($text, $message->getText(), $this->matches) || (bool) preg_match($text, $answerText, $this->matches);

    // ...

    return $regexMatched;
}

Great! Having a look at the documentation, we can even find an example with additional parameters: https://botman.io/2.0/receiving#matching-regular-expressions

$botman->hears('I want ([0-9]+) portions of (Cheese|Cake)', function ($bot, $amount, $dish) {
    $bot->reply('You will get '.$amount.' portions of '.$dish.' served shortly.');
});

So for our case, we can use a more flexible approach like this:

$this->botman->hears('^(!\S+)( .+){0,1}$', function (BotMan $bot, $command, $parameters) {
    if ($bot->getMessage()->isFromBot()) {
        return;
    }

	$bot->reply($command);
    $bot->reply($parameters);
});

Now, we can extract every command into its own class and execute it whenever the command matches. Furthermore, with the command having its own class, we can easily write unit tests for it now!

Check out the whole diff at:
https://github.com/moritzwachter/refactoring-challenge-2/commit/79de6aca7759d7e672d1e7b15e7dd904d6aabbce

Extracting all commands

Going further, we can extract the !projectdump command into a separate class and write unit tests for its functionality. After having a closer look at what the code is trying to accomplish and some browsing through the Gitlab API documentation, I found a much easier way to find a specific artifact of the latest job on a specific branch. So we only need to build a download URL and send it to the chat.

https://github.com/moritzwachter/refactoring-challenge-2/commit/83cf62924ea7811803629906e6468e1708522fbd

Eventually, we can have a look at the !help command. The command is basically a list of available commands with descriptions. Now that we have the command already inside the classes, we can also add the descriptions to every command. Adding some more tests, I’m pretty pleased with the result we achieved so far.

https://github.com/moritzwachter/refactoring-challenge-2/commit/10a43b57e238fcc7e8a6bad08297cf79af585c14

Looking back at the initial requirements of the challenge, our code is:

  • testable – we have unit tests for every command
  • object-oriented – including a CommandInterface
  • reusable – you can throw away the unnecessary commands and reuse the code for any other BotMan implementation (doesn’t even have to be Slack!)
  • configurablethrough configuration, you could use the GitlabDumpCommand for every other project or instance that is creating a DB dump as an artifact.
  • extendable – we can add new commands

Do you need it?

Obviously, there is room for improvement and there are ways to make the code more flexible. I don’t like the way new commands are initialized. We still need to adjust our index.php to initialize the classes, their dependencies, in addition to adding the implementation itself. This is where Autowiring comes in really handy! We could add every class that implements the CommandInterface to a CommandCollection and use it instead of the $commands array. Speaking of arrays, this is the other thing that could be improved. Right now, the structure and required fields inside the $config array are very unclear. With DTOs, we could add validation and make it more transparent what kind of data is expected in a specific command.

In the end, the question always is: do you need it? We have improved the code base, added tests, made it more reusable and robust, and we know of ways how to improve it further if the need is there. Once again the disclaimer: this is just one proposal of an approach to solving the challenge! There is a multitude of different approaches and I am very looking forward to seeing your solutions as a fork to the repository! 🙂

Moritz Wachter

Author Moritz Wachter

More posts by Moritz Wachter