Skip to content

Instantly share code, notes, and snippets.

@aoberoi
Last active January 15, 2019 18:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save aoberoi/f743cab0798d904ef69c8abc63b6aa4a to your computer and use it in GitHub Desktop.
Save aoberoi/f743cab0798d904ef69c8abc63b6aa4a to your computer and use it in GitHub Desktop.
Working out an API proposal for Slapp v4

Slapp v4 Proposal

This proposal expresses a design that leans towards solving problems with middleware. This is motivated by the need to support migratability from Hubot, deliver extensibility in the framework, and to lay the ground work for a plugin API in the future.

Please add feedback Does this feel unintuitive? Complicated? Are there use cases that you beleive should be easy hard to implement using this API? Please comment below.

There are some wrinkles that still need to be ironed out. These are noted below where they come up.


Initialization

You create an app by calling a constructor, which is a top-level export.

const { Slapp } = require('slapp');

// NOTE: the parameters to the constructor are not fully specified here
const app = new Slapp(token, options);

/* Add functionality here */

app.start();

NOTE: This change eliminates the attachToExpress() method in favor of abstracting the specific reciever. start() calls through to the reciever to initialize it in whichever manner it wants to. The default reciever is built on express, so this call would tell it to listen. Alternatively, the default reciever may export the express app, so that you can use it as part of an existing HTTP server.

Listening for events

Apps typically react to incoming events, which can be events, actions, commands, options requests, etc. For each type of events, there's a method to attach a listener function.

// Listen for an event from the Events API
app.event(eventType, fn);

// Listen for an action from a button, menu, dialog submission, or message action
app.action(callbackId, fn);

// Listen for a command
app.command(commandName, fn);

// Listen for options requests
app.options(callbackId, fn);

There's a special method that's provided as a convenience to handle Events API events with the type message. When using this method to attach a listener function, it will not be called for messages that were sent from this app (can be disabled using the ignoreSelf option on the constructor). Also, you can include a string or RegExp pattern before the listener function to only call that listener function when the message text matches that pattern.

app.message([pattern ,] fn);

Making things happen

Most of the app's functionality will be inside listener functions (the fn parameters above). These functions are called with arguments that make it easier to build a rich app.

  • payload: object (aliases: message, event) - The contents of the event. The exact structure will depend on which type of event this listener is attached to. For example, for an event from the events API, it will be an event type structure. NOTE: Fully specify which structures this could be (see @slack/events-api, @slack/interactive-messages for examples).

  • context: object - The event context. This object contains data about the message and the app, such as the botId. See advanced usage for more details.

  • say?: (string|object) => void - A function to respond to an incoming event. This argument is only available when the listener is triggered for event that contains a channel_id (including message events). Call this function to send a message back to the same channel as the incoming event. It accepts both simple strings (for plain messages) and objects (for complex messages, including blocks or attachments).

  • ack?: (string|object|undefined) => void - A function to acknowledge that an incoming event was recieved by the app. Incoming events from actions, commands, and options requests must be acknowledged by calling this function. See acknowleding events for details.

  • respond?: (string|object) => void - A function to respond to an incoming event. This argument is only available when the listener is triggered for an event that contains a response_url (actions and commands). Call this function to send a message back to the same channel as the incoming event, but using the semantics of the response_url. NOTE: Fully specify what this means in each case for buttons, menus, dialog submissions, slash commands, and message actions.

  • body: object - An object that contains the whole body of the event, which is a superset of the data in payload. Some types of data are only available outside the event payload itself, such as api_app_id, authed_users, etc. This argument should rarely be needed, but for completeness it is provided here.

The arguments are grouped into properties of one object, so that its easier to pick just the ones your listener needs (using object destructuring). Here is an example where the app sends a simple response, so there's no need for most of these arguments:

// Reverse all messages the app can hear
app.message(({ message, say }) => {
  const reversedText = message.text.split('').reverse().join('');
  say(reversedText);
});

Calling the Web API

Listeners can use the full power of all the methods in the Web API (given that your app is installed with the appropriate scopes). Each app has a client property that can be used to call these methods. See the WebClient documentation for a more complete description of how it can be used.

// Reacts to any message that contains "happy" with a 😀
app.message('happy', ({ message }) => {
  // Calls the "reactions.add" Web API method
  app.client.reactions.add({ name: ':grinning:', channel: message.channel_id })
    .then(console.log)
    .catch(console.error);
});

