From 483e7030a38499a6f1ca3c525aa33ef7b110767d Mon Sep 17 00:00:00 2001 From: Greg Walker Date: Fri, 28 Dec 2018 10:13:28 -0600 Subject: [PATCH 1/2] generic random post script; configure dag facts --- config/slack-random-response.json | 8 ++ scripts/dag_fact.coffee | 142 ------------------- scripts/random-responses.js | 95 +++++++++++++ test/scripts/dag_fact.js | 85 ------------ test/scripts/random-responses.js | 217 ++++++++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 227 deletions(-) create mode 100644 config/slack-random-response.json delete mode 100644 scripts/dag_fact.coffee create mode 100644 scripts/random-responses.js delete mode 100644 test/scripts/dag_fact.js create mode 100644 test/scripts/random-responses.js diff --git a/config/slack-random-response.json b/config/slack-random-response.json new file mode 100644 index 00000000..113522a7 --- /dev/null +++ b/config/slack-random-response.json @@ -0,0 +1,8 @@ +[ + { + "botName": "Dag Bot", + "defaultEmoji": ":dog:", + "trigger": "d(o|a)g facts?", + "responseUrl": "https://raw.githubusercontent.com/18F/18f-bot-facts/master/dags.json" + } +] diff --git a/scripts/dag_fact.coffee b/scripts/dag_fact.coffee deleted file mode 100644 index 68612c1e..00000000 --- a/scripts/dag_fact.coffee +++ /dev/null @@ -1,142 +0,0 @@ -# Description: -# Selects a random dog fact -# -# Commands: -# dag facts -# -# Author: -# TC Baxter - -facts = [ - ":belle-dag: Belle loves sunbeams more than anything except belly rubs and bacon.", - ":belle-dag: Belle likes to have her nails painted. She holds out her paw to have it - done, then wants to show everyone how they look when they're done.", - ":belle-dag: Belle can't see or hear all that well anymore. - She relies on her well-honed sniffer.", - ":bubba: Bubba used to be scared of walking down the stairs, - so Kate and Micah had to carry him downstairs each day.", - ":bubba: Bubba’s favorite food is cottage cheese.", - ":bubba: On walks, Bubba is easily intimidated — he’s typically scared of - garbage cans and upturned paper grocery bags.", - ":bubba: Bubba was originally going to be named “Tater Tot” (but Bubba was a better fit).", - ":cooper: Cooper's full name is Wilford J Cooperbottom.", - ":cooper: Cooper only likes two toys: his laser and destroying whatever toy - Cupcake had a minute ago.", - ":cooper: One day Cooper was paying too much attention to another dog - and he walked right into a parked car.", - ":cooper: Every winter we have to rub dryer sheets on Cooper or else he gets shocked every - time someone touches him. He gets very careful about touching anyone.", - ":cooper: When we go to the dog park, Cooper likes to show off on the agility dog ramps. - We never trained him to do the ramps, - he just saw other dogs on them and he likes to one-up other dogs.", - ":cooper: If you give :cooper: Cooper and :cupcake-dag: Cupcake a toy they will remember. - When you come to visit months later they will go to the toybox and get it out to show you - that they still have it." - ":cupcake-dag: Cupcake does not like hats. At all.", - ":cupcake-dag: Cupcake's full name is Princess Petunia Cupcake.", - ":cupcake-dag: Cupcake likes to nap on the back of the couch.", - ":cupcake-dag: Cupcake can roll down car windows, but she won't do it for Cooper.", - ":fox-dag: Fox and Luna are 18F dog twins.", - ":fox-dag: Fox's favorite food is smoked salmon. She's fancy like that.", - ":fox-dag: Fox once barked at a picture of a dog in a magazine.", - ":hugo: Hugo is named after Hugo Cabret, the literary child abandoned to - raise himself in the infrastructure of a railway station, - on account of his being abandoned by his prior owners.", - ":hugo: Hugo can be convinced to crawl under a bed from one side to the other when amped up.", - ":hugo: Hugo will do a somersault on command, - but only in front of the bedroom closet where he originally learned the trick.", - ":hugo: Hugo is terrified of humans wearing headbands with springy things on them, - even if they are people he knows well.", - ":laddie-dag: Laddie is scared of his own farts.", - ":loki: Loki will give you the stare of death if you do not share an apple with him.", - ":loki: Loki’s tongue is physically too large for his own mouth and just hangs out all day.", - ":loki: Loki once ate an entire bag of Reese’s peanut butter cups, wrappers and all. - He had sparkly :poop: for a week!", - ":loki: Loki once decided it was a good idea to roll around in cow patties - and run up to us like it was no big deal.", - ":loki: Loki was named after the Nordic god of tricks and mischief, - which fits his personality perfectly.", - ":mahalo_goldeneye: Mahalo raced in Daytona Beach, Florida.", - ":mason: Mason's middle name is Dreamsicle. It was his name when we adopted him - (his sister was Creamsicle).", - ":neko: Jessie dropped an apple once, and Neko grabbed it like a ball - and went running around the house to show it off.", - ":neko: Neko is part rottweiler, part pit bull, and part chow chow, - which is the best and weirdest and most surprising thing.", - ":paloma: Paloma looks a little like Toto from The Wizard of Oz. She dislikes the car but loves riding in a bike basket, which inspires lots of \"TOTO!\" calls as she rides through town.", - ":paloma: Paloma knows how to ring a bell on command. But, she never quite caught onto the fact that ringing the bell would alert her parents to take her outside, so now it's just a fun party trick.", - ":paloma: According to Wisdom Panel, Paloma is a lhasapoouhuahua (lhasa apso / poodle / chihuahua mix). Because Paloma's mom is embarrassed to admit she DNA tested her dog, she tells people she's simply a \"terrier mix.\"", - ":paloma: When Paloma meets someone new, she begs for a belly rub. If she knows you already, she also begs for a belly rub. It's her standard greeting (unless you're a delivery driver...).", - ":pancho-dag: Pancho can read.", - ":pancho-dag: Pancho is not color blind, he knows UPS brown.", - ":pixie: I am a rescue that was found injured on the side of the highway. - Don't worry I'm all healed up and healthy now. - Pixie", - ":pixie: I like to give \"puppy hugs\", where I lean on you - placing my paws around you. - Pixie :two_hearts:", - ":pixie: I love to chase and bark at wheels! - Pixie", - ":pixie: My middle name is sioux. - Pixie", - ":pixie: My favorite chew toy was \"my Tito\", a plush Titos vodka bottle - gifted from my neighbor who works in the food/bev industry :joy:. - Tito was retired in 2018, and my new favorite is \"Ricky\", an - unstuffed floppy raccoon. - Pixie", - ":pixie: I love to go on hikes in the wilderness and will hike all day. - Pixie", - ":pixie: I still see my foster mom regularly - and stay with her when my owners go on extendted trips. - Pixie", - ":pixie: My owner is @scottweber - Pixie", - ":ripley-dag: Ripley sleeps on her dog bed in our bedroom, but every morning, - about a half hour before it's time for us to get up, - she climbs into the human bed under the covers for some snugs", - ":ripley-dag: One time Ripley ate some weird peach thing from - the tree on the corner and barfed on the couch.", - ":ripley-dag: Ripley is scared of even the smallest human toot, - but happily delivers her own brutal farts with extreme nonchalance.", - ":ripley-dag: Things Ripley has calmly slept through: thunderstorms, fireworks, - the dogs next door barking all day, our house getting its roof replaced. :sweat_smile:", - ":ripley-dag: Ripley was the star pupil of her obedience class, at least until she - celebrated by dropping a :poop: in the middle of the floor on graduation day.", - ":ripley-dag: Ripley sleeps on her dog bed in our bedroom, but every morning, - about a half hour before it's time for us to get up, she climbs into the human bed - under the covers for some snugs.", - ":ripley-dag: Thanks to the magic of static cling, Ripley often gets up from a nap and wanders - around the house with her blanket stuck to her like a shawl or some kind of strange dog skirt.", - ":ruby-dag: If there is snow on the ground and I'm outside, there is snow on my snoot. - Ruby", - ":ruby-dag: I like to growl at carved pumpkins. :jack_o_lantern: - Ruby", - ":ruby-dag: Finding street food on the ground is one of my best talents. I found a _full chicken thigh_ once. - Ruby", - ":scully: Scully doesn't chew human things she's not supposed to. - But she does pick up socks that are left on the floor and move them to other locations.", - ":winry-smile: Winry was afraid of going up or down stairs, - so when Greg moved into a house that had them, - she just slept in the living room by herself until she got over it.", - ":winry-smile: Winry ate a banana peel once when Greg wasn't looking. - She was totally fine.", - ":winry-smile: Winry has heterochromia - one eye is solid blue - but the other is half blue,half brown.", - ":winry-smile: Sometimes if you put one of Winry's back feet near her ear, - it'll start scratching automatically. - That gets on her nerves and she'll start biting her foot to make it stop.", - ":winry-smile: Winry likes to be pushed around the carpet. - You push her away and she jumps up and comes running back to your hands." - ":zoey: Zoey thinks she is the cat police at our house: - if they are fighting (usually playfully) with each other, she runs up to them, - sits in front of them and whines until they stop.", - ":zoey: Zoey loves to take baths. When I say, \"time for a bath?!\" - she will run to the bathtub and sit down.", -] - -module.exports = (robot) -> - robot.hear /dag fact(s)?/i, (res) -> - fact = res.random(facts) - - emoji = ':dog:' - match = fact.match /^(:[^:]+:)(.*)$/ - if match - emoji = match[1] - fact = match[2].trim() - - res.send - text: fact - as_user: false - username: 'Dag Bot (Charlie)' - icon_emoji: emoji - -module.exports.factList = facts; diff --git a/scripts/random-responses.js b/scripts/random-responses.js new file mode 100644 index 00000000..12fda6e5 --- /dev/null +++ b/scripts/random-responses.js @@ -0,0 +1,95 @@ +const fs = require('fs'); +const configs = JSON.parse( + fs.readFileSync('config/slack-random-response.json') +); + +const cachedRequests = {}; + +const getResponses = async (robot, config) => { + if (config.responseList) { + return config.responseList; + } + + if (config.responseUrl) { + if (cachedRequests[config.responseUrl]) { + const cached = cachedRequests[config.responseUrl]; + if (Date.now() < cached.expiry) { + return cached.value; + } + } + + return new Promise(resolve => { + robot + .http(config.responseUrl) + .header('User-Agent', '18F-bot') + .get()((err, res, body) => { + cachedRequests[config.responseUrl] = { + expiry: Date.now() + 60000, // five minutes + value: JSON.parse(body) + }; + resolve(JSON.parse(body)); + }); + }); + } +}; + +const responseFrom = ( + robot, + { botName = null, defaultEmoji = null, ...config } = {} +) => async res => { + const message = {}; + if (defaultEmoji) { + message.icon_emoji = defaultEmoji; + } + if (botName) { + message.username = botName; + } + + const responses = await getResponses(robot, config); + const response = res.random(responses); + + if (typeof response === 'object') { + message.text = response.text; + if (response.name) { + message.username = response.name + (botName ? ` (${botName})` : ''); + } + if (response.emoji) { + message.icon_emoji = response.emoji; + } + } else { + message.text = response; + const match = response.match(/^(:[^:]+:)(.*)$/); + if (match) { + message.icon_emoji = match[1]; + message.text = match[2].trim(); + } + } + + if (message.icon_emoji || message.username) { + message.as_user = false; + } + + res.send(message); +}; + +const attachTrigger = (robot, trigger, config) => { + if (Array.isArray(trigger)) { + trigger.forEach(t => + robot.hear(new RegExp(t, 'i'), responseFrom(robot, config)) + ); + } else { + robot.hear(new RegExp(trigger, 'i'), responseFrom(robot, config)); + } +}; + +module.exports = robot => { + if (Array.isArray(configs)) { + configs.forEach(async config => { + attachTrigger(robot, config.trigger, config); + }); + } +}; + +module.exports.attachTrigger = attachTrigger; +module.exports.getResponses = getResponses; +module.exports.responseFrom = responseFrom; diff --git a/test/scripts/dag_fact.js b/test/scripts/dag_fact.js deleted file mode 100644 index f73a94fa..00000000 --- a/test/scripts/dag_fact.js +++ /dev/null @@ -1,85 +0,0 @@ -const Helper = require('hubot-test-helper'); -const sinon = require('sinon'); -const facts = require('../../scripts/dag_fact.coffee'); - -const helper = new Helper('../../scripts/dag_fact.coffee'); - -const co = require('co'); -const expect = require('chai').expect; - -describe('daggity facts', () => { - // Replace the list of facts with one known fact, so we don't - // have to worry about randomness. - facts.factList.splice( - 0, - facts.factList.length, - ':emoji: this is an injected fact' - ); - - beforeEach(() => { - this.room = helper.createRoom(); - }); - afterEach(() => { - this.room.destroy(); - }); - - describe('user asks for a dag fact', () => { - beforeEach(() => { - return co( - function*() { - yield this.room.user.say('alice', 'I request one dag fact please'); - yield this.room.user.say('bob', 'I too would like some dag facts'); - }.bind(this) - ); - }); - - it('should reply with a wonderful fact about a dag', () => { - expect(this.room.messages).to.eql([ - ['alice', 'I request one dag fact please'], - [ - 'hubot', - { - text: 'this is an injected fact', - as_user: false, - username: 'Dag Bot (Charlie)', - icon_emoji: ':emoji:' - } - ], - ['bob', 'I too would like some dag facts'], - [ - 'hubot', - { - text: 'this is an injected fact', - as_user: false, - username: 'Dag Bot (Charlie)', - icon_emoji: ':emoji:' - } - ] - ]); - }); - }); - - describe('a little more in-depth test', () => { - const robot = { hear: sinon.stub() }; - facts(robot); - const handler = robot.hear.args[0][1]; - - it('sends a random response from an array', () => { - const res = { - random: sinon.stub().returns(':rrrrandom: random!'), - send: sinon.stub() - }; - handler(res); - - expect(res.random.calledWith(sinon.match.array)).to.eql(true); - expect( - res.send.calledWith({ - text: 'random!', - as_user: false, - username: 'Dag Bot (Charlie)', - icon_emoji: ':rrrrandom:' - }) - ).to.eql(true); - }); - }); -}); diff --git a/test/scripts/random-responses.js b/test/scripts/random-responses.js new file mode 100644 index 00000000..1107fade --- /dev/null +++ b/test/scripts/random-responses.js @@ -0,0 +1,217 @@ +const sinon = require('sinon'); +const expect = require('chai').expect; + +const script = require('../../scripts/random-responses'); + +describe('random responder', () => { + const sandbox = sinon.createSandbox(); + beforeEach(() => { + sandbox.resetBehavior(); + sandbox.resetHistory(); + }); + + describe('response builder', () => { + const res = { + random: sandbox.stub(), + send: sandbox.spy() + }; + + const config = { botName: null, defaultEmoji: null }; + beforeEach(() => { + config.botName = null; + config.defaultEmoji = null; + }); + + const responsePermutations = [ + ['simple string response', 1, 'a message', { text: 'a message' }], + [ + 'string with emoji', + 2, + ':emoji: b message', + { text: 'b message', icon_emoji: ':emoji:', as_user: false } + ], + [ + 'message object with no name or emoji', + 3, + { text: 'c message' }, + { text: 'c message' } + ], + [ + 'message object with no name', + 4, + { text: 'd message', emoji: ':emoji:' }, + { text: 'd message', icon_emoji: ':emoji:', as_user: false } + ], + [ + 'message object with no emoji', + 5, + { text: 'e message', name: 'bob' }, + { text: 'e message', username: 'bob', as_user: false } + ], + [ + 'full message object', + 6, + { text: 'f message', emoji: ':emoji:', name: 'bob' }, + { + text: 'f message', + icon_emoji: ':emoji:', + username: 'bob', + as_user: false + } + ] + ]; + + describe('with no config', () => { + responsePermutations.forEach( + ([responseName, responses, returned, expected]) => { + it(responseName, async () => { + res.random.returns(returned); + await script.responseFrom( + {}, + { ...config, responseList: responses } + )(res); + expect(res.random.calledWith(responses)).to.eql(true); + expect(res.send.calledWith(expected)).to.eql(true); + }); + } + ); + }); + + describe('with default emoji set', () => { + const baseExpectation = { icon_emoji: ':default-emoji:', as_user: false }; + + responsePermutations.forEach( + ([responseName, responses, returned, expected]) => { + it(responseName, async () => { + config.defaultEmoji = ':default-emoji:'; + res.random.returns(returned); + await script.responseFrom( + {}, + { ...config, responseList: responses } + )(res); + + expect(res.random.calledWith(responses)).to.eql(true); + expect( + res.send.calledWith({ ...baseExpectation, ...expected }) + ).to.eql(true); + }); + } + ); + }); + + describe('with bot name set', () => { + const baseExpectation = { username: 'bot name', as_user: false }; + + responsePermutations.forEach( + ([responseName, responses, returned, expected]) => { + it(responseName, async () => { + config.botName = 'bot name'; + if (expected.username) { + expected.username += ' (bot name)'; + } + res.random.returns(returned); + await script.responseFrom( + {}, + { ...config, responseList: responses } + )(res); + + expect(res.random.calledWith(responses)).to.eql(true); + expect( + res.send.calledWith( + sinon.match({ ...baseExpectation, ...expected }) + ) + ).to.eql(true); + }); + } + ); + }); + }); + + describe('response getter', () => { + it('gets responses directly from config', async () => { + const responses = await script.getResponses( + {}, + { responseList: 'these are my responses' } + ); + expect(responses).to.eql('these are my responses'); + }); + + it('gets responses from a url', async () => { + const http = sandbox.stub(); + const header = sandbox.stub(); + const get = sandbox.stub(); + const then = sandbox.stub(); + + const robot = { http }; + http.returns({ get, header }); + header.returns({ get, header }); + get.returns(then); + + then.callsArgWith(0, null, null, '{"key1":"value 1","key2":"value 2"}'); + + const responses = await script.getResponses(robot, { + responseUrl: 'over there' + }); + + expect(http.calledWith('over there')).to.eql(true); + expect(header.calledWith('User-Agent', '18F-bot')).to.eql(true); + expect(responses).to.eql({ key1: 'value 1', key2: 'value 2' }); + }); + + it('gets cached responses from a url', async () => { + const responses = await script.getResponses( + {}, + { + responseUrl: 'over there' + } + ); + expect(responses).to.eql({ key1: 'value 1', key2: 'value 2' }); + }); + }); + + describe('trigger attachment', () => { + const robot = { + hear: sandbox.spy() + }; + + it('handles a single text trigger', () => { + script.attachTrigger(robot, 'one trigger'); + expect(robot.hear.calledWith(/one trigger/i, sinon.match.func)).to.eql( + true + ); + }); + + it('handles a single regex trigger', () => { + script.attachTrigger(robot, /one regex/g); + expect(robot.hear.calledWith(/one regex/i, sinon.match.func)).to.eql( + true + ); + }); + + it('handles an array of text triggers', () => { + script.attachTrigger(robot, ['trigger 1', 'trigger 2', 'trigger 3']); + expect(robot.hear.calledWith(/trigger 1/i, sinon.match.func)).to.eql( + true + ); + expect(robot.hear.calledWith(/trigger 2/i, sinon.match.func)).to.eql( + true + ); + expect(robot.hear.calledWith(/trigger 3/i, sinon.match.func)).to.eql( + true + ); + }); + + it('handles an array of regex triggers', () => { + script.attachTrigger(robot, [/trigger 1/g, /trigger 2/g, /trigger 3/g]); + expect(robot.hear.calledWith(/trigger 1/i, sinon.match.func)).to.eql( + true + ); + expect(robot.hear.calledWith(/trigger 2/i, sinon.match.func)).to.eql( + true + ); + expect(robot.hear.calledWith(/trigger 3/i, sinon.match.func)).to.eql( + true + ); + }); + }); +}); From 423e098b8f82649d50e64c92ea4e50fac4a6b419 Mon Sep 17 00:00:00 2001 From: Greg Walker Date: Fri, 28 Dec 2018 11:09:20 -0600 Subject: [PATCH 2/2] comments; simplify an interface --- scripts/random-responses.js | 37 ++++++++++++++++++++++++++++++-- test/scripts/random-responses.js | 12 +++++++---- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/scripts/random-responses.js b/scripts/random-responses.js index 12fda6e5..e56dc770 100644 --- a/scripts/random-responses.js +++ b/scripts/random-responses.js @@ -5,12 +5,22 @@ const configs = JSON.parse( const cachedRequests = {}; +/** + * Given a configuration, get a list of responses for it. + * @param {*} robot The Hubot instance being used. Required for HTTP requests + * @param {*} config The configuration to fetch responses for. + * @returns {Promise} Resolves an array of responses + */ const getResponses = async (robot, config) => { + // If the config has a list of responses, use it + // and bail out. if (config.responseList) { return config.responseList; } if (config.responseUrl) { + // If we've hit this URL within the past five minutes, return the cached + // result rather than taking the network hit again so quickly if (cachedRequests[config.responseUrl]) { const cached = cachedRequests[config.responseUrl]; if (Date.now() < cached.expiry) { @@ -23,6 +33,8 @@ const getResponses = async (robot, config) => { .http(config.responseUrl) .header('User-Agent', '18F-bot') .get()((err, res, body) => { + // Cache off this data and set an expiration time so we know when to + // go back to the network cachedRequests[config.responseUrl] = { expiry: Date.now() + 60000, // five minutes value: JSON.parse(body) @@ -33,6 +45,16 @@ const getResponses = async (robot, config) => { } }; +/** + * Given a config, returns a Hubot message handler + * @param {*} robot The Hubot instance being used + * @param {Object} params + * @param {*} params.botName The name to use for the bot when responding + * @param {*} params.defaultEmoji The default emoji to use for the bot + * avatar when responding + * @param {*} params.config All other params properties are rolled into this + * @returns {Function} A Hubot message handler + */ const responseFrom = ( robot, { botName = null, defaultEmoji = null, ...config } = {} @@ -58,6 +80,9 @@ const responseFrom = ( } } else { message.text = response; + + // If the message begins with "::", use the "" as an emoji + // for the bot's avatar. const match = response.match(/^(:[^:]+:)(.*)$/); if (match) { message.icon_emoji = match[1]; @@ -65,6 +90,7 @@ const responseFrom = ( } } + // If we've set the message icon or username, we need to set as_user to false if (message.icon_emoji || message.username) { message.as_user = false; } @@ -72,7 +98,14 @@ const responseFrom = ( res.send(message); }; -const attachTrigger = (robot, trigger, config) => { +/** + * Attach listener(s) for a given config + * @param {*} robot The Hubot instance being used + * @param {Object} props + * @param {*} props.trigger The trigger property of the config + * @param {*} props.config The rest of the config object + */ +const attachTrigger = (robot, { trigger, ...config }) => { if (Array.isArray(trigger)) { trigger.forEach(t => robot.hear(new RegExp(t, 'i'), responseFrom(robot, config)) @@ -85,7 +118,7 @@ const attachTrigger = (robot, trigger, config) => { module.exports = robot => { if (Array.isArray(configs)) { configs.forEach(async config => { - attachTrigger(robot, config.trigger, config); + attachTrigger(robot, config); }); } }; diff --git a/test/scripts/random-responses.js b/test/scripts/random-responses.js index 1107fade..a1a0b401 100644 --- a/test/scripts/random-responses.js +++ b/test/scripts/random-responses.js @@ -175,21 +175,23 @@ describe('random responder', () => { }; it('handles a single text trigger', () => { - script.attachTrigger(robot, 'one trigger'); + script.attachTrigger(robot, { trigger: 'one trigger' }); expect(robot.hear.calledWith(/one trigger/i, sinon.match.func)).to.eql( true ); }); it('handles a single regex trigger', () => { - script.attachTrigger(robot, /one regex/g); + script.attachTrigger(robot, { trigger: /one regex/g }); expect(robot.hear.calledWith(/one regex/i, sinon.match.func)).to.eql( true ); }); it('handles an array of text triggers', () => { - script.attachTrigger(robot, ['trigger 1', 'trigger 2', 'trigger 3']); + script.attachTrigger(robot, { + trigger: ['trigger 1', 'trigger 2', 'trigger 3'] + }); expect(robot.hear.calledWith(/trigger 1/i, sinon.match.func)).to.eql( true ); @@ -202,7 +204,9 @@ describe('random responder', () => { }); it('handles an array of regex triggers', () => { - script.attachTrigger(robot, [/trigger 1/g, /trigger 2/g, /trigger 3/g]); + script.attachTrigger(robot, { + trigger: [/trigger 1/g, /trigger 2/g, /trigger 3/g] + }); expect(robot.hear.calledWith(/trigger 1/i, sinon.match.func)).to.eql( true );