Slack Inviter Extension

Ryan Thompson Guides


How we hacked Slack into a community platform with an extension.

Huge kudos to @levelsio for the original article that blazed the trail for this addon: https://levels.io/slack-typeform-auto-invite-sign-ups/

Also a massive thanks to @BrennonLoveless for spinning up the original addon and getting it jump started!

You can download this addon yourself here: https://packagist.org/packages/anomaly/slack_inviter-extension

Slack is awesome

I, like most, am no stranger to messengers and communication tools. However, they all have their downfalls; Facebook is too distracting, Skype kills everything it touches, Messages (previously iMessage) is limited to Apple devices, IRC is ancient and weird, etc..

Then there's Slack

I first came across Slack when I joined the LaraChat team. I instantly loved the simplicity of it and it's performance and feel were outstanding. I was hooked.

Skip forward to the Beta1 release of PyroCMS and I wanted to develop our own slack team. So Brennon and I came across the article above and he took it by the reigns from there and within an hour or so we had a polished end product.

Here's how we did it:

Building the Extension

Extensions, while they have countless possibilities, are a great way to make addons that just don't fit within the other addon types. Any random thing that might be too small for a module or doesn't need backend UI, for example, is a perfect candidate for extensions.

Determining our needs

  • We needed a form to collect information for invitations
  • We needed a method to send invitations through the Slack API
  • We needed a simple stream to store invitations, monitor activity, and check errors
  • We needed a plugin to render the form on any page we wanted
  • We needed to make it a self contained, shippable, open source package

As you can see, an addon is a must because we want to distribute the code. We don't need any UI so a module doesn't really make sense. We need a plugin but we need a stream too and only extensions and modules can "install" and use migrations to create streams. Since every addon type can provide a plugin the extension addon type makes perfect sense!

Getting Started

To get started we first created our slack_inviter-extension addon folder in an anomaly vendor directory since this is our own addon. Our final path looked like addons/shared/anomaly/slack_inviter-extension. Then we created our composer.json file and PSR'd our src folder like so:

"autoload": {
    "psr-4": {
        "Anomaly\\SlackInviterExtension\\": "src/"
    }
}

Lastly, we added our SlackInviterExtension which extended the base Extension class in our new Anomaly\SlackInviterExtension namespace.

So far, this is the exact same steps you would normally take when building ANY kind of addon. And already, we have a PSR'd directory for all of our classes, an addon that can be made into a repo and composer package and the entire Streams Platform behind us to make something awesome.

Creating our stream

We wanted to use a simple stream to store input from the form and the Slack API response in order to monitor errors and activity. Creating streams and fields and assigning fields to streams is all done using Laravel migrations. I like to use a single migration for fields and a migration per stream to create.

First - the migration for all of our fields.

php artisan make:migration create_slack_inviter_fields --addon=anomaly.extension.slack_inviter

Which looked like:

protected $fields = [
    'email'      => 'anomaly.field_type.email',
    'name'       => 'anomaly.field_type.text',
    'error'      => 'anomaly.field_type.text',
    'successful' => 'anomaly.field_type.boolean',
    'ip_address' => 'anomaly.field_type.text'
];

Then the migration for our stream and field assignments.

php artisan make:migration create_slack_inviter_stream --addon=anomaly.extension.slack_inviter

Which looked like:

protected $stream = [
    'slug' => 'invites'
];

protected $assignments = [
    'email' => [
        'required' => true
    ],
    'name'  => [
        'required' => true
    ],
    'error',
    'successful',
    'ip_address'
];

This is exactly the same method for creating streams in a module, simply replace "extension" with "module" and "slack_inviter" with your module's slug.

Now we can go ahead and install the extension through the addons module or with artisan:

artisan extension:install anomaly.extension.slack_inviter

Once installed, our database structure with required fields and all is ready to go. If we were to change fields and/or stream and assignment details during development - this is a handy command to refresh the your stream structure:

artisan migrate:refresh --addon=anomaly.extension.slack_inviter

Building the model

Since we used streams to create our invites stream, we actually already have a model available: Anomaly\Streams\Platform\Model\SlackInviter\SlackInviterInvitesEntryModel. However, it is highly recommended to simply extend it with your own, even if no functionality is to be added.

When dealing with entry entities (in this case an "invite") Streams will look favorably on you if you follow some best practices. So we set up our Invite entity like this:

src/Invite/InviteModel.php

Which looked something like this:

