title | layout | should_publish | published | permalink |
---|---|---|---|---|
Scripting |
layouts/docs.html |
true |
2023-10-10 19:25:22 UTC |
/scripting.html |
Hubot out of the box doesn't do too much, but it is an extensible, scriptable robot friend. There are hundreds of scripts written and maintained by the community and it's easy to write your own. You can create a custom script in Hubot's scripts
directory or create a script package for sharing with the community!
When you created your Hubot, the generator also created a scripts
directory. If you peek around there, you will see some examples. For a script to be a script, it needs to:
- live in a directory on the Hubot script load path (
src/scripts
andscripts
by default) - be a
.js
or.mjs
file - export a function whos signature takes 1 parameter (
robot
)
By export a function, we just mean:
// .mjs
export default async robot => {
// your code here
}
// .js
module.exports = async robot => {
// your code here
}
The robot
parameter is an instance of your robot friend. At this point, we can start scripting up some awesomeness.
Since this is a chat bot, the most common interactions are based on messages. Hubot can hear
messages said in a room or respond
to messages directly addressed at it. Both methods take a regular expression and a callback function as parameters. For example:
// .mjs
export default async robot => {
robot.hear(/badger/i, async res => {
// your code here
})
robot.respond(/open the pod bay doors/i, async res => {
// your code here
}
}
The robot.hear(/badger/)
callback is called anytime a message's text matches. For example:
- Stop badgering the witness
- badger me
- what exactly is a badger anyways
The robot.respond(/open the pod bay doors/i)
callback is only called for messages that are immediately preceded by the robot's name or alias. If the robot's name is HAL and alias is /, then this callback would be triggered for:
- hal open the pod bay doors
- HAL: open the pod bay doors
- @HAL open the pod bay doors
- /open the pod bay doors
It wouldn't be called for:
- HAL: please open the pod bay doors
- because its
respond
is expecting the text to be prefixed with the robots name
- because its
- has anyone ever mentioned how lovely you are when you open the pod bay doors?
- because it lacks the robot's name at the beginning
The res
parameter is an instance of Response
(historically, this parameter was msg
and you may see other scripts use it this way). With it, you can send
a message back to the room the res
came from, emote
a message to a room (If the given adapter supports it), or reply
to the person that sent the message. For example:
// .mjs
export default async robot => {
robot.hear(/badger/i, async res => {
res.send(`Badgers? BADGERS? WE DON'T NEED NO STINKIN BADGERS`)
}
robot.respond(/open the pod bay doors/i, async res => {
res.reply(`I'm afraid I can't let you do that.`)
}
robot.hear(/I like pie/i, async res => {
res.emote('makes a freshly baked pie')
}
}
The robot.hear(/badgers/)
callback sends a message exactly as specified regardless of who said it, "Badgers? BADGERS? WE DON'T NEED NO STINKIN BADGERS".
If a user Dave says "HAL: open the pod bay doors", robot.respond(/open the pod bay doors/i)
callback sends a message "Dave: I'm afraid I can't let you do that."
Messages can be sent to a specified room or user using the messageRoom function.
// .mjs
export default async robot => {
robot.hear(/green eggs/i, async res => {
const room = 'mytestroom'
await robot.messageRoom(room, 'I do not like green eggs and ham. I do not like them Sam-I-Am.')
}
}
User name can be explicitely specified if desired ( for a cc to an admin/manager), or using the response object a private message can be sent to the original sender.
robot.respond(/I don't like sam-i-am/i, async res => {
const room = 'joemanager'
await robot.messageRoom(room, 'Someone does not like Dr. Seus')
await res.reply('That Sam-I-Am\nThat Sam-I-Am\nI do not like\nthat Sam-I-Am')
}
robot.hear(/Sam-I-Am/i, async res => {
const room = res.envelope.user.name
await robot.messageRoom(room, 'That Sam-I-Am\nThat Sam-I-Am\nI do not like\nthat Sam-I-Am')
}
So far, our scripts have had static responses, which while amusing, are boring functionality-wise. res.match
has the result of match
ing the incoming message against the regular expression. This is just a JavaScript thing, which ends up being an array with index 0 being the full text matching the expression. If you include capture groups, those will be populated on res.match
. For example, if we update a script like:
robot.respond(/open the (.*) doors/i, async res => {
// your code here
}
If Dave says "HAL: open the pod bay doors", then res.match[0]
is "open the pod bay doors", and res.match[1]
is just "pod bay". Now we can start doing more dynamic things:
robot.respond(/open the (.*) doors/i, async res => {
const doorType = res.match[1]
if (doorType == 'pod bay') {
await res.reply(`I'm afraid I can't let you do that.`)
} else {
await res.reply(`Opening ${doorType} doors`)
}
}
Hubot can make HTTP calls on your behalf to integrate & consume third party APIs. This can be through an instance of ScopedHttpClient available at robot.http
. The simplest case looks like:
robot.http('https://midnight-train').get()((err, res, body) => {
// your code here
})
A post looks like:
const data = JSON.stringify({
foo: 'bar'
})
robot.http('https://midnight-train')
.header('Content-Type', 'application/json')
.post(data)((err, res, body) => {
// your code here
})
err
is an error encountered on the way, if one was encountered. You'll generally want to check for this and handle accordingly:
robot.http('https://midnight-train')
.get()((err, res, body) => {
if (err){
return res.send `Encountered an error :( ${err}`
}
// your code here, knowing it was successful
})
res
is an instance of node's http.ServerResponse. Most of the methods don't matter as much when using ScopedHttpClient
, but of interest are statusCode
and getHeader
. Use statusCode
to check for the HTTP status code, where usually non-200 means something bad happened. Use getHeader
for peeking at the header, for example to check for rate limiting:
robot.http('https://midnight-train')
.get() ((err, res, body) => {
// pretend there's error checking code here
if (res.statusCode <> 200)
return res.send(`Request didn't come back HTTP 200 :(`)
const rateLimitRemaining = res.getHeader('X-RateLimit-Limit') ? parseInt(res.getHeader('X-RateLimit-Limit')) : 1
if (rateLimitRemaining && rateLimitRemaining < 1)
return res.send('Rate Limit hit, stop believing for awhile')
// rest of your code
}
body
is the response's body as a string, the thing you probably care about the most:
robot.http('https://midnight-train')
.get()((err, res, body) => {
// error checking code here
res.send(`Got back ${body}`)
})
If you are talking to Web Services that respond with JSON representation, then when making the robot.http
call, you will usually set the Accept
header to give the Web Service a clue that's what you are expecting back. Once you get the body
back, you can parse it with JSON.parse
:
robot.http('https://midnight-train')
.header('Accept', 'application/json')
.get()((err, res, body) => {
// error checking code here
const data = JSON.parse(body)
res.send(`${data.passenger} taking midnight train going ${data.destination}`)
})
It's possible to get non-JSON back, like if the Web Service has an error and renders HTML instead of JSON. To be on the safe side, you should check the Content-Type
, and catch any errors while parsing.
robot.http('https://midnight-train')
.header('Accept', 'application/json')
.get()((err, res, body) => {
// err & res status checking code here
if (res.getHeader('Content-Type') != 'application/json'){
return res.send(`Didn't get back JSON :(`)
}
let data = null
try {
data = JSON.parse(body)
} catch (error) {
res.send(`Ran into an error parsing JSON :(`)
}
// your code here
})
XML Web Services require installing a XML parsing library. It's beyond the scope of this documentation to go into detail, but here are a few libraries to check out:
- xml2json (simplest to use, but has some limitations)
- jsdom (JavaScript implementation of the W3C DOM)
- xml2js
For consuming a Web Service that responds with HTML, you'll need an HTML parser. It's beyond the scope of this documentation to go into detail, but here's a few libraries to check out:
As mentioned previously, Hubot uses ScopedHttpClient to provide a simple interface for making HTTP and HTTPS requests. Under the hood, it's using node's http and https modules, but tries to provide an easier Domain Specific Language (DSL) for common kinds of Web Service interactions.
If you need to control options on http
and https
more directly, you pass a second parameter to robot.http
that will be passed on to ScopedHttpClient
which will be passed on to http
and https
:
const options = {
rejectUnauthorized: false // don't verify server certificate against a CA, SCARY!
}
robot.http('https://midnight-train', options)
In addition, if ScopedHttpClient
doesn't suit you, you can use http, https or fetch
directly.
A common pattern is to hear or respond to commands, and send with a random funny image or line of text from an array of possibilities. Hubot includes a convenience method:
const lulz = ['lol', 'rofl', 'lmao']
res.send(res.random(lulz))
Hubot can react to a room's topic changing, assuming that the adapter supports it.
// .mjs
export default async robot => {
robot.topic(async res => {
await res.send()`${res.message.text}? That's a Paddlin'`)
})
}
Hubot can see users entering and leaving, assuming that the adapter supports it.
// .mjs
const enterReplies = ['Hi', 'Target Acquired', 'Firing', 'Hello friend.', 'Gotcha', 'I see you']
const leaveReplies = ['Are you still there?', 'Target lost', 'Searching']
export default async robot => {
robot.enter(async res => {
await res.send(res.random(enterReplies))
})
robot.leave(async res => {
await res.send(res.random(leaveReplies))
})
}
While the above helpers cover most of the functionality the average user needs (hear, respond, enter, leave, topic), sometimes you would like to have very specialized matching logic for listeners. If so, you can use listen
to specify a custom match function instead of a regular expression.
The match function must return a truthy value if the listener callback should be executed. The truthy return value of the match function is then passed to the callback as res.match
.
// .mjs
export default async robot =>{
robot.listen(
(message) => {
// Match function
// only match messages with text (ie ignore enter and other events)
if(!message?.text) return
// Occassionally respond to things that Steve says
return message.user.name == 'Steve' && Math.random() > 0.8
},
async res => {
// Standard listener callback
// Let Steve know how happy you are that he exists
await res.reply(`HI STEVE! YOU'RE MY BEST FRIEND! (but only like ${res.match * 100}% of the time)`)
}
)
}
See the design patterns document for examples of complex matchers.
Hubot can access the environment he's running in, just like any other Node.js program, using process.env
. This can be used to configure how scripts are run, with the convention being to use the HUBOT_
prefix.
// .mjs
const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
export default async robot => {
robot.respond(/what is the answer to the ultimate question of life/, async res => {
await res.send(`${answer}, but what is the question?`)
})
}
Take care to make sure the script can load if it's not defined, give the Hubot developer notes on how to define it, or default to something. It's up to the script writer to decide if that should be a fatal error (e.g. hubot exits), or not (make any script that relies on it to say it needs to be configured. When possible and when it makes sense to, having a script work without any other configuration is preferred.
Here we can default to something:
// .mjs
const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING ?? 42
export default async robot => {
robot.respond(/what is the answer to the ultimate question of life/, async res => {
await res.send(`${answer}, but what is the question?`)
})
}
Here we exit if it's not defined:
// .mjs
const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
if(!answer) {
console.log(`Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again`)
process.exit(1)
}
export default async robot => {
robot.respond(/what is the answer to the ultimate question of life/, async res => {
await res.send(`${answer}, but what is the question?`)
})
}
And lastly, we update the robot.respond
to check it:
// .mjs
const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
export default async robot => {
robot.respond(/what is the answer to the ultimate question of life/, async res => {
if(!answer) {
return await res.send('Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again')
}
await res.send(`${answer}, but what is the question?`)
})
}
Hubot uses npm to manage its dependencies. To add additional packages, add them to dependencies
in package.json
. For example, to add lolimadeupthispackage 1.2.3, it'd look like:
"dependencies": {
"hubot": "2.5.5",
"lolimadeupthispackage": "1.2.3"
},
by executing npm i [email protected]
.
If you are using scripts from hubot-scripts, take note of the Dependencies
documentation in the script to add. They are listed in a format that can be copy & pasted into package.json
, just make sure to add commas as necessary to make it valid JSON.
Hubot can run code later using JavaScript's built-in setTimeout. It takes a callback method, and the amount of time to wait before calling it:
// .mjs
export default async robot => {
robot.respond(/you are a little slow/, async res => {
setTimeout(async () => {
await res.send(`Who you calling 'slow'?`)
}, 60 * 1000)
})
}
Additionally, Hubot can run code on an interval using setInterval. It takes a callback method, and the amount of time to wait between calls:
// .mjs
export default async robot => {
robot.respond(/annoy me/, async res => {
await res.send('Hey, want to hear the most annoying sound in the world?')
setInterval(async () => {
await res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
}, 1000)
})
}
Both setTimeout
and setInterval
return the ID of the timeout or interval it created. This can be used to to clearTimeout
and clearInterval
.
// .mjs
export default async robot => {
let annoyIntervalId = null
robot.respond(/annoy me/, async res => {
if (annoyIntervalId) {
return await res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
}
await res.send('Hey, want to hear the most annoying sound in the world?')
annoyIntervalId = setInterval(async () => {
await res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
}, 1000)
}
robot.respond(/unannoy me/, async res => {
if (annoyIntervalId) {
await res.send('GUYS, GUYS, GUYS!')
clearInterval(annoyIntervalId)
annoyIntervalId = null
} else {
await res.send('Not annoying you right now, am I?')
}
}
}
Hubot includes support for the express web framework to serve up HTTP requests. It listens on the port specified by the EXPRESS_PORT
or PORT
environment variables (preferred in that order) and defaults to 8080. An instance of an express application is available at robot.router
. It can be protected with username and password by specifying EXPRESS_USER
and EXPRESS_PASSWORD
. It can automatically serve static files by setting EXPRESS_STATIC
.
You can increase the maximum request body size by specifying EXPRESS_LIMIT
. It defaults to '100kb'. You can also set the maximum number of parameters that are allowed in the URL-encoded data by setting EXPRESS_PARAMETER_LIMIT
. The default is 1000
.
The most common use of this is for providing HTTP end points for services with webhooks to push to, and have those show up in chat.
// .mjs
export default async robot => {
// the expected value of :room is going to vary by adapter, it might be a numeric id, name, token, or some other value
robot.router.post('/hubot/chatsecrets/:room', async (req, res) => { // NOTE: this is the Express route handler, not Hubot's, so `res.send` isn't async/await.
const room = req.params.room
const data = req.body?.payload ? JSON.parse(req.body.payload) : req.body
const secret = data.secret
await robot.messageRoom(room, `I have a secret: ${secret}`)
res.send('OK')
})
}
Test it with curl; also see section on error handling below.
# raw json, must specify Content-Type: application/json
curl -X POST -H "Content-Type: application/json" -d '{"secret":"C-TECH Astronomy"}' http://127.0.0.1:8080/hubot/chatsecrets/general
# defaults Content-Type: application/x-www-form-urlencoded, must st payload=...
curl -d 'payload=%7B%22secret%22%3A%22C-TECH+Astronomy%22%7D' http://127.0.0.1:8080/hubot/chatsecrets/general
All endpoint URLs should start with the literal string /hubot
(regardless of what your robot's name is). This consistency makes it easier to set up webhooks (copy-pasteable URL) and guarantees that URLs are valid (not all bot names are URL-safe).
Hubot can also respond to events which can be used to pass data between scripts. This is done by encapsulating Node.js's EventEmitter with robot.emit
and robot.on
.
One use case for this would be to have one script for handling interactions with a service, and then emitting events as they come up. For example, we could have a script that receives data from a GitHub post-commit hook, make that emit commits as they come in, and then have another script act on those commits.
// src/scripts/github-commits.mjs
export default async robot => {
robot.router.post('/hubot/gh-commits', (req, res) => {
robot.emit('commit', {
user: {}, //hubot user object
repo: 'https://github.com/github/hubot',
hash: '2e1951c089bd865839328592ff673d2f08153643'
})
})
}
// src/scripts/heroku.mjs
export default async robot => {
robot.on('commit', async (commit) => {
await robot.send(commit.user, `Will now deploy ${commit.hash} from ${commit.repo}!`)
// deploy code goes here
}
}
If you provide an event, it's highly recommended to include a hubot user or room object in its data. This would allow for hubot to notify a user or room in chat.
No code is perfect, and errors and exceptions are to be expected. Previously, an uncaught exceptions would crash your hubot instance. Hubot now includes an uncaughtException
handler, which provides hooks for scripts to do something about exceptions.
// src/scripts/does-not-compute.mjs
export default async robot => {
robot.error(async (err, res) => {
robot.logger.error('DOES NOT COMPUTE')
if(res) {
await res.reply('DOES NOT COMPUTE')
}
}
}
You can do anything you want here, but you will want to take extra precaution of rescuing and logging errors, particularly with asynchronous code. Otherwise, you might find yourself with recursive errors and not know what is going on.
Under the hood, there is an 'error' event emitted, with the error handlers consuming that event. The uncaughtException handler technically leaves the process in an unknown state. Therefore, you should rescue your own exceptions whenever possible, and emit them yourself. The first parameter is the error emitted, and the second parameter is an optional message that generated the error.
Using previous examples:
robot.router.post()'/hubot/chatsecrets/:room', (req, res) => {
const room = req.params.room
let data = null
try {
data = JSON.parse(req.body.payload)
} catch(err) {
robot.emit('error', err)
}
// rest of the code here
}
robot.hear(/midnight train/i, (res) => {
robot.http('https://midnight-train')
.get()((err, res, body) => {
if (err) {
res.reply('Had problems taking the midnight train')
robot.emit('error', err, res)
return
}
// rest of code here
})
})
For the second example, it's worth thinking about what messages the user would see. If you have an error handler that replies to the user, you may not need to add a custom message and could send back the error message provided to the get()
request, but of course it depends on how public you want to be with your exception reporting.
Hubot scripts can be documented with comments at the top of their file, for example:
// Description:
// <description of the scripts functionality>
//
// Dependencies:
// "<module name>": "<module version>"
//
// Configuration:
// LIST_OF_ENV_VARS_TO_SET
//
// Commands:
// hubot <trigger> - <what the respond trigger does>
// <trigger> - <what the hear trigger does>
//
// Notes:
// <optional notes required for the script>
//
// Author:
// <github username of the original script author>
The most important and user facing of these is Commands
. At load time, Hubot looks at the Commands
section of each scripts, and build a list of all commands. The hubot-help script lets a user ask for help across all commands, or with a search. Therefore, documenting the commands make them a lot more discoverable by users.
When documenting commands, here are some best practices:
- Stay on one line. Help commands get sorted, so would insert the second line at an unexpected location, where it probably won't make sense.
- Refer to the Hubot as hubot, even if your hubot is named something else. It will automatically be replaced with the correct name. This makes it easier to share scripts without having to update docs.
- For
robot.respond
documentation, always prefix withhubot
. Hubot will automatically replace this with your robot's name, or the robot's alias if it has one - Check out how man pages document themselves. In particular, brackets indicate optional parts, '...' for any number of parameters, etc.
The other sections are more relevant to developers of the bot, particularly dependencies, configuration variables, and notes. All contributions to hubot-scripts should include all these sections that are related to getting up and running with the script.
Hubot has two persistence methods available that can be used to store and retrieve data by scripts: an in-memory key-value store exposed as robot.brain
, and an optional persistent database-backed key-value store expsoed as robot.datastore
.
robot.respond(/have a soda/i, async res => {
// Get number of sodas had (coerced to a number).
const sodasHad = robot.brain.get('totalSodas') * 1 ?? 0
if (sodasHad > 4) {
await res.reply(`I'm too fizzy..`)
} else {
await res.reply('Sure!')
robot.brain.set('totalSodas', sodasHad + 1)
}
})
robot.respond(/sleep it off/i, async res => {
robot.brain.set('totalSodas', 0)
await res.reply('zzzzz')
}
If the script needs to lookup user data, there are methods on robot.brain
for looking up one or many users by id, name, or 'fuzzy' matching of name: userForName
, userForId
, userForFuzzyName
, and usersForFuzzyName
.
export default async robot => {
robot.respond(/who is @?([\w .\-]+)\?*$/i, async res => {
const name = res.match[1].trim()
const users = robot.brain.usersForFuzzyName(name)
if (users.length == 1) {
const user = users[0]
// Do something interesting here..
}
await res.send(`${name} is user - ${user}`)
})
}
Unlike the brain, the datastore's getter and setter methods are asynchronous and don't resolve until the call to the underlying database has resolved. This requires a slightly different approach to accessing data:
robot.respond(/have a soda/i, async res => {
// Get number of sodas had (coerced to a number).
robot.datastore.get('totalSodas').then((value) => {
const sodasHad = value * 1 ?? 0
if (sodasHad > 4) {
await res.reply(`I'm too fizzy..`)
} else {
await res.reply('Sure!')
robot.brain.set('totalSodas', sodasHad + 1)
}
})
})
robot.respond(/sleep it off/i, async res => {
await robot.datastore.set('totalSodas', 0)
await res.reply('zzzzz')
})
The datastore also allows setting and getting values which are scoped to individual users:
export default async robot ->
robot.respond(/who is @?([\w .\-]+)\?*$/i, async res => {
const name = res.match[1].trim()
const users = robot.brain.usersForFuzzyName(name)
if (users.length == 1) {
const user = users[0]
const roles = await user.get('roles')
await res.send(`${name} is ${roles.join(', ')}`)
}
})
There are three main sources to load scripts from:
- all scripts bundled with your hubot installation under
scripts/
directory - community scripts specified in
hubot-scripts.json
and shipped in thehubot-scripts
npm package - scripts loaded from external npm packages and specified in
external-scripts.json
Scripts loaded from the scripts/
directory are loaded in alphabetical order, so you can expect a consistent load order of scripts. For example:
scripts/1-first.js
scripts/_second.js
scripts/third.js
Once you've built some new scripts to extend the abilities of your robot friend, you should consider sharing them with the world! At the minimum, you need to package up your script and submit it to the Node.js Package Registry. You should also review the best practices for sharing scripts below.
Start by checking if an NPM package for a script like yours already exists. If you don't see an existing package that you can contribute to, then you can easily get started using npx hubot --create myhubot
.
Creating a script package for hubot is very simple. Start by running npx hubot --create myhubot
to create your own instance.
cd myhubot
and create a script. For example, if we wanted to create a hubot script called "my-awesome-script":
% npm hubot --create my-awesome-script
% cd my-awesome-script
% mkdir src
% touch src/AwesomeScript.mjs
Open package.json
and add:
"peerDependencies": {
"hubot": ">=9"
},
If you are using git, the generated directory includes a .gitignore, so you can initialize and add everything:
% git init
% git add .
% git commit -m "Initial commit"
You now have a hubot script repository that's ready to roll! Feel free to crack open src/AwesomeScript.mjs
and start building up your script! When you've got it ready, you can publish it to npmjs by following their documentation!
You'll probably want to write some unit tests for your new script. Review the [Hubot Repo](https://github.com/hubotio/hubot/tree/main/test for examples creating tests.
In addition to a regular expression and callback, the hear
and respond
functions also accept an optional options Object which can be used to attach arbitrary metadata to the generated Listener object. This metadata allows for easy extension of your script's behavior without modifying the script package.
The most important and most common metadata key is id
. Every Listener should be given a unique name (options.id; defaults to null
). Names should be scoped by module (e.g. 'my-module.my-listener'). These names allow other scripts to directly address individual listeners and extend them with additional functionality like authorization and rate limiting.
Additional extensions may define and handle additional metadata keys. For more information, see the Listener Middleware section.
Returning to an earlier example:
export default async robot => {
robot.respond(/annoy me/, id:'annoyance.start', async res => {
// code to annoy someone
})
robot.respond(/unannoy me/, id:'annoyance.stop', async res => {
// code to stop annoying someone
})
}
These scoped identifiers allow you to externally specify new behaviors like:
- authorization policy: "allow everyone in the
annoyers
group to executeannoyance.*
commands" - rate limiting: "only allow executing
annoyance.start
once every 30 minutes"
There are three kinds of middleware: Receive, Listener and Response.
Receive middleware runs once, before listeners are checked. Listener middleware runs for every listener that matches the message. Response middleware runs for every response sent to a message.
Similar to Express middleware, Hubot executes middleware in definition order. Each middleware can either continue the chain (by calling next
) or interrupt the chain (by calling done
). If all middleware continues, the listener callback is executed and done
is called. Middleware may wrap the done
callback to allow executing code in the second half of the process (after the listener callback has been executed or a deeper piece of middleware has interrupted).
Middleware is called with:
context
- See the each middleware type's API to see what the context will expose.
return true
to allow the message to continue; return false
to stop it from continuing.
Every middleware receives the same API signature of context
. Different kinds of middleware may receive different information in the
context
object. For more details, see the API for each type of middleware.
Asynchronous middleware should catch its own exceptions, emit an error
event, and return true
or false
. Any uncaught exceptions will interrupt all execution of middleware.
Listener middleware inserts logic between the listener matching a message and the listener executing. This allows you to create extensions that run for every matching script. Examples include centralized authorization policies, rate limiting, logging, and metrics. Middleware is implemented like other hubot scripts: instead of using the hear
and respond
methods, middleware is registered using listenerMiddleware
.
A fully functioning example can be found in hubot-rate-limit (Note, this is a coffee version, non-async/await).
A simple example of middleware logging command executions:
export default async robot => {
robot.listenerMiddleware(async context => {
// Log commands
robot.logger.info(`${context.response.message.user.name} asked me to ${context.response.message.text}`)
// Continue executing middleware
return true
})
}
In this example, a log message will be written for each chat message that matches a Listener.
A more complex example making a rate limiting decision:
export default async robot => {
// Map of listener ID to last time it was executed
let lastExecutedTime = {}
robot.listenerMiddleware(async context => {
try {
// Default to 1s unless listener provides a different minimum period
const minPeriodMs = context.listener.options?.rateLimits?.minPeriodMs ?? 1000
// See if command has been executed recently
if (lastExecutedTime.hasOwnProperty(context.listener.options.id) &&
lastExecutedTime[context.listener.options.id] > Date.now() - minPeriodMs) {
// Command is being executed too quickly!
return false
} else {
lastExecutedTime[context.listener.options.id] = Date.now()
return true
}
} catch(err) {
robot.emit('error', err, context.response)
}
})
}
In this example, the middleware checks to see if the listener has been executed in the last 1,000ms. If it has, the middleware return false
immediately, preventing the listener callback from being called. If the listener is allowed to execute, the middleware records the time the listener finished executing and return true
.
This example also shows how listener-specific metadata can be leveraged to create very powerful extensions: a script developer can use the rate limiting middleware to easily rate limit commands at different rates by just adding the middleware and setting a listener option.
// .mjs
export default async robot => {
robot.hear(/hello/, id: 'my-hello', rateLimits: {minPeriodMs: 10000}, async res => {
// This will execute no faster than once every ten seconds
await res.reply('Why, hello there!')
})
}
Listener middleware callbacks receive 1 argument, context
. Listener middleware context includes these fields:
listener
options
: a simple Object containing options set when defining the listener. See Listener Metadata.- all other properties should be considered internal
response
- all parts of the standard response API are included in the middleware API. See Send & Reply.
- middleware may decorate (but not modify) the response object with additional information (e.g. add a property to
response.message.user
with a user's LDAP groups) - note: the textual message (
response.message.text
) should be considered immutable in listener middleware
Receive middleware runs before any listeners have executed. It's suitable for excluded commands that have not been updated to add an ID, metrics, and more.
This simple middlware bans hubot use by a particular user, including hear
listeners. If the user attempts to run a command explicitly, it will return an error message.
const EXCLUDED_USERS = [
'12345' // Restrict access for a user ID for a contractor
]
robot.receiveMiddleware(async context => {
if (EXCLUDED_USERS.some( id => context.response.message.user.id == id)) {
// Don't process this message further.
context.response.message.finish()
// If the message starts with 'hubot' or the alias pattern, this user was
// explicitly trying to run a command, so respond with an error message.
if (context.response.message.text?.match(robot.respondPattern(''))) {
await context.response.reply(`I'm sorry @${context.response.message.user.name}, but I'm configured to ignore your commands.`)
}
// Don't process further middleware.
return false
} else {
return true
}
})
Receive middleware callbacks receive 1 argument, context
. Receive middleware context includes these fields:
response
- this response object will not have a
match
property, as no listeners have been run yet to match it. - middleware may decorate the response object with additional information (e.g. add a property to
response.message.user
with a user's LDAP groups) - middleware may modify the
response.message
object
- this response object will not have a
Response middleware runs against every message hubot sends to a chat room. It's helpful for message formatting, preventing password leaks, metrics, and more.
This simple example changes the format of links sent to a chat room from markdown links (like example) to the format supported by Slack, https://example.com|example.
// .mjs
export default async robot=> {
robot.responseMiddleware(async context=> {
if(!context.plaintext) return true
context.strings.forEach(string => {
string.replace(/\[([^\[\]]*?)\]\((https?:\/\/.*?)\)/, "<$2|$1>"
})
return true
})
}
Response middleware callbacks receive 1 parameters, context
and are Promises/async/await. Receive middleware context includes these fields:
response
- This response object can be used to send new messages from the middleware. Middleware will be called on these new responses. Be careful not to create infinite loops.
strings
- An array of strings being sent to the chat room adapter. You can edit these, or use
context.strings = ["new strings"]
to replace them.
- An array of strings being sent to the chat room adapter. You can edit these, or use
method
- A string representing which type of response message the listener sent, such as
send
,reply
,emote
ortopic
.
- A string representing which type of response message the listener sent, such as
plaintext
true
orundefined
. This will be set totrue
if the message is of a normal plaintext type, such assend
orreply
. This property should be treated as read-only.
I use Node's Test Runner for writing and running tests for Hubot.
"scripts": {
"test": "node --test",
}
npm t
Checkout Xample.mjs for an example that tests the Xample.mjs script.
In order to isolate your script from Hubot, I've created a Dummy Adapter that you can use when starting a Robot instance to interact with and excercise your code. For now, my suggestion is to copy the DummyAdapter
into your code so that you can modifiy as your needs evolve.
Please feel free to create Github issues if you have questions or comments. I'm happy to collaborate.
If you created your bot with npx hubot --create xample-bot
, then the DummyAdapter
is already there. Along with an example test.