Acknowledging events

Some types of events need to be acknowledged in order to ensure a consistent user experience inside the Slack client (web, mobile, and desktop apps). This includes all actions, commands, and options requests. Listeners for these events need to call the ack() function, which is passed in as an argument.

In general, the Slack platform expects an acknowledgement within 3 seconds, so listeners should call this function as soon as possible.

Depending on the type of incoming event a listener is meant for, ack() should be called with a parameter:

  • Button clicks, menu selections, and slash commands: Either call ack() with no parameters, a string to to update the message with a simple message, or an object to replace it with a complex message. Replacing the message to remove the interactive elements is a best practice for an action should only be performed once.

  • Message actions: Call ack() with no parameters. TODO: is there message replacement for actions?

  • Dialog submissions: Call ack() with no parameters when the inputs are all valid, or an object describing the validation errors if any inputs are not valid.

  • Options requests: Call ack() with an object containing the options for the user to see.

If an app does not call ack() within the time limit, Slapp will generate an error. See handling errors for more details.

Handling errors

If an error occurs in a listener function, its strongly recommended to handle it directly. There are a few cases where those errors may occur after your listener function has returned (such as when calling say() or respond(), or forgetting to call ack()). In these cases, your app will be notified about the error in an error handler function. Your app should register an error handler using the Slapp.prototype.error(fn) method.

app.error((error) => {
  // Check the details of the error to handle special cases (such as stopping the app or retrying the sending of a message)
  console.error(error);
});

NOTE: Specify the types of errors (corresponding to codes), and the properties of each type.

If you do not attach an error handler, the app will log these errors to the console by default.

The app.error() method should be used as a last resort to catch errors. It is always better to deal with errors in the listeners where they occur because you can use all the context available in that listener. If the app expects that using say() or respond() can fail, it's always possible to use app.client.chat.postMessage() instead, which returns a Promise that can be caught to deal with the error.

Advanced usage

Apps are designed to be extensible using a concept called middleware. Middleware allows you to define functionality that processes a whole class of events before (and after) the listener function is called. This makes it easier to deal with common concerns in one place (e.g. authentication, logging, etc) instead of spreading them out in every listener.

In fact, middleware can be chained so that any number of middleware functions get a chance to run before the listener, and they each run in the order they are added.

Middleware are just functions - nearly identical to listener functions. They can choose to respond right away, to extend the context argument and continue, or trigger an error. The only difference is that middleware use a special next() argument, a function that's called to let the app know it can continue to the next middleware (or listener) in the chain.

There are two types of middleware: global and listener. Each are explained below.

Global middleware

Global middleware are used to deal app-wide concerns, where each incoming event should be processed. They are added to the app using app.use(middleware). You can add multiple middleware, and each of them will run before any of the listener middleware, or the listener functions run. Remember, the order matters!

As an example, let's say your app can only work if the user who sends every incoming message is identified using an internal authentication service (e.g. an SSO provider, LDAP, etc). Here is how you might define a global middleware to make that data available to each listener.

// Authentication middleware - Calls Acme identity provider to associate the incoming event with the user who sent it
function authWithAcme({ payload, context, say, next }) {
  const slackUserId = payload.user;

  // Assume we have a function that can take a Slack user ID as input to find user details from the provider
  acme.lookupBySlackId(slackUserId)
    .then((user) => {
      // When the user lookup is successful, add the user details to the context
      context.user = user;
     
      // Pass control to the next middleware (if there are any) and the listener functions
      next();
    })
    .catch((error) => {
      // Uh oh, this user hasn't registered with Acme. Send them a registration link, and don't let the middleware/listeners continue
      if (error.message === 'Not Found') {
        // In the real world, you would need to check if the say function was defined, falling back to the respond function if not,
        // and then falling back to only logging the error as a last resort.
        say(`I'm sorry <@${slackUserId}, you aren't registered with Acme. Please use <https://acme.com/register> to use this app.`);
         return;
      }
     
      // This middleware doesn't know how to handle any other errors. Pass control to the previous middleware (if there
      // are any) or the global error handler.
      next(error);
    });
}

app.use(authWithAcme);

// The listener now has access to the user details
app.message('whoami', ({ say, context }) => { say(`User Details: ${JSON.stringify(context.user)}`) });

Listener middleware