<?php namespace Anomaly\SlackInviterExtension\Invite;

use Anomaly\Streams\Platform\Model\SlackInviter\SlackInviterInvitesEntryModel;

class InviteModel extends SlackInviterInvitesEntryModel
{
}

That's it. Streams does the heavy lifting for us here.

This is a highly encouraged pattern to use when developing addons using streams. This is referred to in documentation as the Entity Pattern.

Building the form

Again adhering to the Entity Pattern we put our form in a Form namespace inside our Invite namespace and named the form class accordingly.

src/Invite/Form/InviteFormBuilder.php

Which looked like this:

<?php namespace Anomaly\SlackInviterExtension\Invite\Form;

use Anomaly\Streams\Platform\Ui\Form\FormBuilder;

class InviteFormBuilder extends FormBuilder
{

    protected $handler = InviteFormHandler::class;

    protected $fields = [
        'name',
        'email'
    ];

    protected $actions = [
        'submit'
    ];

    protected $options = [
        'success_message' => false
    ];

}

Since we're using streams and following the Entity Pattern we don't need to set the model, it's detected in the namespace below. We also don't need to set the stream, it's pulled off the model. And we only need to use the field slugs for our form's fields since the rest can be pulled from streams information we've already provided in our migrations. So the only fields included will be the email and name fields.

We also disabled the success message, since we'll handle that later and used the pre-registered "Submit" button.

Lastly, notice we defined our own "form handler". Typically the default form handler simply saves the input using the model IF there are no errors, but we want to do a little more...

Handling form input

This is where our custom handler comes into play. When a form is submitted, the last step is to handle whatever the form is supposed to do. The default handler simply creates / updates a database record using the model set or detected using $builder->saveForm(). Our handler, however, we wanted to send an API request and set a simple success/error message.

Here is what our custom handler looked like. Note that while the method is resolved out of Laravel's service container and anything can be injected, the builder instance is passed explicitly.

<?php namespace Anomaly\SlackInviterExtension\Invite\Form;

use Anomaly\SlackInviterExtension\Invite\Command\SendInvite;
use Anomaly\Streams\Platform\Message\MessageBag;
use Illuminate\Contracts\Bus\SelfHandling;
use Illuminate\Foundation\Bus\DispatchesJobs;

class InviteFormHandler implements SelfHandling
{

    use DispatchesJobs;

    public function handle(InviteFormBuilder $builder, MessageBag $messages)
    {
        // Validation has failed!
        if ($builder->hasFormErrors()) {
            return;
        }

        $reply = $this->dispatch(new SendInvite($builder));

        if (array_get($reply, 'ok') === true) {
            $messages->success('anomaly.extension.slack_inviter::success.send_invite');
        } else {
            $messages->error('anomaly.extension.slack_inviter::error.send_invite');
            $messages->error('anomaly.extension.slack_inviter::error.' . $reply['error']);
        }

        // Clear the form!
        $builder->resetForm();
    }
}

As you can see, if we have validation errors we simply bail right here. Streams Platform handles the error messages, reloading form values, etc.

Next we dispatch the command that we've all been waiting for. But more on that later.

After our API request is made we look at the reply and set our own messages to display to the user.

Lastly, we manually clear the form since this form is self handling. Typically, front end forms should post to a specific controller method to handle the form but in this case, the form handles itself. So we need to clear it's values.

When the form is submitted, the page simply reloads and displays messages to the user. So we need to make our API call and lastly a plugin method to display this form anywhere.

Calling Slack's API

This is where @levelsio's article really came into play.

Let's take a look at the command we dispatched from our form handler.

<?php namespace Anomaly\SlackInviterExtension\Invite\Command;

use Anomaly\SlackInviterExtension\Invite\Event\SlackInviteWasSent;
use Anomaly\SlackInviterExtension\Invite\Form\InviteFormBuilder;
use Anomaly\SlackInviterExtension\Invite\InviteModel;
use Illuminate\Contracts\Bus\SelfHandling;
use Illuminate\Events\Dispatcher;
use Illuminate\Http\Request;

class SendInvite implements SelfHandling
{

    protected $builder;

    public function __construct(InviteFormBuilder $builder)
    {
        $this->builder = $builder;
    }

