Building Slack Slash Commands For Fun And Profit

30 May 2016


I’ve been recently learning about and using expressive, a microframework made in PHP that uses PSR-7.

In this post, I’d like to walk through how I made the /jira Slack slash command. If you want to follow along, you can check out the repo here., and then run composer install to get all the dependencies installed. Also, I’m using PHP 7, so be sure to have that running if you’d like to run these examples. Mainly, I’m using the ultra awesome ?? null coalesce operator, which makes so many things so much easier and cleaner and more fun to write. Read up on it here if you don’t know about it already.

It’s been a lot of fun to make some tools for my team to help improve their workflows, and automate a lot of tedious tasks using it.

If you’re not already familiar with Slack slash commands, here’s how they work:

You run /$command [ args ], inside of Slack and Slack will send a request to an endpoint of your choosing, and you can respond, and do whatever work you need.

Here’s a partial list of what I’ve been able to make using slash commands.


/jenkins [run] [jobName] [ options ]
-p|--public          publicly post option
-s|--status          get job statuses
-l|--last <integer>  get last n jobs
-f|--filter <string> regex filter jobs

For /jenkins, it’s really handy to be able to see the status of the last $n jobs, or kick off a particular job. It’s also really handy to do this while eating lunch, or wherever you find yourself away from your terminal and/or not connected to your VPN that can talk to Jenkins.


/rebase [featureBranch] [repo]

/rebase is used in our current workflow to rebase your work on top of master. For us, we like to have our Pull Requests exactly 1 commit ahead, and 0 behind, so when we’re ready to merge, our history looks nice and tidy, and git bisect is easy to use. Having this in Slack might seem like overkill, but it’s particularly awesome when:

  • You’re in the middle of something else, and your PR is ready to be merged.
  • There’s a one or more PR’s ahead of yours, and after merging them (we use the --no-ff option), your PR is behind.

We also have an endpoint set up that notifies the author on Slack when their PR has enough peer reviewed approvals, but isn’t 1 commit ahead and 0 behind. Slack will include the /rebase command right in the message so the author is able to copy paste the command right back into Slack and everything gets taken care of automagically. Boom. Hooray automation!


/mailgun [sync|add|get|forward] [ options ]
-p|--public          publicly post option
-v|--valid?          check to see if mailgun has valid records when using `get` command
-f|--from <string>   from address when using `forward` command
-t|--to <string>     to address when using `forward` command
-l|--list            list routes for `forward` command
--filter <string>    regex filter for routes when using `forward` command

/mailgun makes calls using the mailgun API. It’s pretty self explanatory, but being able to do things without logging into mailgun is a productivity booster.


/twilio accountname search|show_errors sms|voice [ options ]
-p|--public          publicly post option
-f|--from <string>   filter by from number when using `search`
-t|--to <string>     filter by to number when using `search`
--sid <string>       filter by SID when using `search`
--status <string>    filter by call status for `voice` when using `search`
-s|--start <string>  filter by voice|sms started after midnight on a date
-e|--end <string>    filter by voice|sms started before midnight on a date
-c|--code <string>   search for error code(s) (separated by spaces) when using `show_errors`
--error <string>     regex search for twilio error message `show_errors`
-l|--limit <integer> limit results when using `search` or `show_errors

/twilio is useful for easily troubleshooting and querying twilio. Also super nice to not have to login and check on an error when you’re able to do it right from the comfort of Slack.


/jira show issuesKey(s)[] [ options ]
-p|--public          publicly post option

/jira was made out of the idea that it’s really nice to be able to reference JIRA issues using their issue key in Slack without having to build/grab a link. It shows a nice summary, and can show multiple issues.

In this post, I’ll be showing you how I made /jira using expressive, and PHP 7. You can check out the repo here


Set up

I’m using composer, so if you want to follow along, run composer self-update if you haven’t in a while. Then run composer create-project zendframework/zend-expressive-skeleton $projectDir

The Zend Expressive Skeleton gives you a lot of things for free. It’s probably a bit overkill for what we need to do, but it makes things a little more magical, and gets you up and going really quickly, especially if you’re not already familiar with expressive.

It gives you a nice CLI interface to pick which router, dependency injection container, templating engine, and error handler you want to use. One really interesting thing about expressive is most things are swappable, so if you decide at some point you need/want a different way to route things, it’s easy to drop something else in without too much pain.

In my opinion, the biggest strength of expressive is through the convention of creating middleware pieces that look something like this:

 1 <?php
 2 
 3 namespace App\MiddlewareExample
 4 
 5 /* use statements */
 6 
 7 class MiddlewareExample
 8 {
 9     public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next = null)
10     {
11         // do something interesting
12         return $next($requst, $response, $error ?? null);
13     }
14 }

The above way of doing things makes it possible to build really small bits of functionality that are really easy to reason about, and test. Building things in this way has felt like building with LEGOs, which is super fun.

Config

Some of the boilerplate code is already in place for you when you use the skeleton. Some of the important bits are:

  • /config/autoload/** This is where your config files go. Expressive will autoload things for you in there, and you can fetch them like this: $container->get('config'); Something that’s nice is that *.local.php is ignored by git, so it’s a good place to keep secrets, like tokens, and credentials, or local configuration that will override your *.global.php config files with the same name.

In the config/autoload directory, you’ll find:

  • routes.global.php This is where you set up your application routes. It’s dead simple.
1 <?php
2     // ... other config 
3 'routes' =>
4     [
5         'name' => 'routeName',
6         'path' => '/path/you/care/about',
7         'middleware' => [MiddlewareStack::class], // this can be a single item, or an array of middleware classes or a middleware pipelines
8         'allowed_methods' => ['GET'], // HTTP methods you want to accept
9     ],
  • middleware-pipeline.global.php

Also an array of configuration. There are lots of comments in that file to help understand what section does what.

  • dependencies.global.php

This is where you register your classes, and tell expressive if they need a factory or if they can be invoked without any dependencies.


Something else that is handy is after getting set up with composer, you can run composer serve, which is just a wrapper around running a local PHP server using php -S 0.0.0.0:8080 -t public/ public/index.php, and then visit it in your browser 0.0.0.0:8080/

Since composer times out after 300 seconds, it’s easier to just run the command directly when developing things.

There’s also

  • composer cs (phpcs)
  • composer cs-fix (phpcbf)
  • composer test (phpunit)

First steps

The first thing I did after setting up the skeleton is replace the HomePageAction HTMLResponse with a JSONReponse changeset 6b21dd here

The reason you can get to it, and it works the way it does is because of routes.global.php

1 <?php
2 
3 'routes' => [
4         [
5             'name' => 'home',
6             'path' => '/',
7             'middleware' => App\Action\HomePageAction::class,
8             'allowed_methods' => ['GET'],
9         ],

The next thing I did was add a Slack Jira Pipeline. changeset cd6bda All you need is to add the route you want, and register it with expressive.

// routes.global.php
+ [
+            'name' => '/slack_jira',
+            'path' => '/slack_jira',
+            'middleware' => [SlackJiraPipeline::class],
+            'allowed_methods' => ['POST'],
+        ],

// dependencies.global.php

'factories'  => [
    Application::class       => ApplicationFactory::class,
    Helper\UrlHelper::class  => Helper\UrlHelperFactory::class,
+ SlackJiraPipeline::class => SlackJiraPipeline::class,

All that pipeline looks like is this:

 1 <?php
 2 
 3 namespace App\Pipeline;
 4 
 5 use App\Validator\ValidateBody;
 6 use Interop\Container\ContainerInterface;
 7 use Zend\Stratigility\MiddlewarePipe;
 8 
 9 class SlackJiraPipeline
10 {
11     public function __invoke(ContainerInterface $container)
12     {
13         $pipeline = new MiddlewarePipe();
14         $pipeline->pipe($container->get(ValidateBody::class));
15 
16         return $pipeline;
17     }
18 }

That tells expressive to route through your pipeline in the order you define. Really straightforward so far. The only piece of middleware currently in this pipeline is one we’ll create called ValidateBody.

All it does is make sure an incoming request with a body is valid. One bit of setup you have to do is add BodyParamsMiddleware to middleware-pipeline.global.php. This middleware comes with expressive, and allows you to call $request->getParsedBody(), and return an associative array with the body contents, which we’ll need for our ValidateBody middleware. You can also add strategies to this to parse your incoming bodies however makes the most sense for your application.

Also, putting it in middleware-pipeline.global.php makes it always run this to make it available everywhere instead of having to make sure it’s on every middleware stack

'routing' => [
             'middleware' => [
                 ApplicationFactory::ROUTING_MIDDLEWARE,
                 Helper\UrlHelperMiddleware::class,
+                Helper\BodyParams\BodyParamsMiddleware::class,

Onto the middleware we want to build. Here’s all ValidateBody does:

 1 <?php
 2 
 3 class ValidateBody
 4 {
 5     public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
 6     {
 7         $body = $request->getParsedBody();
 8         if (!$body) {
 9             throw new InvalidArgumentException("Invalid Body");
10         }
11         return $next($request, $response);
12     }
13 }

It calls $request->getParsedBody(), and if it’s empty, we’ll throw. That’s it! This pattern is going to become very familiar.

Throwing in a middleware will kick the request out to your error handler. That way we can cut the request short at any step if we don’t want to continue for whatever reason. Super handy.

After setting that class up, we’ll need to register it with expressive in dependencies.global.php

1 'invokables' => [
2              // Fully\Qualified\InterfaceName::class => Fully\Qualified\ClassName::class,
3              Helper\ServerUrlHelper::class => Helper\ServerUrlHelper::class,
4 +            ValidateBody::class           => ValidateBody::class,

This tells expressive that when it’s autoloading it, since it’s in the invokables section that it can start using it right away without fetching any dependencies.

Testing middleware

To give you a basic example of how to write a middleware test, I went ahead and added a couple of tests in my tests directory for the Validate body middleware f66c9e changeset. (also something expressive skeleton gets you set up with)

 1 <?php
 2 
 3 namespace AppTest\Validator;
 4 
 5 use App\Validator\ValidateBody;
 6 use Psr\Http\Message\ServerRequestInterface;
 7 use Zend\Expressive\Container\Exception\InvalidArgumentException;
 8 use Zend\Stratigility\Http\ResponseInterface;
 9 
10 class ValidateBodyTest extends \PHPUnit_Framework_TestCase
11 {
12     private $validateBody;
13 
14     public function setUp()
15     {
16         $this->validateBody = new ValidateBody();
17     }
18 
19     /**
20      * @test
21      * * @expectedException InvalidArgumentException
22      */
23     public function itWillThrowAnExceptionWhenBodyIsInvalid()
24     {
25         /** @var ServerRequestInterface $request */
26         $request = $this->prophesize(ServerRequestInterface::class);
27         /** @var ResponseInterface $response */
28         $response = $this->prophesize(ResponseInterface::class);
29         $next     = function ($request, $response) {
30             return $response;
31         };
32         $request->getParsedBody()->shouldBeCalled()->willReturn([]);
33         $result   = $this->validateBody->__invoke($request->reveal(), $response->reveal(), $next);
34     }
35 
36     /**
37      * @test
38      */
39     public function itWillNotThrowAnExceptionForValidBody()
40     {
41         /** @var ServerRequestInterface $request */
42         $request = $this->prophesize(ServerRequestInterface::class);
43         /** @var ResponseInterface $response */
44         $response = $this->prophesize(ResponseInterface::class);
45         $next     = function ($request, $response) {
46             return $response;
47         };
48         $request->getParsedBody()->shouldBeCalled()->willReturn(["foo" => "bar"]);
49         $result = $this->validateBody->__invoke($request->reveal(), $response->reveal(), $next);
50         $this->assertInstanceOf(ResponseInterface::class, $result);
51     }
52 }

We’re testing both code paths. A request with a invalid body, and one with a valid body. Really simple.

Next Steps

We’re going to want to validate the incoming request is actually coming from Slack. We’ll do this by building a middleware piece that checks the request body’s token. 68f455 changeset

In SlackJiraPipeline.php, we add our Slack Validate token middleware after the ValidateBody middleware

{
         $pipeline = new MiddlewarePipe();
         $pipeline->pipe($container->get(ValidateBody::class));
+        $pipeline->pipe($container->get(ValidateSlackToken::class));
 
         return $pipeline;
     }

Here’s ValidateSlackToken.php

 1 <?php
 2 
 3 namespace App\Validator\Slack;
 4 
 5 use Psr\Http\Message\ResponseInterface;
 6 use Psr\Http\Message\ServerRequestInterface;
 7 use Zend\Expressive\Container\Exception\InvalidArgumentException;
 8 
 9 class ValidateSlackToken
10 {
11     /** * @var array */
12     private $validTokens;
13 
14     public function __construct(array $validTokens)
15     {
16         $this->validTokens = $validTokens;
17     }
18 
19     public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
20     {
21         $body  = $request->getParsedBody();
22         $token = $body['token'] ?? "";
23         if ($this->validTokens[$request->getUri()->getPath()] != $token) {
24             throw new InvalidArgumentException("Invalid Slack Token");
25         }
26 
27         return $next($request, $response, $error ?? null);
28     }
29 }

All it’s doing is getting the parsed body from the request, (which we know is valid, thanks ValidateBody), and then getting the token from the request (where Slack puts it), and checks it against valid slack tokens. If there’s a match, we continue onto the next middleware piece, and if not, we throw.

This class has a dependency, which is $this->validTokens, which is an array of key values that look something like this:

1 <?php
2 
3 [
4     '/route1' => 'slackToken',
5     '/route2' => 'anotherSlackToken',
6 ]

The reason why we’re injecting all possible valid tokens into this class will become clear later. What it allows is for our factory to remain stateless. This ends up making life much easier down the road for numerous reason. (more on that later)

When we have a class with dependencies, we’ll need to tell expressive to instantiate it by using a factory. ValidateSlackTokenFactory.php in this case

 1 <?php
 2 
 3 namespace App\Validator\Slack;
 4 
 5 use Interop\Container\ContainerInterface;
 6 
 7 class ValidateSlackTokenFactory
 8 {
 9     public function __invoke(ContainerInterface $container)
10     {
11         $validTokens = $container->get('config')['slack_config']['tokens'] ?? [];
12 
13         return new ValidateSlackToken($validTokens);
14     }
15 }

Here, we’re getting the entire config array (autoloaded by expressive), and then narrowing our focus to just slack_config, and then to tokens.

In tandem with the factory, we’ll need a config file that looks like this:

 1 <?php
 2 
 3 return [
 4     'slack_config' => [
 5         'tokens' => [
 6             '/slack_jira' => 'token',
 7             // other routes => tokens would go here
 8         ],
 9     ],
10 ];

Here, we’re defining our route from before, and then our valid Slack token. In ValidateSlackTokenFactory, $validTokens ends up being ['/slack_jira' => 'token'], which gets handed to our ValidateSlackToken middleware constructor.

To show you what a typical factory tests looks like, there’s ValidateSlackTokenFactoryTest.php, which is also simple.

 1 <?php
 2 
 3 namespace AppTest\Validator\Slack;
 4 
 5 use App\Validator\Slack\ValidateSlackToken;
 6 use App\Validator\Slack\ValidateSlackTokenFactory;
 7 use Interop\Container\ContainerInterface;
 8 
 9 class ValidateSlackTokenFactoryTest extends \PHPUnit_Framework_TestCase
10 {
11     /**
12      * @test
13      */
14     public function itWillDoTheNeedful()
15     {
16         $container = $this->prophesize(ContainerInterface::class);
17         $factory   = new ValidateSlackTokenFactory();
18         $container->get('config')->shouldBeCalled()->willReturn([]);
19         $result = $factory($container->reveal());
20         $this->assertInstanceOf(ValidateSlackToken::class, $result);
21     }
22 }

We’re making a new factory, making sure that $container->get('config') gets called, and then asserting on the output of the factory, which should be an instance of our middleware.

You can see another typical middleware tests for the ValidateSlackToken middleware here.

In the interest of brevity (ha!), I’ve omitted tests for the rest of this post. Hopefully it’s clear what’s happening in the above tests so it’s really clear how to write other middleware tests.

Also, along with all of this, if you want to set up your very own slack slash command, go to https://$slackInstance.slack.com/apps/manage/custom-integrations Here’s a couple of screenshots of what my setup looks like.

Initial Slack Setup Slack Description

As a fun side note, if you don’t know about ngrok, you should be using it. It lets you send real traffic to your localhost, which comes in especially handy when testing stuff like this.

More Packages, and a Parser

Next, I added guzzle by running composer require guzzlehttp/guzzle changeset 04642f Guzzle is awesome, and will come in handy when we need to start making requests from our app. After that’s finished, I ran composer require zendframework/zend-console changeset bcea1d which is used below to parse incoming input from Slack.

We need something to parse the input incoming from slack. I initially made something that worked decently, but ultimately wound up with a more formal parser I found on stack overflow. changeset 2ebf40

To actually start using the parser, I stuck it in a piece of middleware, along with a Slack Client piece of middleware we can use to send messages back to the Slack user. changeset 12beab

Our slack client is basically a wrapper around the basic Guzzle client, with a header added to make it even simpler to use:

 1 <?php
 2 
 3 namespace App\Client;
 4 
 5 use GuzzleHttp\Client;
 6 use Interop\Container\ContainerInterface;
 7 
 8 class SlackClient
 9 {
10     public function __invoke(ContainerInterface $container)
11     {
12         return new Client(['headers' => ['Content-Type' => 'application/json']]);
13     }
14 }

It follows the factory pattern in expressive, so we need to register it as such in dependencies.global.php

'factories'  => [
+            SlackClient::class         => SlackClient::class,

In SlackJiraPipeline.php, we’ll add our middleware that will handle and parse the input incoming from Slack.

$pipeline = new MiddlewarePipe();
$pipeline->pipe($container->get(ValidateBody::class));
$pipeline->pipe($container->get(ValidateSlackToken::class));
+$pipeline->pipe($container->get(ParseSlackJiraInput::class));

Here’s what ParseSlackJiraInput.php looks like

 1 <?php
 2 
 3 namespace App\Validator\Slack;
 4 
 5 use App\Utility\ArgvParser;
 6 use GuzzleHttp\Client;
 7 use Psr\Http\Message\ServerRequestInterface;
 8 use Zend\Console\Exception\RuntimeException;
 9 use Zend\Console\Getopt;
10 use Zend\Stratigility\Http\ResponseInterface;
11 
12 class ParseSlackJiraInput
13 {
14     /**
15      * @var Client
16      */
17     private $slackClient;
18 
19     public function __construct(Client $slackClient)
20     {
21         $this->slackClient = $slackClient;
22     }
23 
24     public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
25     {
26         $body    = $request->getParsedBody();
27         $text    = $body['text'] ?? '';
28         $args    = ArgvParser::parseString($text);
29         $_SERVER['argv'][0] = "/jira show issuesKey(s)[]"; // will show up as usage message
30         // you have to set register_argc_argv => On for this to work with Getopt, or you can dump things into $_SERVER['argv']
31         $opts = new Getopt(
32             [
33                 'p|public' => 'publicly post option',
34             ],
35             $args
36         );
37         try {
38             if (!$opts->getOptions() && count($opts->getArguments()) == 1) {
39                 throw new RuntimeException("Invalid arguments", $opts->getUsageMessage());
40             }
41             if (!$opts->getRemainingArgs()) {
42                 throw new RuntimeException("Invalid arguments", $opts->getUsageMessage());
43             }
44             $body['args'] = $opts->getRemainingArgs();
45             // set options in body for later
46             $body['response_type'] = $opts->public ? 'in_channel' : 'ephemeral';
47         } catch (RuntimeException $e) {
48             $responseBody = [
49                 'text' => "Tried running: `{$body['command']} {$body['text']}` \n" . $e->getUsageMessage(),
50             ];
51             $this->slackClient->post(
52                 $body['response_url'] ?? '',
53                 [
54                     'body'    => json_encode($responseBody),
55                     'headers' => [
56                         'Content-Type' => 'application/json',
57                     ],
58                 ]
59             );
60 
61             $error = $e->getUsageMessage();
62         }
63 
64         return $next($request->withParsedBody($body), $response, $error ?? null);
65     }
66 }

It’s pretty simple. We’ll get the parsed (validated) body, and pull out the text portion, and hand it off to our parser. The parser will split each value into elements in an array, and then hand it back to Getopt, which is from Zend Console.

This makes it easy to do stuff like $arg -o value, and then pull out the -o value by doing something like $result->o

The above parser only accepts one option -p(making the results public, meaning it’ll post publicly, or default to ‘ephemeral’, meaning only the user who ran the slash command can see the results) Using the above pattern makes it trivial to add more functionality and options.

I’ve found it helpful while building slash commands to echo out the command the user put in. It makes it clearer to the user if something doesn’t work, and is handy for sharing. We’re checking to make sure the user has more than one argument in their command, (meaning they can’t successfully run something like /jira show) and if not, we’re posting back to the user in slack the usage.

Another benefit of doing it this way is so when new functionality gets added, or if someone is unfamiliar with options in this slash command, they can run /jira and get back usage info.

If you notice on line 64, we’re calling $next with $request->withParsedBody($body). This is so we can pass things into the request and use them later on.

Making a request to JIRA

changeset 739757

Now we’re finally at a point that we can query the JIRA API, and show an issue. Here’s what the relevant parts of JiraShowIssueCommand.php look like:

 1 <?php
 2 
 3 namespace App\Service\Jira;
 4 
 5 use GuzzleHttp\Client;
 6 use GuzzleHttp\Exception\ClientException;
 7 use GuzzleHttp\UriTemplate;
 8 use Psr\Http\Message\ResponseInterface;
 9 use Psr\Http\Message\ServerRequestInterface;
10 
11 class JiraShowIssueCommand
12 {
13     /** * @var Client */
14     private $jiraClient;
15     /** * @var Client */
16     private $slackClient;
17     /** * @var string */
18     private $jiraUrl;
19 
20     public function __construct(Client $jiraClient, Client $slackClient, string $jiraUrl)
21     {
22         $this->jiraClient  = $jiraClient;
23         $this->slackClient = $slackClient;
24         $this->jiraUrl     = $jiraUrl;
25     }
26 
27     public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
28     {
29         $body = $request->getParsedBody();
30         if ($body['args'][0] !== "show") {
31             return $next($request, $response);
32         }
33         $jobs        = array_reverse(array_slice($body['args'], 1));
34         $uriTemplate = new UriTemplate();
35         $uri         = $uriTemplate->expand(
36             'rest/api/2/search?jql=key in ({issueKeys*})&expand=editmeta&fields=customfield_10024&fields=summary'
37             . '&fields=creator&fields=assignee&fields=issuetype&fields=priority&fields=status&fields=resolution',
38             [
39                 'issueKeys' => $jobs,
40             ]
41         );
42         try {
43             $json         = json_decode($this->jiraClient->get($uri)->getBody()->getContents(), true);
44             $attachments  = $this->prepareSlackAttachments($json);
45             $responseBody = [
46                 'text'          => "Search Results for `{$body['command']} {$body['text']}`",
47                 'response_type' => $body['response_type'] ?? 'ephemeral',
48                 'attachments'   => $attachments,
49             ];
50             $this->slackClient->post($body['response_url'], ['body' => json_encode($responseBody)]);
51         } catch (ClientException $e) {
52             $error = $e->getMessage();
53             $this->slackClient->post(
54                 $body['response_url'],
55                 [
56                     'body' => json_encode(
57                         [
58                             'text' => "Running `{$body['command']} {$body['text']}` didn't work. Got "
59                                 . $e->getCode() . " for an HTTP response",
60                         ]
61                     ),
62                 ]
63             );
64         }
65 
66         return $next($request, $response, $error ?? null);
67     }
68 }

First, on line 30, we’re checking to make sure we’re looking at a request that looks something like /jira show $ISSUE-KEY Then, we’re saving the rest of the arguments in the request body for our request to JIRA later on.

The UriTemplate on line 34 from Guzzle makes it trivial to build a url dynamically using whatever data you need.

The request we’re making will use JIRA’s JQL to request whatever issues we’re asking for by sticking them in jql=key in ({issueKeys*}). The rest of the query are the fields we care about. If there are multiple issues passed in, the template will automatically split them up using a comma, which is just what we need.

After we have our uri built, we make a GET request using our JIRA client. That will return JSON data, which we hand into a function that formats our message in a format for slack to make it pretty. You can see that bit of code here.

Guzzle will throw an exception if an invalid HTTP response or any other type of error happens. If that’s the case, we’re using our Slack client to send the error back to the user.

The factory for the above class looks like this JiraShowIssueCommandFactory.php:

 1 <?php
 2 
 3 namespace App\Service\Jira;
 4 
 5 use App\Client\JiraClient;
 6 use App\Client\SlackClient;
 7 use Interop\Container\ContainerInterface;
 8 
 9 class JiraShowIssueCommandFactory
10 {
11     public function __invoke(ContainerInterface $container)
12     {
13         $jiraClient  = $container->get(JiraClient::class);
14         $slackClient = $container->get(SlackClient::class);
15         $jiraUrl     = $container->get('config')['jira_config']['base_uri'] ?? '';
16 
17         return new JiraShowIssueCommand($jiraClient, $slackClient, $jiraUrl);
18     }
19 }

We are re-using our old Slack Client, and also using a JIRA client. It’s a bit different and has more config than our Slack Client. Here’s what it looks like.

 1 <?php
 2 
 3 namespace App\Client;
 4 
 5 use GuzzleHttp\Client;
 6 use Interop\Container\ContainerInterface;
 7 
 8 class JiraClient
 9 {
10     public function __invoke(ContainerInterface $container)
11     {
12         $config  = $container->get('config')['jira_config'] ?? [];
13         $auth    = $config['auth'] ?? [];
14         $baseUri = $config['base_uri'] ?? '';
15 
16         return new Client(
17             [
18                 'base_uri' => $baseUri,
19                 'auth'     => [($auth['user'] ?? ''), ($auth['password'] ?? '')],
20                 'headers'  => [
21                     'Content-Type' => 'application/json',
22                 ],
23             ]
24         );
25     }
26 }

The main difference is that we know the base_uri for our JIRA instance, so we’ll construct it with that and we need to make requests using basic auth.

The config file that’s driving the factory above looks like this (jira_config.local.php.dist):

1 <?php
2 
3 return [
4     'jira_config' => [
5         'auth' => ['user' => 'your JIRA username (not email)', 'password' => 'password'],
6         'base_uri' => 'https://$yourJiraInstance.atlassian.net/',
7     ],
8 ];

It’s a dist file since it has our JIRA credentials in it. In production and locally, you should have a copy of this file with your real username and password to be able to make it work.

After all of these are created, be sure to add them to your dependencies config file. Expressive will throw a nice error back at you if you forget though.

Final Steps

We now have a working stack of middleware that receives, validates, and makes a query to JIRA.

Here’s a screenshot of an example result: Result

The last piece I added was to send a message back to the user on Slack when we’re done processing JIRA commands. changeset 4c8ceb

A handy thing you can do with Zend’s Service Manager is call build instead of get, with config options. Here’s what we’re adding to our pipeline.

class SlackJiraPipeline
 {
     public function __invoke(ContainerInterface $container)
     {
         $pipeline = new MiddlewarePipe();
         $pipeline->pipe($container->get(ValidateBody::class));
         $pipeline->pipe($container->get(ValidateSlackToken::class));
         $pipeline->pipe($container->get(ParseSlackJiraInput::class));
         $pipeline->pipe($container->get(JiraShowIssueCommand::class));
+        $pipeline->pipe(
+            $container->build(SendMessageToSlackUserViaResponseUrl::class, ['message' => 'Processing Complete'])
+        );

Here’s the corresponding middleware:

 1 <?php
 2 
 3 namespace App\Service\Slack;
 4 
 5 use GuzzleHttp\Client;
 6 use Psr\Http\Message\ResponseInterface;
 7 use Psr\Http\Message\ServerRequestInterface;
 8 
 9 class SendMessageToSlackUserViaResponseUrl
10 {
11     /**
12      * @var Client
13      */
14     private $slackClient;
15     /**
16      * @var string
17      */
18     private $message;
19 
20     public function __construct(Client $slackClient, string $message)
21     {
22         $this->slackClient = $slackClient;
23         $this->message     = $message;
24     }
25 
26     public function __invoke(ServerRequestInterface $request, ResponseInterface $response, callable $next)
27     {
28         $body         = $request->getParsedBody();
29         $responseBody = [
30             'text'          => $this->message,
31             'response_type' => $body['response_type'] ?? 'ephemeral',
32         ];
33         $this->slackClient->post($body['response_url'], ['body' => json_encode($responseBody)]);
34 
35         return $next($request, $response, $error ?? null);
36     }
37 }

This takes in a Slack Client, and a message, and then uses that message to send to Slack via the response_url in the body.

Here’s the factory:

 1 <?php
 2 
 3 namespace App\Service\Slack;
 4 
 5 use App\Client\SlackClient;
 6 use Interop\Container\ContainerInterface;
 7 
 8 class SendMessageToSlackUserViaResponseUrlFactory
 9 {
10     public function __invoke(ContainerInterface $container, $requestedName, array $options = [])
11     {
12         $defaults = [
13             'message' => 'You sent a message, but forgot to replace the default! Go you! 
14             You should probably use `build`',
15         ];
16         $options += $defaults;
17         $slackClient = $container->get(SlackClient::class);
18 
19         return new SendMessageToSlackUserViaResponseUrl($slackClient, $options['message']);
20     }
21 }

Expressive will take in options that you pass to it at runtime. We set up a default message, and then pass it along to our middleware.

Here’s a screenshot of the result using the complete message at the end. We’re also passing in the -p option to post the result publicly.

Slack Example

That’s it! Hopefully you now have a good overview of how to connect Slack to JIRA, and potentially any other service using expressive.

One other practical considerations, is queuing requests, and responding to slack immediately instead of processing commands in real-time. You’ll probably get timeout errors from Slack if you’re not doing that, even though it’ll still probably work. Since this is already a really long post, I’ll have to cover how to do that in a different post.

Thanks for reading, and I hope you enjoyed it. Please feel free to ask me any questions you have via email or twitter. If you want me to walk through anything else that you think would be interested, I’d be especially interested to hear form you.