Listener middleware are used to deal with shared concerns amongst many listeners, but not necessarily for all of them. They are added as arguments that precede the listener function in the call that attaches the listener function. This means the methods described in Listening for events are actually all variadic (they take any number of parameters). You can add as many listener middleware as you like.

As an example, let's say your listener only needs to deal with messages from humans. Messages from apps will always have a subtype of bot_message. We can write a middleware that excludes bot messages:

// Listener middleware - filters out messages that have subtype 'bot_message'
function noBotMessages({ message, next }) {
  if (!message.subtype || message.subtype !== 'bot_message') {
     next();
  }
}

// The listener only sees messages from human users
app.message(noBotMessages, ({ message }) => console.log(
`(MSG) User: ${message.user}
       Message: ${message.text}`
));

Subtype matching is so common, that Slapp ships with a listener middleware helper that filters all messages that match a given subtype. The following is an example of the opposite of the one above - the listener only sees messages that are bot_messages.

const { Slapp, subtype } = require('slapp');

// Not shown: app initialization and start

// The listener only sees messages from bot users (apps)
app.message(subtype('bot_message'), ({ message }) => console.log(
`(MSG) Bot: ${message.bot_id}
       Message: ${message.text}`
));

Even more advanced usage

The examples above all illustrate how middleware can be used to process an event before the listener (and other middleware in the chain) run. However, some middleware are designed to process the event after the listener finishes. In general, a middleware can run both before and after the remaining middleware chain.

In order to process the event after the listener, a function is passed to next(). The function recieves two arguments:

  • error will be falsy when the middleware chain finished handling the event normally. When some later middleware calls next(error) (including an error parameter), then this argument is set to an error value.

  • done is a callback that must be called when processing is complete. When there is no error, or the incoming error has been handled, done() should be called with no parameters. If instead the middleware is propigating an error up the middleware chain, done(error) should be called with the error for its only parameter.

The following example shows a global middleware that calculates the total processing time for the middleware chain:

function logProcessingTime({ next }) {
  const startTimeMs = Date.now();
  next((error, done) => {
    // This middleware doesn't deal with any errors, so it propogates any truthy value to the previous middleware
    if (error) {
      done(error);
      return;
    }
    
    const endTimeMs = Date.now();
    console.log(`Total processing time: ${endTimeMs - startTimeMs}`);
     
    // Continue normally
    done();
  });
}