    public function handle(InviteModel $invites, Request $request, Dispatcher $events)
    {
        $user['ip_address'] = $request->ip();

        // Slack configurations
        $slackAuthToken        = config('anomaly.extension.slack_inviter::slack.auth_token');
        $slackHostName         = config('anomaly.extension.slack_inviter::slack.host_name');
        $slackAutoJoinChannels = config('anomaly.extension.slack_inviter::slack.auto_join_channels');

        $slackInviteUrl = 'https://' . $slackHostName . '.slack.com/api/users.admin.invite?t=' . time();

        $fields = array(
            'email'      => $user['email'] = $this->builder->getFormValue('email'),
            'first_name' => urlencode($user['name'] = $this->builder->getFormValue('name')),
            'channels'   => $slackAutoJoinChannels,
            'token'      => $slackAuthToken,
            'set_active' => true,
            '_attempts'  => '1'
        );

        // Open the connection.
        $ch = curl_init();

        // set the url, number of POST vars, POST data
        curl_setopt($ch, CURLOPT_URL, $slackInviteUrl);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, count($fields));
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($fields));

        // Execute the request.
        $reply = json_decode(curl_exec($ch), true);

        if ($reply['ok'] == false) {
            $user['error'] = $reply['error'];
        } else {
            $user['successful'] = true;
        }

        // Close the connection.
        curl_close($ch);

        $invites->create($user)

        return $reply;
    }
}

This probably looks a lot like your typical Laravel command. In short, we passed our builder instance, used some config values and the form input to make a Slack API, and manually save the invite to our stream and return the API response back to the form handler.

The configuration file

You will notice the repository comes with a config file located at resources/config/slack.php. Since this addon is distributed, you obviously don't want to store our API keys in the repository. You can override this config file and merge in your own values by creating the same file in your projects base Laravel config directory at config/addon/slack_inviter-extension/slack.php.

Saving the invite

Since streams directly extends Laravel's Eloquent ORM, we can use our tried and true methods to create the invite in our stream.

Lastly, the plugin

The last thing we need to do is make it so that this form can display in any view or rendered content.

To make a plugin, we first need to create our AddonServiceProvider and define our SlackInviterExtensionPlugin like in the $plugins property.

Our plugin looked like this:

<?php namespace Anomaly\SlackInviterExtension;

use Anomaly\Streams\Platform\Addon\Plugin\Plugin;

class SlackInviterExtensionPlugin extends Plugin
{

    protected $functions;

    public function __construct(SlackInviterExtensionPluginFunctions $functions)
    {
        $this->functions = $functions;
    }

    public function getFunctions()
    {
        return [
            new \Twig_SimpleFunction('slack_invite_form', [$this->functions, 'form'], ['is_safe' => ['html']])
        ];
    }
}

We could have put this plugin anywhere in the addon, but I like to put them next to the addon class.

Also note that instead of mucking up our plugin with it's attached function logic, we inject the function logic separately. Here is what the function logic class looked like:

<?php namespace Anomaly\SlackInviterExtension;

use Anomaly\SlackInviterExtension\Invite\Form\InviteFormBuilder;
use Illuminate\Container\Container;

class SlackInviterExtensionPluginFunctions
{

    protected $container;

    function __construct(Container $container)
    {
        $this->container = $container;
    }

    public function form()
    {
        $builder = $this->container->make(InviteFormBuilder::class);

        $builder->make();

        return $builder->getFormPresenter();
    }
}

Pretty simple at it's surface. All we're doing here is making our InviteFormBuilder class that we made earlier, running it's make method which builds it and makes it's inner content, then returning the form's presenter. The __toString() method of the presenter returns $form->getContent() so the form just displays.

Note: The presenter also contains some nice methods to customize the form and offers more control of the layout / display of the form.

All that's needed now is to include {% verbatim %}{{ slack_invite_form() }}{% endverbatim %} in a view or page content and that's it!

We had a validated form, a plugin to display the form, a simple stream to store activity, user feedback and an automated invite system! And it didn't take longer than an hour or two.

Final thoughts

There are a lot of different ways this same thing could have been handled..

We could have let the form handle itself like a normal form and used the onSaved() callback on the builder to dispatch the command or fire an event and listen to the event to handle the API request.

We could have skipped the stream and used a custom form with manually defined fields and just dumped results to log.

We could have just stored the results in the stream and processed them later using a scheduled command and CRON like the original article.

There are lots of ways you can use the tools in PyroCMS and the Streams Platform... This is just how we happened to do it. For the record, if we did it again I probably would have probably used a callback on the builder and fired an event.. Just sayin.

- Ryan