UNPKG

43.2 kBMarkdownView Raw
1---
2permalink: /docs/scripting/
3---
4
5# Scripting
6
7Hubot 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](index.md#scripts) and it's easy to write your own. You can create a custom script in Hubot's `scripts` directory or [create a script package](#creating-a-script-package) for sharing with the community!
8
9## Anatomy of a script
10
11When 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:
12
13* live in a directory on the Hubot script load path (`src/scripts` and `scripts` by default)
14* be a `.js` file
15* export a function whos signature takes 1 parameter (`robot`)
16
17By export a function, we just mean:
18
19```javascript
20module.exports = (robot) => {
21 // your code here
22}
23```
24
25The `robot` parameter is an instance of your robot friend. At this point, we can start scripting up some awesomeness.
26
27## Hearing and responding
28
29Since 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:
30
31```javascript
32module.exports = (robot) => {
33 robot.hear(/badger/i, (res) => {
34 // your code here
35 })
36
37 robot.respond(/open the pod bay doors/i, (res) => {
38 // your code here
39 }
40}
41```
42
43The `robot.hear(/badger/)` callback is called anytime a message's text matches. For example:
44
45* Stop badgering the witness
46* badger me
47* what exactly is a badger anyways
48
49The `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:
50
51* hal open the pod bay doors
52* HAL: open the pod bay doors
53* @HAL open the pod bay doors
54* /open the pod bay doors
55
56It wouldn't be called for:
57
58* HAL: please open the pod bay doors
59 * because its `respond` is expecting the text to be prefixed with the robots name
60* has anyone ever mentioned how lovely you are when you open the pod bay doors?
61 * because it lacks the robot's name at the beginning
62
63## Send & reply
64
65The `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:
66
67```javascript
68module.exports = (robot) => {
69 robot.hear(/badger/i, (res) => {
70 res.send(`Badgers? BADGERS? WE DON'T NEED NO STINKIN BADGERS`)
71 }
72
73 robot.respond(/open the pod bay doors/i, (res) => {
74 res.reply(`I'm afraid I can't let you do that.`)
75 }
76
77 robot.hear(/I like pie/i, (res) => {
78 res.emote('makes a freshly baked pie')
79 }
80}
81```
82
83The `robot.hear(/badgers/)` callback sends a message exactly as specified regardless of who said it, "Badgers? BADGERS? WE DON'T NEED NO STINKIN BADGERS".
84
85If 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."
86
87## Messages to a room or user
88
89Messages can be sent to a specified room or user using the messageRoom function.
90
91```javascript
92module.exports = (robot) => {
93 robot.hear(/green eggs/i, (res) => {
94 const room = 'mytestroom'
95 robot.messageRoom(room, 'I do not like green eggs and ham. I do not like them Sam-I-Am.')
96 }
97}
98```
99
100User 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.
101
102```javascript
103 robot.respond(/I don't like sam-i-am/i, (res) => {
104 const room = 'joemanager'
105 robot.messageRoom(room, 'Someone does not like Dr. Seus')
106 res.reply('That Sam-I-Am\nThat Sam-I-Am\nI do not like\nthat Sam-I-Am')
107 }
108
109 robot.hear(/Sam-I-Am/i, (res) => {
110 const room = res.envelope.user.name
111 robot.messageRoom(room, 'That Sam-I-Am\nThat Sam-I-Am\nI do not like\nthat Sam-I-Am')
112 }
113```
114
115## Capturing data
116
117So 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](http://www.w3schools.com/jsref/jsref_match.asp), 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:
118
119```javascript
120 robot.respond(/open the (.*) doors/i, (res) => {
121 // your code here
122 }
123```
124
125If 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:
126
127```javascript
128 robot.respond(/open the (.*) doors/i, (res) => {
129 const doorType = res.match[1]
130 if (doorType == 'pod bay') {
131 res.reply(`I'm afraid I can't let you do that.`)
132 } else {
133 res.reply(`Opening ${doorType} doors`)
134 }
135 }
136```
137
138## Making HTTP calls (please use `fetch` instead)
139
140Hubot can make HTTP calls on your behalf to integrate & consume third party APIs. This can be through an instance of [ScopedHttpClient](../src/httpclient.js) available at `robot.http`. The simplest case looks like:
141
142
143```javascript
144 robot.http('https://midnight-train').get()((err, res, body) => {
145 // your code here
146 })
147```
148
149A post looks like:
150
151```javascript
152 const data = JSON.stringify({
153 foo: 'bar'
154 })
155 robot.http('https://midnight-train')
156 .header('Content-Type', 'application/json')
157 .post(data)((err, res, body) => {
158 // your code here
159 })
160```
161
162
163`err` is an error encountered on the way, if one was encountered. You'll generally want to check for this and handle accordingly:
164
165```javascript
166 robot.http('https://midnight-train')
167 .get()((err, res, body) => {
168 if (err){
169 return res.send `Encountered an error :( ${err}`
170 }
171 // your code here, knowing it was successful
172 })
173```
174
175`res` is an instance of node's [http.ServerResponse](http://nodejs.org/api/http.html#http_class_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:
176
177```javascript
178 robot.http('https://midnight-train')
179 .get() ((err, res, body) => {
180 // pretend there's error checking code here
181 if (res.statusCode <> 200)
182 return res.send(`Request didn't come back HTTP 200 :(`)
183
184 const rateLimitRemaining = res.getHeader('X-RateLimit-Limit') ? parseInt(res.getHeader('X-RateLimit-Limit')) : 1
185 if (rateLimitRemaining && rateLimitRemaining < 1)
186 return res.send('Rate Limit hit, stop believing for awhile')
187
188 // rest of your code
189 }
190```
191
192`body` is the response's body as a string, the thing you probably care about the most:
193
194```javascript
195 robot.http('https://midnight-train')
196 .get()((err, res, body) => {
197 // error checking code here
198 res.send(`Got back ${body}`)
199 })
200```
201
202### JSON
203
204If 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`:
205
206```javascript
207 robot.http('https://midnight-train')
208 .header('Accept', 'application/json')
209 .get()((err, res, body) => {
210 // error checking code here
211 const data = JSON.parse(body)
212 res.send(`${data.passenger} taking midnight train going ${data.destination}`)
213 })
214```
215
216It'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.
217
218```javascript
219 robot.http('https://midnight-train')
220 .header('Accept', 'application/json')
221 .get()((err, res, body) => {
222 // err & res status checking code here
223 if (res.getHeader('Content-Type') != 'application/json'){
224 return res.send(`Didn't get back JSON :(`)
225 }
226 let data = null
227 try {
228 data = JSON.parse(body)
229 } catch (error) {
230 res.send(`Ran into an error parsing JSON :(`)
231 }
232
233 // your code here
234 })
235```
236
237### XML
238
239XML 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:
240
241* [xml2json](https://github.com/buglabs/node-xml2json) (simplest to use, but has some limitations)
242* [jsdom](https://github.com/tmpvar/jsdom) (JavaScript implementation of the W3C DOM)
243* [xml2js](https://github.com/Leonidas-from-XIV/node-xml2js)
244
245### Screen scraping
246
247For 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:
248
249* [cheerio](https://github.com/MatthewMueller/cheerio) (familiar syntax and API to jQuery)
250* [jsdom](https://github.com/tmpvar/jsdom) (JavaScript implementation of the W3C DOM)
251
252
253### Advanced HTTP and HTTPS settings
254
255As mentioned previously, Hubot uses [ScopedHttpClient](../src/httpclient.js) to provide a simple interface for making HTTP and HTTPS requests. Under the hood, it's using node's [http](http://nodejs.org/api/http.html) and [https](http://nodejs.org/api/https.html) modules, but tries to provide an easier Domain Specific Language (DSL) for common kinds of Web Service interactions.
256
257If 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`:
258
259```javascript
260 const options = {
261 rejectUnauthorized: false // don't verify server certificate against a CA, SCARY!
262 }
263 robot.http('https://midnight-train', options)
264```
265
266In addition, if `ScopedHttpClient` doesn't suit you, you can use [http](http://nodejs.org/api/http.html), [https](http://nodejs.org/api/https.html) or `fetch` directly.
267
268## Random
269
270A 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:
271
272```javascript
273const lulz = ['lol', 'rofl', 'lmao']
274res.send(res.random(lulz))
275```
276
277## Topic
278
279Hubot can react to a room's topic changing, assuming that the adapter supports it.
280
281```javascript
282module.exports = (robot) => {
283 robot.topic((res) => {
284 res.send()`${res.message.text}? That's a Paddlin'`)
285 })
286}
287```
288
289## Entering and leaving
290
291Hubot can see users entering and leaving, assuming that the adapter supports it.
292
293```javascript
294const enterReplies = ['Hi', 'Target Acquired', 'Firing', 'Hello friend.', 'Gotcha', 'I see you']
295const leaveReplies = ['Are you still there?', 'Target lost', 'Searching']
296
297module.exports = (robot) => {
298 robot.enter(res) => {
299 res.send(res.random(enterReplies))
300 }
301 robot.leave(res) => {
302 res.send(res.random(leaveReplies))
303 }
304}
305```
306
307## Custom Listeners
308
309While 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.
310
311The 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`.
312
313```javascript
314module.exports = (robot) =>{
315 robot.listen(
316 (message) => {
317 // Match function
318 // only match messages with text (ie ignore enter and other events)
319 if(!message?.text) return
320
321 // Occassionally respond to things that Steve says
322 return message.user.name == 'Steve' && Math.random() > 0.8
323 },
324 (res) => {
325 // Standard listener callback
326 // Let Steve know how happy you are that he exists
327 res.reply(`HI STEVE! YOU'RE MY BEST FRIEND! (but only like ${res.match * 100}% of the time)`)
328 }
329 )
330}
331```
332
333See [the design patterns document](patterns.md#dynamic-matching-of-messages) for examples of complex matchers.
334
335## Environment variables
336
337Hubot can access the environment he's running in, just like any other Node.js program, using [`process.env`](http://nodejs.org/api/process.html#process_process_env). This can be used to configure how scripts are run, with the convention being to use the `HUBOT_` prefix.
338
339```javascript
340const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
341
342module.exports = (robot) => {
343 robot.respond(/what is the answer to the ultimate question of life/, (res) => {
344 res.send(`${answer}, but what is the question?`)
345 }
346}
347```
348
349Take 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.
350
351Here we can default to something:
352
353```javascript
354const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING ?? 42
355
356module.exports = (robot) => {
357 robot.respond(/what is the answer to the ultimate question of life/, (res) => {
358 res.send(`${answer}, but what is the question?`)
359 }
360}
361```
362
363Here we exit if it's not defined:
364
365```javascript
366const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
367if(!answer) {
368 console.log(`Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again`)
369 process.exit(1)
370}
371
372module.exports = (robot) => {
373 robot.respond(/what is the answer to the ultimate question of life/, (res) => {
374 res.send(`${answer}, but what is the question?`)
375 }
376}
377```
378
379And lastly, we update the `robot.respond` to check it:
380
381```javascript
382const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING
383
384module.exports = (robot) => {
385 robot.respond(/what is the answer to the ultimate question of life/, (res) => {
386 if(!answer) {
387 return res.send('Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again')
388 }
389 res.send(`${answer}, but what is the question?`)
390 }
391}
392```
393
394## Dependencies
395
396Hubot uses [npm](https://github.com/isaacs/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:
397
398```json
399 "dependencies": {
400 "hubot": "2.5.5",
401 "lolimadeupthispackage": "1.2.3"
402 },
403```
404
405If 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.
406
407# Timeouts and Intervals
408
409Hubot can run code later using JavaScript's built-in [setTimeout](http://nodejs.org/api/timers.html#timers_settimeout_callback_delay_arg). It takes a callback method, and the amount of time to wait before calling it:
410
411```javascript
412module.exports = (robot) => {
413 robot.respond(/you are a little slow/, (res) => {
414 setTimeout(() => {
415 res.send(`Who you calling 'slow'?`)
416 }, 60 * 1000)
417 })
418}
419```
420
421Additionally, Hubot can run code on an interval using [setInterval](http://nodejs.org/api/timers.html#timers_setinterval_callback_delay_arg). It takes a callback method, and the amount of time to wait between calls:
422
423```javascript
424module.exports = (robot) => {
425 robot.respond(/annoy me/, (res) => {
426 res.send('Hey, want to hear the most annoying sound in the world?')
427 setInterval(() => {
428 res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
429 }, 1000)
430 })
431}
432```
433
434Both `setTimeout` and `setInterval` return the ID of the timeout or interval it created. This can be used to to `clearTimeout` and `clearInterval`.
435
436```javascript
437module.exports = (robot) => {
438 let annoyIntervalId = null
439
440 robot.respond(/annoy me/, (res) => {
441 if (annoyIntervalId) {
442 return res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
443 }
444
445 res.send('Hey, want to hear the most annoying sound in the world?')
446 annoyIntervalId = setInterval(() => {
447 res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH')
448 }, 1000)
449 }
450
451 robot.respond(/unannoy me/, (res) => {
452 if (annoyIntervalId) {
453 res.send('GUYS, GUYS, GUYS!')
454 clearInterval(annoyIntervalId)
455 annoyIntervalId = null
456 } else {
457 res.send('Not annoying you right now, am I?')
458 }
459 }
460}
461```
462
463## HTTP Listener
464
465Hubot includes support for the [express](http://expressjs.com) 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`.
466
467You can increase the [maximum request body size](https://github.com/expressjs/body-parser#limit-3) by specifying `EXPRESS_LIMIT`. It defaults to '100kb'. You can also set the [maximum number of parameters](https://github.com/expressjs/body-parser#parameterlimit) that are allowed in the URL-encoded data by setting `EXPRESS_PARAMETER_LIMIT`. The default is `1000`.
468
469The 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.
470
471
472```javascript
473module.exports = (robot) => {
474 // the expected value of :room is going to vary by adapter, it might be a numeric id, name, token, or some other value
475 robot.router.post('/hubot/chatsecrets/:room', (req, res) => {
476 const room = req.params.room
477 const data = req.body?.payload ? JSON.parse(req.body.payload) : req.body
478 const secret = data.secret
479
480 robot.messageRoom(room, `I have a secret: ${secret}`)
481
482 res.send('OK')
483 })
484}
485```
486
487Test it with curl; also see section on [error handling](#error-handling) below.
488
489```sh
490# raw json, must specify Content-Type: application/json
491curl -X POST -H "Content-Type: application/json" -d '{"secret":"C-TECH Astronomy"}' http://127.0.0.1:8080/hubot/chatsecrets/general
492
493# defaults Content-Type: application/x-www-form-urlencoded, must st payload=...
494curl -d 'payload=%7B%22secret%22%3A%22C-TECH+Astronomy%22%7D' http://127.0.0.1:8080/hubot/chatsecrets/general
495```
496
497All 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).
498
499## Events
500
501Hubot can also respond to events which can be used to pass data between scripts. This is done by encapsulating Node.js's [EventEmitter](http://nodejs.org/api/events.html#events_class_events_eventemitter) with `robot.emit` and `robot.on`.
502
503One 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.
504
505```javascript
506// src/scripts/github-commits.js
507module.exports = (robot) => {
508 robot.router.post('/hubot/gh-commits', (req, res) => {
509 robot.emit('commit', {
510 user: {}, //hubot user object
511 repo: 'https://github.com/github/hubot',
512 hash: '2e1951c089bd865839328592ff673d2f08153643'
513 })
514 })
515}
516```
517
518```javascript
519// src/scripts/heroku.js
520module.exports = (robot) => {
521 robot.on('commit', (commit) => {
522 robot.send(commit.user, `Will now deploy ${commit.hash} from ${commit.repo}!`)
523 // deploy code goes here
524 }
525}
526```
527
528If 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.
529
530## Error Handling
531
532No 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.
533
534```javascript
535// src/scripts/does-not-compute.js
536module.exports = (robot) => {
537 robot.error((err, res) => {
538 robot.logger.error('DOES NOT COMPUTE')
539
540 if(res) {
541 res.reply('DOES NOT COMPUTE')
542 }
543 }
544}
545```
546
547You 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.
548
549Under 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](http://nodejs.org/api/process.html#process_event_uncaughtexception). 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.
550
551Using previous examples:
552
553```javascript
554 robot.router.post()'/hubot/chatsecrets/:room', (req, res) => {
555 const room = req.params.room
556 let data = null
557 try {
558 data = JSON.parse(req.body.payload)
559 } catch(err) {
560 robot.emit('error', err)
561 }
562
563 // rest of the code here
564 }
565
566 robot.hear(/midnight train/i, (res) => {
567 robot.http('https://midnight-train')
568 .get()((err, res, body) => {
569 if (err) {
570 res.reply('Had problems taking the midnight train')
571 robot.emit('error', err, res)
572 return
573 }
574 // rest of code here
575 })
576 })
577```
578
579For 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.
580
581## Documenting Scripts
582
583Hubot scripts can be documented with comments at the top of their file, for example:
584
585```javascript
586// Description:
587// <description of the scripts functionality>
588//
589// Dependencies:
590// "<module name>": "<module version>"
591//
592// Configuration:
593// LIST_OF_ENV_VARS_TO_SET
594//
595// Commands:
596// hubot <trigger> - <what the respond trigger does>
597// <trigger> - <what the hear trigger does>
598//
599// Notes:
600// <optional notes required for the script>
601//
602// Author:
603// <github username of the original script author>
604```
605
606The 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](https://github.com/hubotio/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.
607
608When documenting commands, here are some best practices:
609
610* 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.
611* 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.
612* For `robot.respond` documentation, always prefix with `hubot`. Hubot will automatically replace this with your robot's name, or the robot's alias if it has one
613* Check out how man pages document themselves. In particular, brackets indicate optional parts, '...' for any number of parameters, etc.
614
615The other sections are more relevant to developers of the bot, particularly dependencies, configuration variables, and notes. All contributions to [hubot-scripts](https://github.com/github/hubot-scripts) should include all these sections that are related to getting up and running with the script.
616
617## Persistence
618
619Hubot 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`.
620
621### Brain
622
623```javascript
624robot.respond(/have a soda/i, (res) => {
625 // Get number of sodas had (coerced to a number).
626 const sodasHad = robot.brain.get('totalSodas') * 1 ?? 0
627
628 if (sodasHad > 4) {
629 res.reply(`I'm too fizzy..`)
630 } else {
631 res.reply('Sure!')
632 robot.brain.set('totalSodas', sodasHad + 1)
633 }
634})
635
636robot.respond(/sleep it off/i, (res) => {
637 robot.brain.set('totalSodas', 0)
638 res.reply('zzzzz')
639}
640```
641
642If 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`.
643
644```javascript
645module.exports = (robot) => {
646 robot.respond(/who is @?([\w .\-]+)\?*$/i, (res) => {
647 const name = res.match[1].trim()
648
649 const users = robot.brain.usersForFuzzyName(name)
650 if (users.length == 1) {
651 const user = users[0]
652 // Do something interesting here..
653 }
654 res.send(`${name} is user - ${user}`)
655 })
656}
657```
658
659### Datastore
660
661Unlike 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:
662
663```javascript
664robot.respond(/have a soda/i, (res) => {
665 // Get number of sodas had (coerced to a number).
666 robot.datastore.get('totalSodas').then((value) => {
667 const sodasHad = value * 1 ?? 0
668
669 if (sodasHad > 4) {
670 res.reply(`I'm too fizzy..`)
671 } else {
672 res.reply('Sure!')
673 robot.brain.set('totalSodas', sodasHad + 1)
674 }
675 })
676})
677
678robot.respond(/sleep it off/i, (res) => {
679 robot.datastore.set('totalSodas', 0).then(() => {
680 res.reply('zzzzz')
681 })
682})
683```
684
685The datastore also allows setting and getting values which are scoped to individual users:
686
687```javascript
688module.exports = (robot) ->
689
690 robot.respond(/who is @?([\w .\-]+)\?*$/i, (res) => {
691 const name = res.match[1].trim()
692
693 const users = robot.brain.usersForFuzzyName(name)
694 if (users.length == 1) {
695 const user = users[0]
696 user.get('roles').then((roles) => {
697 res.send "#{name} is #{roles.join(', ')}"
698 })
699 }
700 })
701```
702
703## Script Loading
704
705There are three main sources to load scripts from:
706
707* all scripts __bundled__ with your hubot installation under `scripts/` directory
708* __community scripts__ specified in `hubot-scripts.json` and shipped in the `hubot-scripts` npm package
709* scripts loaded from external __npm packages__ and specified in `external-scripts.json`
710
711Scripts loaded from the `scripts/` directory are loaded in alphabetical order, so you can expect a consistent load order of scripts. For example:
712
713* `scripts/1-first.js`
714* `scripts/_second.js`
715* `scripts/third.js`
716
717# Sharing Scripts
718
719Once 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](http://npmjs.org). You should also review the best practices for sharing scripts below.
720
721## See if a script already exists
722
723Start by [checking if an NPM package](index.md#scripts) 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 the `hubot` script [yeoman](http://yeoman.io/) generator.
724
725## Creating A Script Package
726
727Creating a script package for hubot is very simple. Start by installing the `hubot` [yeoman](http://yeoman.io/) generator:
728
729
730```
731% npm install -g yo generator-hubot
732```
733
734Once you've got the hubot generator installed, creating a hubot script is similar to creating a new hubot. You create a directory for your hubot script and generate a new `hubot:script` in it. For example, if we wanted to create a hubot script called "my-awesome-script":
735
736```
737% mkdir hubot-my-awesome-script
738% cd hubot-my-awesome-script
739% yo hubot:script
740```
741
742At this point, you'll be asked a few questions about the author of the script, name of the script (which is guessed by the directory name), a short description, and keywords to find it (we suggest having at least `hubot, hubot-scripts` in this list).
743
744If you are using git, the generated directory includes a .gitignore, so you can initialize and add everything:
745
746```
747% git init
748% git add .
749% git commit -m "Initial commit"
750```
751
752You now have a hubot script repository that's ready to roll! Feel free to crack open the pre-created `src/awesome-script.js` file and start building up your script! When you've got it ready, you can publish it to [npmjs](http://npmjs.org) by [following their documentation](https://docs.npmjs.com/getting-started/publishing-npm-packages)!
753
754You'll probably want to write some unit tests for your new script. A sample test script is written to
755`test/awesome-script-test.js`, which you can run with `grunt`. For more information on tests,
756see the [Testing Hubot Scripts](#testing-hubot-scripts) section.
757
758# Listener Metadata
759
760In 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.
761
762The 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.
763
764Additional extensions may define and handle additional metadata keys. For more information, see the [Listener Middleware section](#listener-middleware).
765
766Returning to an earlier example:
767
768```javascript
769module.exports = (robot) => {
770 robot.respond(/annoy me/, id:'annoyance.start', (res) => {
771 // code to annoy someone
772 })
773
774 robot.respond(/unannoy me/, id:'annoyance.stop', (res) => {
775 // code to stop annoying someone
776 })
777}
778```
779
780These scoped identifiers allow you to externally specify new behaviors like:
781- authorization policy: "allow everyone in the `annoyers` group to execute `annoyance.*` commands"
782- rate limiting: "only allow executing `annoyance.start` once every 30 minutes"
783
784# Middleware
785
786There are three kinds of middleware: Receive, Listener and Response.
787
788Receive middleware runs once, before listeners are checked.
789Listener middleware runs for every listener that matches the message.
790Response middleware runs for every response sent to a message.
791
792## Execution Process and API
793
794Similar to [Express middleware](http://expressjs.com/api.html#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).
795
796Middleware is called with:
797
798- `context`
799 - See the each middleware type's API to see what the context will expose.
800- `next`
801 - a Function with no additional properties that should be called to continue on to the next piece of middleware/execute the Listener callback
802 - `next` should be called with a single, optional parameter: either the provided `done` function or a new function that eventually calls `done`. If the parameter is not given, the provided `done` will be assumed.
803- `done`
804 - a Function with no additional properties that should be called to interrupt middleware execution and begin executing the chain of completion functions.
805 - `done` should be called with no parameters
806
807Every middleware receives the same API signature of `context`, `next`, and
808`done`. Different kinds of middleware may receive different information in the
809`context` object. For more details, see the API for each type of middleware.
810
811### Error Handling
812
813For synchronous middleware (never yields to the event loop), hubot will automatically catch errors and emit an `error` event, just like in standard listeners. Hubot will also automatically call the most recent `done` callback to unwind the middleware stack. Asynchronous middleware should catch its own exceptions, emit an `error` event, and call `done`. Any uncaught exceptions will interrupt all execution of middleware completion callbacks.
814
815# Listener Middleware
816
817Listener 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`.
818
819## Listener Middleware Examples
820
821A fully functioning example can be found in [hubot-rate-limit](https://github.com/michaelansel/hubot-rate-limit/blob/master/src/rate-limit.coffee).
822
823A simple example of middleware logging command executions:
824
825```javascript
826module.exports = (robot) => {
827 robot.listenerMiddleware((context, next, done) => {
828 // Log commands
829 robot.logger.info(`${context.response.message.user.name} asked me to ${context.response.message.text}`)
830 // Continue executing middleware
831 next()
832 })
833}
834```
835
836In this example, a log message will be written for each chat message that matches a Listener.
837
838A more complex example making a rate limiting decision:
839
840```javascript
841module.exports = (robot) => {
842 // Map of listener ID to last time it was executed
843 let lastExecutedTime = {}
844
845 robot.listenerMiddleware((context, next, done) => {
846 try {
847 // Default to 1s unless listener provides a different minimum period
848 const minPeriodMs = context.listener.options?.rateLimits?.minPeriodMs ?? 1000
849
850 // See if command has been executed recently
851 if (lastExecutedTime.hasOwnProperty(context.listener.options.id) &&
852 lastExecutedTime[context.listener.options.id] > Date.now() - minPeriodMs) {
853 // Command is being executed too quickly!
854 done()
855 } else {
856 next(()=> {
857 lastExecutedTime[context.listener.options.id] = Date.now()
858 done()
859 })
860 }
861 } catch(err) {
862 robot.emit('error', err, context.response)
863 }
864 })
865}
866```
867
868In this example, the middleware checks to see if the listener has been executed in the last 1,000ms. If it has, the middleware calls `done` immediately, preventing the listener callback from being called. If the listener is allowed to execute, the middleware attaches a `done` handler so that it can record the time the listener *finished* executing.
869
870This 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.
871
872```javascript
873module.exports = (robot) => {
874 robot.hear(/hello/, id: 'my-hello', rateLimits: {minPeriodMs: 10000}, (res) => {
875 // This will execute no faster than once every ten seconds
876 res.reply('Why, hello there!')
877 })
878}
879```
880
881## Listener Middleware API
882
883Listener middleware callbacks receive three parameters, `context`, `next`, and
884`done`. See the [middleware API](#execution-process-and-api) for a description
885of `next` and `done`. Listener middleware context includes these fields:
886 - `listener`
887 - `options`: a simple Object containing options set when defining the listener. See [Listener Metadata](#listener-metadata).
888 - all other properties should be considered internal
889 - `response`
890 - all parts of the standard response API are included in the middleware API. See [Send & Reply](#send--reply).
891 - 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)
892 - note: the textual message (`response.message.text`) should be considered immutable in listener middleware
893
894# Receive Middleware
895
896Receive middleware runs before any listeners have executed. It's suitable for
897excluded commands that have not been updated to add an ID, metrics, and more.
898
899## Receive Middleware Example
900
901This simple middlware bans hubot use by a particular user, including `hear`
902listeners. If the user attempts to run a command explicitly, it will return
903an error message.
904
905```javascript
906const EXCLUDED_USERS = [
907 '12345' // Restrict access for a user ID for a contractor
908]
909
910robot.receiveMiddleware((context, next, done) => {
911 if (EXCLUDED_USERS.some( id => context.response.message.user.id == id)) {
912 // Don't process this message further.
913 context.response.message.finish()
914
915 // If the message starts with 'hubot' or the alias pattern, this user was
916 // explicitly trying to run a command, so respond with an error message.
917 if (context.response.message.text?.match(robot.respondPattern(''))) {
918 context.response.reply(`I'm sorry @${context.response.message.user.name}, but I'm configured to ignore your commands.`)
919 }
920
921 // Don't process further middleware.
922 done()
923 } else {
924 next(done)
925 }
926})
927```
928
929## Receive Middleware API
930
931Receive middleware callbacks receive three parameters, `context`, `next`, and
932`done`. See the [middleware API](#execution-process-and-api) for a description
933of `next` and `done`. Receive middleware context includes these fields:
934 - `response`
935 - this response object will not have a `match` property, as no listeners have been run yet to match it.
936 - middleware may decorate the response object with additional information (e.g. add a property to `response.message.user` with a user's LDAP groups)
937 - middleware may modify the `response.message` object
938
939# Response Middleware
940
941Response middleware runs against every message hubot sends to a chat room. It's
942helpful for message formatting, preventing password leaks, metrics, and more.
943
944## Response Middleware Example
945
946This simple example changes the format of links sent to a chat room from
947markdown links (like [example](https://example.com)) to the format supported
948by [Slack](https://slack.com), <https://example.com|example>.
949
950```javascript
951module.exports = (robot)=> {
952 robot.responseMiddleware((context, next, done) => {
953 if(!context.plaintext) return
954 context.strings.forEach(string => {
955 string.replace(/\[([^\[\]]*?)\]\((https?:\/\/.*?)\)/, "<$2|$1>"
956 })
957 next()
958 })
959}
960```
961
962## Response Middleware API
963
964Response middleware callbacks receive three parameters, `context`, `next`, and
965`done`. See the [middleware API](#execution-process-and-api) for a description
966of `next` and `done`. Receive middleware context includes these fields:
967 - `response`
968 - 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.
969 - `strings`
970 - An array of strings being sent to the chat room adapter. You can edit these, or use `context.strings = ["new strings"]` to replace them.
971 - `method`
972 - A string representing which type of response message the listener sent, such as `send`, `reply`, `emote` or `topic`.
973 - `plaintext`
974 - `true` or `undefined`. This will be set to `true` if the message is of a normal plaintext type, such as `send` or `reply`. This property should be treated as read-only.
975
976# Testing Hubot Scripts
977
978[hubot-test-helper](https://github.com/mtsmfm/hubot-test-helper) is a good
979framework for unit testing Hubot scripts. (Note that, in order to use
980hubot-test-helper, you'll need a recent Node.js version with support for Promises.)
981
982Install the package in your Hubot instance:
983
984``` % npm install hubot-test-helper --save-dev ```
985
986You'll also need to install:
987
988 * a JavaScript testing framework such as *Mocha*
989 * an assertion library such as *chai* or *expect.js*
990
991You may also want to install:
992
993 * a mocking library such as *Sinon.js* (if your script performs webservice calls or
994 other asynchronous actions)
995
996[Note: This section is still refering to Coffeescript, but we've update Hubot for Javascript. We'll have to replace this when we get a JavaScript example.]
997
998Here is a sample script that tests the first couple of commands in the
999[Hubot sample script](https://github.com/hubotio/generator-hubot/blob/master/generators/app/templates/scripts/example.coffee). This script uses *Mocha*, *chai*, *coffeescript*, and of course *hubot-test-helper*:
1000
1001**test/example-test.coffee**
1002```coffeescript
1003Helper = require('hubot-test-helper')
1004chai = require 'chai'
1005
1006expect = chai.expect
1007
1008helper = new Helper('../scripts/example.coffee')
1009
1010describe 'example script', ->
1011 beforeEach ->
1012 @room = helper.createRoom()
1013
1014 afterEach ->
1015 @room.destroy()
1016
1017 it 'doesn\'t need badgers', ->
1018 @room.user.say('alice', 'did someone call for a badger?').then =>
1019 expect(@room.messages).to.eql [
1020 ['alice', 'did someone call for a badger?']
1021 ['hubot', 'Badgers? BADGERS? WE DON\'T NEED NO STINKIN BADGERS']
1022 ]
1023
1024 it 'won\'t open the pod bay doors', ->
1025 @room.user.say('bob', '@hubot open the pod bay doors').then =>
1026 expect(@room.messages).to.eql [
1027 ['bob', '@hubot open the pod bay doors']
1028 ['hubot', '@bob I\'m afraid I can\'t let you do that.']
1029 ]
1030
1031 it 'will open the dutch doors', ->
1032 @room.user.say('bob', '@hubot open the dutch doors').then =>
1033 expect(@room.messages).to.eql [
1034 ['bob', '@hubot open the dutch doors']
1035 ['hubot', '@bob Opening dutch doors']
1036 ]
1037```
1038
1039**sample output**
1040```bash
1041% mocha --require coffeescript/register test/*.coffee
1042
1043
1044 example script
1045 ✓ doesn't need badgers
1046 ✓ won't open the pod bay doors
1047 ✓ will open the dutch doors
1048
1049
1050 3 passing (212ms)
1051```