app.use(logProcessingTime)
// Exercise: port the existing hubot example script to the slapp proposed API involving global and listener middleware.
//
// The commented sections are from the hubot example script, while the uncommented sections below them are the
// same functionality using the proposal
// module.exports = (robot) => {
const { Slapp } = require('slapp');
const slapp = new Slapp(process.env.SLACK_BOT_TOKEN, {}); // NOTE: other config here
// robot.hear(/badger/i, (res) => {
// res.send('Badgers? BADGERS? WE DON’T NEED NO STINKIN BADGERS')
// })
slapp.message('badger', ({ say }) => say('Badgers? BADGERS? WE DON’T NEED NO STINKIN BADGERS'));
// robot.respond(/open the (.*) doors/i, (res) => {
// const doorType = res.match[1]
//
// if (doorType === 'pod bay') {
// res.reply('I’m afraid I can’t let you do that.')
// return
// }
//
// res.reply('Opening #{doorType} doors')
// })
slapp.message(/open the (.*) doors/i, ({ say, context }) => {
const doorType = context.matches[1];
const text = (doorType === 'pod bay') ?
'I’m afraid I can’t let you do that.' :
`Opening ${doorType} doors`;
say(text);
});
// robot.hear(/I like pie/i, (res) => {
// res.emote('makes a freshly baked pie')
// })
slapp.message(/I like pie/i, ({ message }) => {
slapp.client.reactions.add({ name: ':pie:', channel: message.channel_id })
.catch(console.error);
});
const lulz = ['lol', 'rofl', 'lmao'];
// robot.respond(`/${lulz.join('|')}/i`, (res) => {
// res.send(res.random(lulz))
// })
const randomLulz = () => lulz[Math.floor(Math.random()*items.length)];
slapp.event('app_mention', ({ say }) => say(randomLulz()));
// OR
slapp.message(directMention, ({ say }) => say(randomLulz()));
// robot.topic((res) => {
// res.send(`${res.message.text}? That’s a Paddlin`)
// })
// 🚫 there's no Events API event type for channel topic changed.
const enterReplies = ['Hi', 'Target Acquired', 'Firing', 'Hello friend.', 'Gotcha', 'I see you']
const leaveReplies = ['Are you still there?', 'Target lost', 'Searching']
// robot.enter((res) => {
// res.send(res.random(enterReplies))
// })
// robot.leave((res) => {
// res.send(res.random(leaveReplies))
// })
const randomEnterReply = () => enterReplies[Math.floor(Math.random()*items.length)];
const randomLeaveReply = () => leaveReplies[Math.floor(Math.random()*items.length)];
slapp.event('member_joined_channel', ({ say }) => say(randomEnterReply()));
slapp.event('member_joined_channel', ({ say }) => say(randomLeaveReply()));
const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
// robot.respond(/what is the answer to the ultimate question of life/, (res) => {
// if (answer) {
// res.send(`${answer}, but what is the question?`)
// return
// }
//
// res.send('Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again')
// })
slapp.message(
directMention,
'what is the answer to the ultimate question of life',
({ say }) => if (answer) say(`${answer}, but what is the question?`));
// robot.respond(/you are a little slow/, (res) => {
// setTimeout(() => res.send('Who you calling "slow"?'), 60 * 1000)
// })
slapp.message('you are a little slow', ({ say }) => {
setTimeout(() => say('Who you calling "slow"?'), 60 * 1000);
});
let annoyIntervalId = null
// robot.respond(/annoy me/, (res) => {
// if (annoyIntervalId) {
// res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
// return
// }
//
// res.send('Hey, want to hear the most annoying sound in the world?')
// annoyIntervalId = setInterval(() => res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'), 1000)
// })
//
// robot.respond(/unannoy me/, (res) => {
// if (!annoyIntervalId) {
// res.send('Not annoying you right now, am I?')
// return
// }
//
// res.send('OKAY, OKAY, OKAY!')
// clearInterval(annoyIntervalId)
// annoyIntervalId = null
// })
slapp.message(directMention, 'annoy me', ({ say }) => {
if (annoyIntervalId) {
say('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH');
return;
}
say('Hey, want to hear the most annoying sound in the world?');
annoyIntervalId = setInterval(() => {
say('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH');
}, 1000);
});
slapp.message(directMention, 'unannoy me', ({ say }) => {
if (!annoyIntervalId) {
say('Not annoying you right now, am I?');
return;
}
say('OKAY, OKAY, OKAY!');
clearInterval(annoyIntervalId);
annoyIntervalId = null;
});
// robot.router.post('/hubot/chatsecrets/:room', (req, res) => {
// const room = req.params.room
// const data = JSON.parse(req.body.payload)
// const secret = data.secret
//
// robot.messageRoom(room, `I have a secret: ${secret}`)
//
// res.send('OK')
// })
// 🚫 stand up your own express router
// robot.error((error, response) => {
// const message = `DOES NOT COMPUTE: ${error.toString()}`
// robot.logger.error(message)
//
// if (response) {
// response.reply(message)
// }
// })
slapp.error((error) => {
const message = `DOES NOT COMPUTE: ${error.toString()}`;
console.error(message);
// 🚫 no reply handling from global error handler
});
// robot.respond(/have a soda/i, (response) => {
// // Get number of sodas had (coerced to a number).
// const sodasHad = +robot.brain.get('totalSodas') || 0
//
// if (sodasHad > 4) {
// response.reply('I’m too fizzy…')
// return
// }
//
// response.reply('Sure!')
// robot.brain.set('totalSodas', sodasHad + 1)
// })
//
// robot.respond(/sleep it off/i, (res) => {
// robot.brain.set('totalSodas', 0)
// res.reply('zzzzz')
// })
// TODO: context is not a brain replacement by default since its created per-event. the above use case can be addressed
// using a middleware that decorates the context with a getter and setter for the values from the database.
// ---------------------------------------------------------------------
// Helpers (ship inside slapp, no need for scripter to write these)
/**
* Example implementation of how plain strings or RegExp arguments to slapp.message() are turned into middleware
* @param {string|RegExp} pattern
*/
function matches(pattern) {
const p = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
return ({ message, context, next }) => {
const m = p.exec(message.text);
if (m !== null) {
context.matches = m;
next();
}
};
}
/**
* A listener middleware that skips messages which don't mention the bot
*/
function directMention({ message, context, next }) {
if (message.text.indexOf(`<@${context.bot_user_id}>`) === 0) {
next();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment