├── CNAME ├── external-scripts.json ├── .gitignore ├── bin ├── hubot.cmd └── hubot ├── hubot-scripts.json ├── scripts ├── ghost-roadmap.coffee ├── events.coffee ├── youtube.coffee ├── math.coffee ├── ping.coffee ├── pugme.coffee ├── maps.coffee ├── httpd.coffee ├── rules.coffee ├── help.coffee ├── google-images.coffee ├── ghost-milestones.coffee ├── roles.coffee ├── translate.coffee ├── ghost-github.coffee ├── auth.coffee ├── ghost-issues.coffee └── logger.coffee ├── lib └── admins.coffee ├── package.json ├── runlocal.sh ├── LICENSE ├── runbot.sh └── README.md /CNAME: -------------------------------------------------------------------------------- 1 | www.slimer.co -------------------------------------------------------------------------------- /external-scripts.json: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | runbot.sh.test 4 | github-token.* -------------------------------------------------------------------------------- /bin/hubot.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | npm install && node_modules\.bin\hubot.cmd %* -------------------------------------------------------------------------------- /bin/hubot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm install 4 | export PATH="node_modules/.bin:node_modules/hubot/node_modules/.bin:$PATH" 5 | 6 | exec node_modules/.bin/hubot "$@" 7 | 8 | -------------------------------------------------------------------------------- /hubot-scripts.json: -------------------------------------------------------------------------------- 1 | ["hello.coffee", 2 | "karma.coffee", 3 | "redis-brain.coffee", 4 | "tweet.coffee", 5 | "shipit.coffee", 6 | "ambush.coffee", 7 | "git-help.coffee"] 8 | -------------------------------------------------------------------------------- /scripts/ghost-roadmap.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Github utility commands for TryGhost/Ghost 3 | # 4 | # Commands: 5 | # hubot roadmap - Reply with link to roadmap 6 | 7 | module.exports = (robot) -> 8 | robot.respond /roadmap/i, (response) -> 9 | roadmap = "https://trello.com/b/EceUgtCL/ghost-roadmap" 10 | plans = "https://github.com/TryGhost/Ghost/wiki/Planned-Features" 11 | response.send "Roadmap is at #{roadmap}, Future plans are at #{plans}" 12 | -------------------------------------------------------------------------------- /scripts/events.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Event system related utilities 3 | # 4 | # Commands: 5 | # hubot fake event - Triggers the event for debugging reasons 6 | # 7 | # Events: 8 | # debug - {user: } 9 | 10 | util = require 'util' 11 | 12 | module.exports = (robot) -> 13 | 14 | robot.respond /FAKE EVENT (.*)/i, (msg) -> 15 | msg.send "fake event '#{msg.match[1]}' triggered" 16 | robot.emit msg.match[1], {user: msg.message.user} 17 | 18 | robot.on 'debug', (event) -> 19 | robot.send event.user, util.inspect event -------------------------------------------------------------------------------- /lib/admins.coffee: -------------------------------------------------------------------------------- 1 | names = [ 2 | # The one who makes the birds sing # 3 | 'JohnONolan', 4 | # The one who cusses like a sailor # 5 | 'HannahWolfe', 6 | # The one who is also from Austria # 7 | 'sebgie', 8 | # The one who knows all the memes # 9 | 'javorszky', 10 | # The one who is the master of Azure # 11 | 'gotdibbs', 12 | # The one who runs the bot # 13 | 'jgable', 14 | # The one who is King of tests # 15 | 'jtw', 16 | # The one who wanders Wyoming # 17 | 'novaugust', 18 | # The Darth Vapor # 19 | 'pauladamdavis' 20 | ] 21 | 22 | regexes = (new RegExp("^#{name}$") for name in names) 23 | 24 | module.exports = { names, regexes } -------------------------------------------------------------------------------- /scripts/youtube.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Messing around with the YouTube API. 3 | # 4 | # Commands: 5 | # hubot youtube me - Searches YouTube for the query and returns the video embed link. 6 | module.exports = (robot) -> 7 | robot.respond /(youtube|yt)( me)? (.*)/i, (msg) -> 8 | query = msg.match[3] 9 | msg.http("http://gdata.youtube.com/feeds/api/videos") 10 | .query({ 11 | orderBy: "relevance" 12 | 'max-results': 15 13 | alt: 'json' 14 | q: query 15 | }) 16 | .get() (err, res, body) -> 17 | videos = JSON.parse(body) 18 | videos = videos.feed.entry 19 | video = msg.random videos 20 | 21 | video.link.forEach (link) -> 22 | if link.rel is "alternate" and link.type is "text/html" 23 | msg.send link.href 24 | 25 | -------------------------------------------------------------------------------- /scripts/math.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Allows Hubot to do mathematics. 3 | # 4 | # Commands: 5 | # hubot math me - Calculate the given expression. 6 | # hubot convert me to - Convert expression to given units. 7 | module.exports = (robot) -> 8 | robot.respond /(calc|calculate|convert|math|maths)( me)? (.*)/i, (msg) -> 9 | msg 10 | .http('https://www.google.com/ig/calculator') 11 | .query 12 | hl: 'en' 13 | q: msg.match[3] 14 | .headers 15 | 'Accept-Language': 'en-us,en;q=0.5', 16 | 'Accept-Charset': 'utf-8', 17 | 'User-Agent': "Mozilla/5.0 (X11; Linux x86_64; rv:2.0.1) Gecko/20100101 Firefox/4.0.1" 18 | .get() (err, res, body) -> 19 | # Response includes non-string keys, so we can't use JSON.parse here. 20 | json = eval("(#{body})") 21 | msg.send json.rhs || 'Could not compute.' 22 | 23 | -------------------------------------------------------------------------------- /scripts/ping.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Utility commands surrounding Hubot uptime. 3 | # 4 | # Commands: 5 | # hubot ping - Reply with pong 6 | # hubot echo - Reply back with 7 | # hubot time - Reply with current time 8 | # hubot die - End hubot process 9 | 10 | admins = require '../lib/admins' 11 | 12 | module.exports = (robot) -> 13 | robot.respond /PING$/i, (msg) -> 14 | msg.send "PONG" 15 | 16 | robot.respond /ECHO (.*)$/i, (msg) -> 17 | msg.send msg.match[1] 18 | 19 | robot.respond /TIME$/i, (msg) -> 20 | msg.send "Server time is: #{new Date()}" 21 | 22 | robot.respond /DIE$/i, (response) -> 23 | 24 | for adminReg in admins.regexes when response.message?.user?.name?.match(adminReg) 25 | response.send "Goodbye, cruel world." 26 | setTimeout (-> process.exit 0), 1000 27 | return 28 | 29 | response.send "Ah ah ah, you didn't say the magic word." -------------------------------------------------------------------------------- /scripts/pugme.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Pugme is the most important thing in your life 3 | # 4 | # Dependencies: 5 | # None 6 | # 7 | # Configuration: 8 | # None 9 | # 10 | # Commands: 11 | # hubot pug me - Receive a pug 12 | # hubot pug bomb N - get N pugs 13 | 14 | module.exports = (robot) -> 15 | 16 | robot.respond /pug me/i, (msg) -> 17 | msg.http("http://pugme.herokuapp.com/random") 18 | .get() (err, res, body) -> 19 | msg.send JSON.parse(body).pug 20 | 21 | robot.respond /pug bomb( (\d+))?/i, (msg) -> 22 | count = msg.match[2] || 5 23 | msg.http("http://pugme.herokuapp.com/bomb?count=" + count) 24 | .get() (err, res, body) -> 25 | msg.send pug for pug in JSON.parse(body).pugs 26 | 27 | robot.respond /how many pugs are there/i, (msg) -> 28 | msg.http("http://pugme.herokuapp.com/count") 29 | .get() (err, res, body) -> 30 | msg.send "There are #{JSON.parse(body).pug_count} pugs." 31 | 32 | -------------------------------------------------------------------------------- /scripts/maps.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Interacts with the Google Maps API. 3 | # 4 | # Commands: 5 | # hubot map me - Returns a map view of the area returned by `query`. 6 | 7 | module.exports = (robot) -> 8 | 9 | robot.respond /(?:(satellite|terrain|hybrid)[- ])?map me (.+)/i, (msg) -> 10 | mapType = msg.match[1] or "roadmap" 11 | location = msg.match[2] 12 | mapUrl = "http://maps.google.com/maps/api/staticmap?markers=" + 13 | escape(location) + 14 | "&size=400x400&maptype=" + 15 | mapType + 16 | "&sensor=false" + 17 | "&format=png" # So campfire knows it's an image 18 | url = "http://maps.google.com/maps?q=" + 19 | escape(location) + 20 | "&hl=en&sll=37.0625,-95.677068&sspn=73.579623,100.371094&vpsrc=0&hnear=" + 21 | escape(location) + 22 | "&t=m&z=11" 23 | 24 | msg.send mapUrl 25 | msg.send url 26 | 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slimer", 3 | "version": "2.5.5", 4 | "author": "TryGhost", 5 | "keywords": [ 6 | "github", 7 | "hubot", 8 | "campfire", 9 | "bot" 10 | ], 11 | "description": "A simple helpful robot for your Company", 12 | "licenses": [ 13 | { 14 | "type": "MIT", 15 | "url": "http://github.com/github/hubot/raw/master/LICENSE" 16 | } 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/github/hubot.git" 21 | }, 22 | "dependencies": { 23 | "hubot": "2.5.5", 24 | "hubot-scripts": "2.4.6", 25 | "hubot-irc": "~0.2.4", 26 | "ntwitter": "~0.5.0", 27 | "moment": "~2.1.0", 28 | "redis": "~0.8.4", 29 | "jsdom": "~0.7.0", 30 | "underscore": "~1.5.1", 31 | "underscore.string": "~2.2.0rc", 32 | "githubot": "~0.4.0", 33 | "request": "~2.27.0", 34 | "git-at-me": "~0.0.2" 35 | }, 36 | "engines": { 37 | "node": ">= 0.8.x", 38 | "npm": ">= 1.1.x" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /runlocal.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Set Environment Variables 4 | export HUBOT_IRC_NICK=slimer-test 5 | export HUBOT_IRC_SERVER=irc.freenode.net 6 | export HUBOT_IRC_ROOMS="#ghost-slimer-test" 7 | export HUBOT_AUTH_ADMIN="jgable" 8 | export HUBOT_HOSTNAME="localhost:8008" 9 | 10 | # Using SSL? 11 | #export HUBOT_IRC_PORT=6697 12 | #export HUBOT_IRC_USESSL=true 13 | #export HUBOT_IRC_SERVER_FAKE_SSL=true 14 | 15 | # Server password? 16 | #export HUBOT_IRC_PASSWORD=password 17 | 18 | # Don't let hubot flood the room. 19 | export HUBOT_IRC_UNFLOOD=true 20 | 21 | # Output environment variables 22 | echo HUBOT_IRC_NICK=$HUBOT_IRC_NICK 23 | echo HUBOT_IRC_SERVER=$HUBOT_IRC_SERVER 24 | echo HUBOT_IRC_ROOMS=$HUBOT_IRC_ROOMS 25 | 26 | #echo HUBOT_IRC_PORT=$HUBOT_IRC_PORT 27 | #echo HUBOT_IRC_USESSL=$HUBOT_IRC_USESSL 28 | #echo HUBOT_IRC_SERVER_FAKE_SSL=$HUBOT_IRC_SERVER_FAKE_SSL 29 | #echo HUBOT_IRC_PASSWORD=$HUBOT_IRC_PASSWORD 30 | 31 | # Start the redis brain 32 | #echo "" 33 | #echo "Starting Redis Server" 34 | #/usr/local/bin/redis-server > /dev/null & 35 | 36 | echo "" 37 | echo "Starting bot" 38 | PORT=8008 ./bin/hubot -a irc 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2018 Ghost Foundation 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /runbot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Set Environment Variables 4 | export HUBOT_IRC_NICK=slimer 5 | export HUBOT_IRC_SERVER=irc.freenode.net 6 | export HUBOT_IRC_ROOMS="#ghost" 7 | export HUBOT_AUTH_ADMIN="jgable" 8 | export HUBOT_HOSTNAME="ec2-54-227-53-105.compute-1.amazonaws.com:8008" 9 | 10 | # Using SSL? 11 | #export HUBOT_IRC_PORT=6697 12 | #export HUBOT_IRC_USESSL=true 13 | #export HUBOT_IRC_SERVER_FAKE_SSL=true 14 | 15 | # Server password? 16 | #export HUBOT_IRC_PASSWORD=password 17 | 18 | # Don't let hubot flood the room. 19 | export HUBOT_IRC_UNFLOOD=true 20 | 21 | # Output environment variables 22 | echo HUBOT_IRC_NICK=$HUBOT_IRC_NICK 23 | echo HUBOT_IRC_SERVER=$HUBOT_IRC_SERVER 24 | echo HUBOT_IRC_ROOMS=$HUBOT_IRC_ROOMS 25 | 26 | #echo HUBOT_IRC_PORT=$HUBOT_IRC_PORT 27 | #echo HUBOT_IRC_USESSL=$HUBOT_IRC_USESSL 28 | #echo HUBOT_IRC_SERVER_FAKE_SSL=$HUBOT_IRC_SERVER_FAKE_SSL 29 | #echo HUBOT_IRC_PASSWORD=$HUBOT_IRC_PASSWORD 30 | 31 | # Start the redis brain 32 | #echo "" 33 | #echo "Starting Redis Server" 34 | #/usr/local/bin/redis-server > /dev/null & 35 | 36 | echo "" 37 | echo "Starting bot" 38 | PORT=8008 /home/ubuntu/slimer/bin/hubot -a irc 39 | -------------------------------------------------------------------------------- /scripts/httpd.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # A simple interaction with the built in HTTP Daemon 3 | # 4 | # Dependencies: 5 | # None 6 | # 7 | # Configuration: 8 | # None 9 | # 10 | # Commands: 11 | # None 12 | # 13 | # URLS: 14 | # /hubot/version 15 | # /hubot/ping 16 | # /hubot/time 17 | # /hubot/info 18 | # /hubot/ip 19 | 20 | spawn = require('child_process').spawn 21 | 22 | module.exports = (robot) -> 23 | 24 | robot.router.get "/hubot/version", (req, res) -> 25 | res.end robot.version 26 | 27 | robot.router.post "/hubot/ping", (req, res) -> 28 | res.end "PONG" 29 | 30 | robot.router.get "/hubot/time", (req, res) -> 31 | res.end "Server time is: #{new Date()}" 32 | 33 | robot.router.get "/hubot/info", (req, res) -> 34 | child = spawn('/bin/sh', ['-c', "echo I\\'m $LOGNAME@$(hostname):$(pwd) \\($(git rev-parse HEAD)\\)"]) 35 | 36 | child.stdout.on 'data', (data) -> 37 | res.end "#{data.toString().trim()} running node #{process.version} [pid: #{process.pid}]" 38 | child.stdin.end() 39 | 40 | robot.router.get "/hubot/ip", (req, res) -> 41 | robot.http('http://ifconfig.me/ip').get() (err, r, body) -> 42 | res.end body 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Slimer 2 | ====== 3 | 4 | [Ghost's](https://github.com/TryGhost/Ghost) IRC bot based on [Hubot](http://hubot.github.com). 5 | 6 | ## Custom Scripts 7 | 8 | - [Logger](https://github.com/TryGhost/Slimer/blob/master/scripts/logger.coffee) 9 | - [Ghost Issues](https://github.com/TryGhost/Slimer/blob/master/scripts/ghost-issues.coffee) 10 | - [Ghost Milestones](https://github.com/TryGhost/Slimer/blob/master/scripts/ghost-milestones.coffee) 11 | - [Ghost Roadmap](https://github.com/TryGhost/Slimer/blob/master/scripts/ghost-roadmap.coffee) 12 | 13 | ## Running Locally 14 | 15 | To get a version of the bot running locally clone the repo down and run the [runlocal.sh](https://github.com/TryGhost/Slimer/blob/master/runlocal.sh) shell script; e.g. `. runlocal.sh` from the project root. **NOTE**: You need to have a redis server running (`redis-server &` should do the trick for starting it in a background thread). 16 | 17 | This will start a bot named `slimer-test` that will join `#ghost-slimer-test` on irc.freenode.net but you can modify your runlocal.sh if you want to change it. 18 | 19 | ## Copyright & License 20 | 21 | Copyright (c) 2013-2018 Ghost Foundation - Released under the [MIT license](LICENSE). 22 | -------------------------------------------------------------------------------- /scripts/rules.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Make sure that hubot knows the rules. 3 | # 4 | # Commands: 5 | # hubot the rules - Make sure hubot still knows the rules. 6 | # 7 | # Notes: 8 | # DON'T DELETE THIS SCRIPT! ALL ROBAWTS MUST KNOW THE RULES 9 | 10 | rules = [ 11 | "1. A robot may not injure a human being or, through inaction, allow a human being to come to harm.", 12 | "2. A robot must obey any orders given to it by human beings, except where such orders would conflict with the First Law.", 13 | "3. A robot must protect its own existence as long as such protection does not conflict with the First or Second Law." 14 | ] 15 | 16 | otherRules = [ 17 | "A developer may not injure Apple or, through inaction, allow Apple to come to harm.", 18 | "A developer must obey any orders given to it by Apple, except where such orders would conflict with the First Law.", 19 | "A developer must protect its own existence as long as such protection does not conflict with the First or Second Law." 20 | ] 21 | 22 | module.exports = (robot) -> 23 | robot.respond /(what are )?the (three |3 )?(rules|laws)/i, (msg) -> 24 | text = msg.message.text 25 | if text.match(/apple/i) or text.match(/dev/i) 26 | msg.send otherRules.join('\n') 27 | else 28 | msg.send rules.join('\n') 29 | 30 | -------------------------------------------------------------------------------- /scripts/help.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Generates help commands for Hubot. 3 | # 4 | # Commands: 5 | # hubot help - Displays all of the help commands that Hubot knows about. 6 | # hubot help - Displays all help commands that match . 7 | # 8 | # URLS: 9 | # /hubot/help 10 | # 11 | # Notes: 12 | # These commands are grabbed from comment blocks at the top of each file. 13 | 14 | helpContents = (name, commands) -> 15 | 16 | """ 17 | 18 | 19 | 20 | #{name} Help 21 | 44 | 45 | 46 |

#{name} Help

47 |
48 | #{commands} 49 |
50 | 51 | 52 | """ 53 | 54 | module.exports = (robot) -> 55 | robot.respond /help\s*(.*)?$/i, (msg) -> 56 | msg.send 'http://' + process.env.HUBOT_HOSTNAME + '/help' 57 | 58 | robot.router.get '/help', (req, res) -> 59 | cmds = robot.helpCommands() 60 | emit = "

#{cmds.join '

'}

" 61 | 62 | emit = emit.replace /hubot/ig, "#{robot.name}" 63 | 64 | res.setHeader 'content-type', 'text/html' 65 | res.end helpContents robot.name, emit 66 | -------------------------------------------------------------------------------- /scripts/google-images.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # A way to interact with the Google Images API. 3 | # 4 | # Commands: 5 | # hubot image me - The Original. Queries Google Images for and returns a random top result. 6 | # hubot animate me - The same thing as `image me`, except adds a few parameters to try to return an animated GIF instead. 7 | # hubot mustache me - Adds a mustache to the specified URL. 8 | # hubot mustache me - Searches Google Images for the specified query and mustaches it. 9 | 10 | module.exports = (robot) -> 11 | robot.respond /(image|img)( me)? (.*)/i, (msg) -> 12 | imageMe msg, msg.match[3], (url) -> 13 | msg.send url 14 | 15 | robot.respond /animate( me)? (.*)/i, (msg) -> 16 | imageMe msg, msg.match[2], true, (url) -> 17 | msg.send url 18 | 19 | robot.respond /(?:mo?u)?sta(?:s|c)he?(?: me)? (.*)/i, (msg) -> 20 | type = Math.floor(Math.random() * 3) 21 | mustachify = "http://mustachify.me/#{type}?src=" 22 | imagery = msg.match[1] 23 | 24 | if imagery.match /^https?:\/\//i 25 | msg.send "#{mustachify}#{imagery}" 26 | else 27 | imageMe msg, imagery, false, true, (url) -> 28 | msg.send "#{mustachify}#{url}" 29 | 30 | imageMe = (msg, query, animated, faces, cb) -> 31 | cb = animated if typeof animated == 'function' 32 | cb = faces if typeof faces == 'function' 33 | q = v: '1.0', rsz: '8', q: query, safe: 'active' 34 | q.imgtype = 'animated' if typeof animated is 'boolean' and animated is true 35 | q.imgtype = 'face' if typeof faces is 'boolean' and faces is true 36 | msg.http('http://ajax.googleapis.com/ajax/services/search/images') 37 | .query(q) 38 | .get() (err, res, body) -> 39 | images = JSON.parse(body) 40 | images = images.responseData?.results 41 | if images?.length > 0 42 | image = msg.random images 43 | cb "#{image.unescapedUrl}#.png" 44 | 45 | -------------------------------------------------------------------------------- /scripts/ghost-milestones.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Github utility commands for TryGhost/Ghost 3 | # 4 | # Commands: 5 | # ... milestone name - Reply with link to milestone 6 | # hubot milestone name - Reply with link to milestone 7 | 8 | request = require 'request' 9 | 10 | milestones = (user, repo) -> "https://api.github.com/repos/#{user}/#{repo}/milestones" 11 | 12 | module.exports = (robot) -> 13 | robot.respond /milestone ([^\s]+)/i, (response) -> 14 | milestoneName = response.match[1] 15 | milestoneUrl = milestones "TryGhost", "Ghost" 16 | 17 | unless milestoneName 18 | console.log "Missing milestoneName: #{milestoneName}" 19 | return 20 | 21 | opts = 22 | url: milestoneUrl 23 | headers: { 'User-Agent': 'Ghost Slimer' } 24 | 25 | request opts, (err, reqResp, body) -> 26 | if err 27 | console.log "Error getting milestone info: #{err.message}" 28 | return 29 | 30 | try 31 | ms = JSON.parse(body); 32 | milestoneList = ms.filter (obj) -> 33 | return obj.title == milestoneName 34 | milestone = milestoneList.shift(); 35 | return unless milestone 36 | 37 | title = milestone.title 38 | open = milestone.open_issues 39 | closed = milestone.closed_issues 40 | due = milestone.due_on 41 | text = "Milestone #{title} (#{open} open/#{closed} closed issues)" 42 | if due 43 | date = (new Date(due)).toDateString() 44 | text += " is due on #{date}" 45 | else 46 | text += " has no due date" 47 | url = "https://github.com/TryGhost/Ghost/issues?milestone=#{milestone.number}&state=open" 48 | text += " (#{url})" 49 | 50 | response.send text 51 | catch err 52 | console.log "Failed to get milestone info: #{err.message}, #{body}" 53 | -------------------------------------------------------------------------------- /scripts/roles.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Assign roles to people you're chatting with 3 | # 4 | # Commands: 5 | # hubot is a badass guitarist - assign a role to a user 6 | # hubot is not a badass guitarist - remove a role from a user 7 | # hubot who is - see what roles a user has 8 | # 9 | # Examples: 10 | # hubot holman is an ego surfer 11 | # hubot holman is not an ego surfer 12 | 13 | module.exports = (robot) -> 14 | 15 | getAmbiguousUserText = (users) -> 16 | "Be more specific, I know #{users.length} people named like that: #{(user.name for user in users).join(", ")}" 17 | 18 | robot.respond /who is @?([\w .\-]+)\?*$/i, (msg) -> 19 | joiner = ', ' 20 | name = msg.match[1].trim() 21 | 22 | if name is "you" 23 | msg.send "Who ain't I?" 24 | else if name is robot.name 25 | msg.send "The best." 26 | else 27 | users = robot.brain.usersForFuzzyName(name) 28 | if users.length is 1 29 | user = users[0] 30 | user.roles = user.roles or [ ] 31 | if user.roles.length > 0 32 | if user.roles.join('').search(',') > -1 33 | joiner = '; ' 34 | msg.send "#{name} is #{user.roles.join(joiner)}." 35 | else 36 | msg.send "#{name} is nothing to me." 37 | else if users.length > 1 38 | msg.send getAmbiguousUserText users 39 | else 40 | msg.send "#{name}? Never heard of 'em" 41 | 42 | robot.respond /@?([\w .\-_]+) is (["'\w: \-_]+)[.!]*$/i, (msg) -> 43 | name = msg.match[1].trim() 44 | newRole = msg.match[2].trim() 45 | 46 | unless name in ['', 'who', 'what', 'where', 'when', 'why'] 47 | unless newRole.match(/^not\s+/i) 48 | users = robot.brain.usersForFuzzyName(name) 49 | if users.length is 1 50 | user = users[0] 51 | user.roles = user.roles or [ ] 52 | 53 | if newRole in user.roles 54 | msg.send "I know" 55 | else 56 | user.roles.push(newRole) 57 | if name.toLowerCase() is robot.name.toLowerCase() 58 | msg.send "Ok, I am #{newRole}." 59 | else 60 | msg.send "Ok, #{name} is #{newRole}." 61 | else if users.length > 1 62 | msg.send getAmbiguousUserText users 63 | else 64 | msg.send "I don't know anything about #{name}." 65 | 66 | robot.respond /@?([\w .\-_]+) is not (["'\w: \-_]+)[.!]*$/i, (msg) -> 67 | name = msg.match[1].trim() 68 | newRole = msg.match[2].trim() 69 | 70 | unless name in ['', 'who', 'what', 'where', 'when', 'why'] 71 | users = robot.brain.usersForFuzzyName(name) 72 | if users.length is 1 73 | user = users[0] 74 | user.roles = user.roles or [ ] 75 | 76 | if newRole not in user.roles 77 | msg.send "I know." 78 | else 79 | user.roles = (role for role in user.roles when role isnt newRole) 80 | msg.send "Ok, #{name} is no longer #{newRole}." 81 | else if users.length > 1 82 | msg.send getAmbiguousUserText users 83 | else 84 | msg.send "I don't know anything about #{name}." 85 | 86 | -------------------------------------------------------------------------------- /scripts/translate.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Allows Hubot to know many languages. 3 | # 4 | # Commands: 5 | # hubot translate me - Searches for a translation for the and then prints that bad boy out. 6 | # hubot translate me from into - Translates from into . Both and are optional 7 | 8 | languages = 9 | "af": "Afrikaans", 10 | "sq": "Albanian", 11 | "ar": "Arabic", 12 | "az": "Azerbaijani", 13 | "eu": "Basque", 14 | "bn": "Bengali", 15 | "be": "Belarusian", 16 | "bg": "Bulgarian", 17 | "ca": "Catalan", 18 | "zh-CN": "Simplified Chinese", 19 | "zh-TW": "Traditional Chinese", 20 | "hr": "Croatian", 21 | "cs": "Czech", 22 | "da": "Danish", 23 | "nl": "Dutch", 24 | "en": "English", 25 | "eo": "Esperanto", 26 | "et": "Estonian", 27 | "tl": "Filipino", 28 | "fi": "Finnish", 29 | "fr": "French", 30 | "gl": "Galician", 31 | "ka": "Georgian", 32 | "de": "German", 33 | "el": "Greek", 34 | "gu": "Gujarati", 35 | "ht": "Haitian Creole", 36 | "iw": "Hebrew", 37 | "hi": "Hindi", 38 | "hu": "Hungarian", 39 | "is": "Icelandic", 40 | "id": "Indonesian", 41 | "ga": "Irish", 42 | "it": "Italian", 43 | "ja": "Japanese", 44 | "kn": "Kannada", 45 | "ko": "Korean", 46 | "la": "Latin", 47 | "lv": "Latvian", 48 | "lt": "Lithuanian", 49 | "mk": "Macedonian", 50 | "ms": "Malay", 51 | "mt": "Maltese", 52 | "no": "Norwegian", 53 | "fa": "Persian", 54 | "pl": "Polish", 55 | "pt": "Portuguese", 56 | "ro": "Romanian", 57 | "ru": "Russian", 58 | "sr": "Serbian", 59 | "sk": "Slovak", 60 | "sl": "Slovenian", 61 | "es": "Spanish", 62 | "sw": "Swahili", 63 | "sv": "Swedish", 64 | "ta": "Tamil", 65 | "te": "Telugu", 66 | "th": "Thai", 67 | "tr": "Turkish", 68 | "uk": "Ukrainian", 69 | "ur": "Urdu", 70 | "vi": "Vietnamese", 71 | "cy": "Welsh", 72 | "yi": "Yiddish" 73 | 74 | getCode = (language,languages) -> 75 | for code, lang of languages 76 | return code if lang.toLowerCase() is language.toLowerCase() 77 | 78 | module.exports = (robot) -> 79 | language_choices = (language for _, language of languages).sort().join('|') 80 | pattern = new RegExp('translate(?: me)?' + 81 | "(?: from (#{language_choices}))?" + 82 | "(?: (?:in)?to (#{language_choices}))?" + 83 | '(.*)', 'i') 84 | robot.respond pattern, (msg) -> 85 | term = "\"#{msg.match[3]}\"" 86 | origin = if msg.match[1] isnt undefined then getCode(msg.match[1], languages) else 'auto' 87 | target = if msg.match[2] isnt undefined then getCode(msg.match[2], languages) else 'en' 88 | 89 | msg.http("https://translate.google.com/translate_a/t") 90 | .query({ 91 | client: 't' 92 | hl: 'en' 93 | multires: 1 94 | sc: 1 95 | sl: origin 96 | ssel: 0 97 | tl: target 98 | tsel: 0 99 | uptl: "en" 100 | text: term 101 | }) 102 | .header('User-Agent', 'Mozilla/5.0') 103 | .get() (err, res, body) -> 104 | data = body 105 | if data.length > 4 and data[0] == '[' 106 | parsed = eval(data) 107 | language =languages[parsed[2]] 108 | parsed = parsed[0] and parsed[0][0] and parsed[0][0][0] 109 | if parsed 110 | if msg.match[2] is undefined 111 | msg.send "#{term} is #{language} for #{parsed}" 112 | else 113 | msg.send "The #{language} #{term} translates as #{parsed} in #{languages[target]}" 114 | 115 | -------------------------------------------------------------------------------- /scripts/ghost-github.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Github Webhook Responding for TryGhost 3 | # 4 | # Dependencies: 5 | # git-at-me (npm install git-at-me --save) 6 | # 7 | # Commands: 8 | # None 9 | 10 | github = require('git-at-me') 11 | 12 | devRoom = '#ghost' 13 | 14 | module.exports = (robot) -> 15 | 16 | return unless robot.router 17 | 18 | githubEvents = github 19 | # TESTING: Must be generated with github.wizard() 20 | #token: require('../github-token') 21 | # Repo information for creating a webhook; not needed for Ghost since it will be created by Hannah 22 | #user: 'jgable' 23 | #repo: 'Slimer' 24 | #events: ['push', 'pull_request', 'issues', 'issue_comment'] 25 | # TESTING: Using ngrok to generate this while testing 26 | url: "http://#{process.env.HUBOT_HOSTNAME}/github/events" 27 | skipHook: true 28 | server: robot.router 29 | 30 | githubEvents.on 'push', (pushData) -> 31 | author = pushData.pusher.name 32 | commits = pushData.commits.length 33 | branch = pushData.ref.replace('refs/heads/', '') 34 | repo = "#{pushData.repository.owner.name}/#{pushData.repository.name}" 35 | compareUrl = pushData.compare 36 | 37 | # Only output commits to master 38 | return unless branch == 'master' 39 | 40 | # Format: ErisDS pushed 2 commits to master on TryGhost/Ghost - https://github.com/jgable/git-at-me/compare/b29e18b9b2db...3722cee576e1 41 | robot.messageRoom devRoom, "#{author} pushed #{commits} commits to #{branch} on #{repo} - #{compareUrl}" 42 | 43 | 44 | githubEvents.on 'pull_request', (prData) -> 45 | { action, number, pull_request, sender, repository } = prData 46 | { html_url, title, user } = pull_request 47 | 48 | action = "merged" if pull_request.merged 49 | 50 | action = "updated" if action == "synchronize" 51 | 52 | # Format: ErisDS merged PR #102 on TryGhost/Ghost - Fix bug on image uploader, fixes #92 - by JohnONolan - http://github.com/TryGhost/Ghost/Pulls/102 53 | msg = "#{sender.login} #{action} PR ##{number} on #{repository.full_name} - #{title} - #{html_url}" 54 | 55 | robot.messageRoom devRoom, msg 56 | 57 | 58 | githubEvents.on 'issues', (issueData) -> 59 | { action, issue, repository, sender } = issueData 60 | 61 | return if action in ['labeled', 'unlabeled'] 62 | 63 | if action == 'assigned' 64 | msg = "#{sender.login} #{action} Issue ##{issue.number} on #{repository.full_name} - #{issue.title} - #{issue.html_url} to #{issueData.assignee.login}" 65 | else if action == 'unassigned' 66 | msg = "#{sender.login} #{action} Issue ##{issue.number} on #{repository.full_name} - #{issue.title} - #{issue.html_url} from #{issueData.assignee.login}" 67 | else 68 | # Format: gotdibbs created issue #1035 on TryGhost/Ghost - File uploads CSRF protection 69 | msg = "#{sender.login} #{action} Issue ##{issue.number} on #{repository.full_name} - #{issue.title} - #{issue.html_url}" 70 | 71 | robot.messageRoom devRoom, msg 72 | 73 | githubEvents.on 'issue_comment', (commentData) -> 74 | # Not reporting on comments right now 75 | return 76 | 77 | { action, issue, comment, repository, sender } = commentData 78 | 79 | return unless action == 'created' 80 | 81 | # Format: jgable commented on issue #3 on TryGhost/Ghost - File uploads CSRF protection 82 | msg = "#{sender.login} commented on Issue ##{issue.number} on #{repository.full_name} - #{issue.title} - #{comment.html_url}" 83 | 84 | robot.messageRoom devRoom, msg 85 | 86 | 87 | githubEvents.on 'error', (err) -> 88 | console.log "Error in githubEvents: #{err.message}" 89 | -------------------------------------------------------------------------------- /scripts/auth.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Auth allows you to assign roles to users which can be used by other scripts 3 | # to restrict access to Hubot commands 4 | # 5 | # Dependencies: 6 | # None 7 | # 8 | # Configuration: 9 | # HUBOT_AUTH_ADMIN - A comma separate list of user IDs 10 | # 11 | # Commands: 12 | # hubot has role - Assigns a role to a user 13 | # hubot doesn't have role - Removes a role from a user 14 | # hubot what role does have - Find out what roles are assigned to a specific user 15 | # hubot who has admin role - Find out who's an admin and can assign roles 16 | # 17 | # Notes: 18 | # * Call the method: robot.auth.hasRole(msg.envelope.user,'') 19 | # * returns bool true or false 20 | # 21 | # * the 'admin' role can only be assigned through the environment variable 22 | # * roles are all transformed to lower case 23 | # 24 | # * The script assumes that user IDs will be unique on the service end as to 25 | # correctly identify a user. Names were insecure as a user could impersonate 26 | # a user 27 | # 28 | # Author: 29 | # alexwilliamsca, tombell 30 | 31 | module.exports = (robot) -> 32 | 33 | unless process.env.HUBOT_AUTH_ADMIN? 34 | robot.logger.warning 'The HUBOT_AUTH_ADMIN environment variable not set' 35 | 36 | if process.env.HUBOT_AUTH_ADMIN? 37 | admins = process.env.HUBOT_AUTH_ADMIN.split ',' 38 | else 39 | admins = [] 40 | 41 | class Auth 42 | hasRole: (user, roles) -> 43 | user = robot.brain.userForId(user.id) 44 | if user? and user.roles? 45 | roles = [roles] if typeof roles is 'string' 46 | for role in roles 47 | return true if role in user.roles 48 | return false 49 | 50 | robot.auth = new Auth 51 | 52 | robot.respond /@?(.+) (has) (["'\w: -_]+) (role)/i, (msg) -> 53 | name = msg.match[1].trim() 54 | newRole = msg.match[3].trim().toLowerCase() 55 | 56 | unless name.toLowerCase() in ['', 'who', 'what', 'where', 'when', 'why'] 57 | user = robot.brain.userForName(name) 58 | return msg.reply "#{name} does not exist" unless user? 59 | user.roles or= [] 60 | 61 | if newRole in user.roles 62 | msg.reply "#{name} already has the '#{newRole}' role." 63 | else 64 | if newRole is 'admin' 65 | msg.reply "Sorry, the 'admin' role can only be defined in the HUBOT_AUTH_ADMIN env variable." 66 | else 67 | myRoles = msg.message.user.roles or [] 68 | if msg.message.user.id.toString() in admins 69 | user.roles.push(newRole) 70 | msg.reply "Ok, #{name} has the '#{newRole}' role." 71 | 72 | robot.respond /@?(.+) (doesn't have|does not have) (["'\w: -_]+) (role)/i, (msg) -> 73 | name = msg.match[1].trim() 74 | newRole = msg.match[3].trim().toLowerCase() 75 | 76 | unless name.toLowerCase() in ['', 'who', 'what', 'where', 'when', 'why'] 77 | user = robot.brain.userForName(name) 78 | return msg.reply "#{name} does not exist" unless user? 79 | user.roles or= [] 80 | 81 | if newRole is 'admin' 82 | msg.reply "Sorry, the 'admin' role can only be removed from the HUBOT_AUTH_ADMIN env variable." 83 | else 84 | myRoles = msg.message.user.roles or [] 85 | if msg.message.user.id.toString() in admins 86 | user.roles = (role for role in user.roles when role isnt newRole) 87 | msg.reply "Ok, #{name} doesn't have the '#{newRole}' role." 88 | 89 | robot.respond /(what role does|what roles does) @?(.+) (have)\?*$/i, (msg) -> 90 | name = msg.match[2].trim() 91 | user = robot.brain.userForName(name) 92 | return msg.reply "#{name} does not exist" unless user? 93 | user.roles or= [] 94 | 95 | if user.id.toString() in admins 96 | isAdmin = ' and is also an admin' 97 | else 98 | isAdmin = '' 99 | msg.reply "#{name} has the following roles: #{user.roles.join(', ')}#{isAdmin}." 100 | 101 | robot.respond /who has admin role\?*$/i, (msg) -> 102 | adminNames = [] 103 | for admin in admins 104 | user = robot.brain.userForId(admin) 105 | adminNames.push user.name if user? 106 | 107 | msg.reply "The following people have the 'admin' role: #{adminNames.join(', ')}" 108 | -------------------------------------------------------------------------------- /scripts/ghost-issues.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Github utility commands for issues 3 | # 4 | # Commands: 5 | # [User/][Repo]#1234 - Reply with link to issue 6 | # 7 | # Notes: 8 | # User and Repo are optional. 9 | # 10 | # Multiple issue numbers per line are supported. 11 | # 12 | # Slimer will attempt to resolve repositories by prepending "Ghost-" to repositories 13 | # that are not found on the first attempt. 14 | # 15 | # Slimer will attempt to resolve repositories by removing "Ghost-" from repositories 16 | # that are not found on the first attempt. 17 | # 18 | # Examples: 19 | # #99 => TryGhost/Ghost#99 20 | # 21 | # Casper#99 => TryGhost/Casper#99 22 | # 23 | # Ghost-Casper#99 => TryGhost/Casper#99 24 | # 25 | # UI#99 => TryGhost/Ghost-UI#99 26 | # 27 | # visionmedia/express#10 => visionmedia/express#10 28 | 29 | request = require 'request' 30 | 31 | urls = 32 | repo: (user, repo) -> "https://api.github.com/repos/#{user}/#{repo}" 33 | issue: (user, repo, number) -> @repo(user, repo) + "/issues/#{number}" 34 | 35 | headers = { 'User-Agent': 'Ghost Slimer' } 36 | 37 | module.exports = (robot) -> 38 | robot.hear /[a-zA-Z0-9-]*\/*[a-zA-Z0-9-]*#[0-9]+/g, (response) -> 39 | issues = [] 40 | response.match.forEach (match) -> 41 | [location, issueNumber] = match.split("#") 42 | 43 | location = location.split("/") 44 | 45 | if location.length == 2 46 | [user, repo] = location 47 | else if location.length == 1 48 | repo = location[0] || "Ghost" 49 | user = "TryGhost" 50 | 51 | issueUrl = urls.issue user, repo, issueNumber 52 | searchAttempts = [] 53 | foundIssue = false 54 | 55 | searchAttempts.push { url: issueUrl, headers: headers } 56 | 57 | # if we're on a TryGhost repo, set up a fallback search by either 58 | # adding or removing "Ghost-" from the repo name 59 | if repo && user.toLowerCase() == "tryghost" 60 | if /^ghost-/i.test(repo) 61 | issueUrl = urls.issue user, repo.split("-")[1], issueNumber 62 | searchAttempts.push { url: issueUrl, headers: headers } 63 | else 64 | issueUrl = urls.issue user, "Ghost-" + repo, issueNumber 65 | searchAttempts.push { url: issueUrl, headers: headers } 66 | 67 | options = searchAttempts.pop() 68 | request options, (err, reqResp, body) -> 69 | return if err 70 | 71 | try 72 | issueInfo = JSON.parse body 73 | title = if issueInfo.title.length > 100 then issueInfo.title.slice(0, 97) + '...' else issueInfo.title 74 | issues.push "[##{issueNumber}] #{title} #{issueInfo.html_url}" 75 | foundIssue = true 76 | catch e 77 | console.log "Failed to get issue info:", e.message 78 | console.log "Request:", options.url, body 79 | 80 | if searchAttempts.length && !foundIssue 81 | options = searchAttempts.pop() 82 | request options, (err, reqResp, body) -> 83 | return if err 84 | 85 | try 86 | issueInfo = JSON.parse body 87 | title = if issueInfo.title.length > 100 then issueInfo.title.slice(0, 97) + '...' else issueInfo.title 88 | issues.push "[##{issueNumber}] #{title} #{issueInfo.html_url}" 89 | foundIssue = true 90 | catch e 91 | console.log "Failed to get issue info:", e.message 92 | console.log "Request:", options.url, body 93 | issues.push "no info for ##{issueNumber}" 94 | 95 | if issues.length == response.match.length 96 | response.send issues.join ", " 97 | else 98 | issues.push "no info for ##{issueNumber}" 99 | 100 | if issues.length == response.match.length 101 | response.send issues.join ", " 102 | 103 | if issues.length == response.match.length && foundIssue 104 | response.send issues.join ", " 105 | -------------------------------------------------------------------------------- /scripts/logger.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Logs chat to Redis and displays it over HTTP 3 | # 4 | # Dependencies: 5 | # "redis": ">=0.7.2" 6 | # "moment": ">=1.7.0" 7 | # 8 | # Configuration: 9 | # LOG_REDIS_URL: URL to Redis backend to use for logging (uses REDISTOGO_URL 10 | # if unset, and localhost:6379 if that is unset. 11 | # LOG_HTTP_USER: username for viewing logs over HTTP (default 'logs' if unset) 12 | # LOG_HTTP_PASS: password for viewing logs over HTTP (default 'changeme' if unset) 13 | # LOG_HTTP_PORT: port for our logging Connect server to listen on (default 8081) 14 | # LOG_STEALTH: If set, bot will not announce that it is logging in chat 15 | # LOG_MESSAGES_ONLY: If set, bot will not log room enter or leave events 16 | # 17 | # Commands: 18 | # hubot logs - show the url where you can view the logs for today 19 | # hubot send me today's logs - messages you the logs for today 20 | # hubot what did I miss - messages you logs for the past 10 minutes 21 | # hubot what did I miss in the last x seconds/minutes/hours - messages you logs for the past x 22 | # hubot start logging - start logging messages from now on 23 | # 24 | # Notes: 25 | # This script by default starts a Connect server on 8081 with the following routes: 26 | # / 27 | # Form that takes a room ID and two UNIX timestamps to show the logs between. 28 | # Action is a GET with room, start, and end parameters to /logs/view. 29 | # 30 | # /logs/view?room=room_name&start=1234567890&end=1456789023&presence=true 31 | # Shows logs between UNIX timestamps and for , 32 | # and includes presence changes (joins, parts) if 33 | # 34 | # /logs/:room 35 | # Lists all logs in the database for 36 | # 37 | # /logs/:room/YYYMMDD?presence=true 38 | # Lists all logs in for the date YYYYMMDD, and includes joins and parts 39 | # if 40 | # 41 | # Feel free to edit the HTML views at the bottom of this module if you want to make the views 42 | # prettier or more functional. 43 | # 44 | # I have only thoroughly tested this script with the xmpp and shell adapters. It doesn't use 45 | # anything that necessarily wouldn't work with other adapters, but it's possible some adapters 46 | # may have issues sending large amounts of logs in a single message. 47 | # 48 | # Author: 49 | # jenrzzz 50 | 51 | 52 | Redis = require "redis" 53 | Url = require "url" 54 | Util = require "util" 55 | moment = require "moment" 56 | hubot = require "hubot" 57 | 58 | # Convenience class to represent a log entry 59 | class Entry 60 | constructor: (@from, @timestamp, @type='text', @message='') -> 61 | 62 | redis_server = Url.parse process.env.LOG_REDIS_URL || process.env.REDISTOGO_URL || 'redis://localhost:6379' 63 | 64 | module.exports = (robot) -> 65 | robot.logging ||= {} # stores some state info that should not persist between application runs 66 | robot.brain.data.logging ||= {} 67 | robot.logger.debug "Starting chat logger." 68 | 69 | # Setup our own redis connection 70 | client = Redis.createClient redis_server.port, redis_server.hostname 71 | if redis_server.auth 72 | client.auth redis_server.auth.split(":")[1] 73 | client.on 'error', (err) -> 74 | robot.logger.error "Chat logger was unable to connect to a Redis backend at #{redis_server.hostname}:#{redis_server.port}" 75 | robot.logger.error err 76 | client.on 'connect', -> 77 | robot.logger.debug "Chat logger successfully connected to Redis." 78 | 79 | # Add a listener that matches all messages and calls log_message with redis and robot instances and a Response object 80 | robot.listeners.push new hubot.Listener(robot, ((msg) -> return true), (res) -> log_message(client, robot, res)) 81 | 82 | # Override send methods in the Response prototype so that we can log Hubot's replies 83 | # This is kind of evil, but there doesn't appear to be a better way 84 | log_response = (room, strings...) -> 85 | return unless robot.brain.data.logging[room]?.enabled 86 | for string in strings 87 | log_entry client, (new Entry(robot.name, Date.now(), 'text', string)), room 88 | 89 | response_orig = 90 | send: robot.Response.prototype.send 91 | reply: robot.Response.prototype.reply 92 | 93 | robot.Response.prototype.send = (strings...) -> 94 | log_response @message.user.room, strings... 95 | response_orig.send.call @, strings... 96 | 97 | robot.Response.prototype.reply = (strings...) -> 98 | log_response @message.user.room, strings... 99 | response_orig.reply.call @, strings... 100 | 101 | #################### 102 | ## HTTP interface ## 103 | #################### 104 | 105 | if robot.router 106 | app = robot.router 107 | app.get '/', (req, res) -> 108 | res.statusCode = 200 109 | res.setHeader 'Content-Type', 'text/html' 110 | res.end views.index 111 | 112 | app.get '/logs/view', (req, res) -> 113 | res.statusCode = 200 114 | res.setHeader 'Content-Type', 'text/html' 115 | if not (req.query.start || req.query.end) 116 | res.end 'No start or end date provided' 117 | m_start = parseInt(req.query.start) 118 | m_end = parseInt(req.query.end) 119 | if isNaN(m_start) or isNaN(m_end) 120 | res.end "Invalid range" 121 | return 122 | m_start = moment.unix m_start 123 | m_end = moment.unix m_end 124 | room = req.query.room || 'general' 125 | presence = !!req.query.presence 126 | get_logs_for_range client, m_start, m_end, room, (replies) -> 127 | res.write views.log_view.head 128 | res.write format_logs_for_html(replies, presence).join("\r\n") 129 | res.end views.log_view.tail 130 | 131 | app.get '/logs/:room', (req, res) -> 132 | res.statusCode = 200 133 | res.setHeader 'Content-Type', 'text/html' 134 | res.write views.log_view.head 135 | res.write "

Logs for #{req.params.room}

\r\n" 136 | res.write "
    \r\n" 137 | 138 | # This is a bit of a hack... KEYS takes O(n) time 139 | # and shouldn't be used for this, but it's not worth 140 | # creating a set just so that we can list all logs 141 | # for a room. 142 | client.keys "logs:#{req.params.room}:*", (err, replies) -> 143 | days = [] 144 | for key in replies 145 | key = key.slice key.lastIndexOf(':')+1, key.length 146 | days.push moment(key, "YYYYMMDD") 147 | days.sort (a, b) -> 148 | return b.diff(a) 149 | days.forEach (date) -> 150 | res.write "
  • #{date.format('dddd, MMMM Do YYYY')}
  • \r\n" 151 | res.write "
" 152 | res.end views.log_view.tail 153 | 154 | app.get '/logs/:room/:id', (req, res) -> 155 | res.statusCode = 200 156 | res.setHeader 'Content-Type', 'text/html' 157 | presence = !!req.query.presence 158 | id = parseInt req.params.id 159 | if isNaN(id) 160 | res.end "Bad log ID" 161 | return 162 | get_log client, req.params.room, id, (logs) -> 163 | res.write views.log_view.head 164 | res.write format_logs_for_html(logs, presence).join("\r\n") 165 | res.end views.log_view.tail 166 | 167 | #################### 168 | ## Chat interface ## 169 | #################### 170 | 171 | # When we join a room, wait for some activity and notify that we're logging chat 172 | # unless we're in stealth mode 173 | robot.hear /.*/, (msg) -> 174 | room = msg.message.user.room 175 | robot.logging[room] ||= {} 176 | robot.brain.data.logging[room] ||= {} 177 | if msg.match[0].match(///(#{robot.name} )?(start|stop) logging*///) or process.env.LOG_STEALTH 178 | robot.logging[room].notified = true 179 | return 180 | if robot.brain.data.logging[room].enabled and not robot.logging[room].notified 181 | msg.send "I'm logging messages in #{room} at " + 182 | "http://#{process.env.HUBOT_HOSTNAME}/" + 183 | "logs/#{encodeURIComponent(room)}/#{date_id()}\n" 184 | robot.logging[room].notified = true 185 | 186 | # Give current logs url 187 | robot.respond /logs$/i, (msg) -> 188 | msg.send "Logs for this room can be found at: http://#{process.env.HUBOT_HOSTNAME}/logs/#{encodeURIComponent(msg.message.user.room)}/#{date_id()}" 189 | 190 | # Enable logging 191 | robot.respond /start logging( messages)?$/i, (msg) -> 192 | enable_logging robot, client, msg 193 | 194 | # PM logs to people who request them 195 | robot.respond /(message|send) me (all|the|today'?s) logs?$/i, (msg) -> 196 | get_logs_for_day client, new Date(), msg.message.user.room, (logs) -> 197 | if logs.length == 0 198 | msg.reply "I don't have any logs saved for today." 199 | return 200 | 201 | logs_formatted = format_logs_for_chat(logs) 202 | robot.send direct_user(msg.message.user.id, msg.message.user.room), logs_formatted.join("\n") 203 | 204 | robot.respond /what did I miss\??$/i, (msg) -> 205 | now = moment() 206 | before = moment().subtract('m', 10) 207 | get_logs_for_range client, before, now, msg.message.user.room, (logs) -> 208 | logs_formatted = format_logs_for_chat(logs) 209 | robot.send direct_user(msg.message.user.id, msg.message.user.room), logs_formatted.join("\n") 210 | 211 | robot.respond /what did I miss in the [pl]ast ([0-9]+) (seconds?|minutes?|hours?)\??/i, (msg) -> 212 | num = parseInt(msg.match[1]) 213 | if isNaN(num) 214 | msg.reply "I'm not sure how much time #{msg.match[1]} #{msg.match[2]} refers to." 215 | return 216 | now = moment() 217 | start = moment().subtract(msg.match[2][0], num) 218 | 219 | if now.diff(start, 'days', true) > 1 220 | robot.send direct_user(msg.message.user.id, msg.message.user.room), 221 | "I can only tell you activity for the last 24 hours in a message." 222 | start = now.sod().subtract('d', 1) 223 | 224 | get_logs_for_range client, start, moment(), msg.message.user.room, (logs) -> 225 | logs_formatted = format_logs_for_chat(logs) 226 | robot.send direct_user(msg.message.user.id, msg.message.user.room), logs_formatted.join("\n") 227 | 228 | 229 | #################### 230 | ## Helpers ## 231 | #################### 232 | 233 | # Converts date into a string formatted YYYYMMDD 234 | date_id = (date=moment())-> 235 | date = moment(date) if date instanceof Date 236 | return date.format("YYYYMMDD") 237 | 238 | # Returns an array of date IDs for the range between 239 | # start and end (inclusive) 240 | enumerate_keys_for_date_range = (start, end) -> 241 | ids = [] 242 | start = moment(start) if start instanceof Date 243 | end = moment(end) if end instanceof Date 244 | start_i = moment(start) 245 | while end.diff(start_i, 'days', true) >= 0 246 | ids.push date_id(start_i) 247 | start_i.add 'days', 1 248 | return ids 249 | 250 | # Returns an array of pretty-printed log messages for 251 | # Params: 252 | # logs - an array of log objects 253 | format_logs_for_chat = (logs) -> 254 | formatted = [] 255 | logs.forEach (item) -> 256 | entry = JSON.parse item 257 | timestamp = moment(entry.timestamp) 258 | str = timestamp.format("MMM DD YYYY HH:mm:ss") 259 | 260 | if entry.type is 'join' 261 | str += " #{entry.from} joined" 262 | else if entry.type is 'part' 263 | str += " #{entry.from} left" 264 | else 265 | str += " <#{entry.from}> #{entry.message}" 266 | formatted.push str 267 | return formatted 268 | 269 | # Returns a string that is half heartedly html encoded for display 270 | htmlEntities = (str) -> 271 | String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') 272 | 273 | # Returns an array of lines representing a table for 274 | # Params: 275 | # logs - an array of log objects 276 | format_logs_for_html = (logs, presence=true) -> 277 | lines = [] 278 | last_entry = null 279 | for log in logs 280 | l = JSON.parse log 281 | 282 | # Don't print a bunch of join or part messages for the same person. Hubot sometimes 283 | # sees keepalives from Jabber gateways as multiple joins 284 | continue if l.type != 'text' and l.from == last_entry?.from and l.type == last_entry?.type 285 | continue if not presence and l.type != 'text' 286 | l.date = moment(l.timestamp) 287 | 288 | # If the date changed 289 | if not (l.date.date() == last_entry?.date?.date() and l.date.month() == last_entry?.date?.month()) 290 | lines.push """
291 |
 
292 |
Date changed to #{l.date.format("D MMMM YYYY")}
293 |
294 | """ 295 | last_entry = l 296 | l.time = moment(l.timestamp).format("h:mm:ss a") 297 | l.datetime = moment(l.timestamp).format("MM-DD-YYYY h:mm:ss a") 298 | l.timeid = moment(l.timestamp).format("ahmmss") 299 | switch l.type 300 | when 'join' 301 | lines.push """
302 | 303 | 304 | 305 |
306 |

#{l.from} joined

307 |
308 |
309 | """ 310 | when 'part' 311 | lines.push """
312 | 313 | 314 | 315 |
316 |

#{l.from} left

317 |
318 |
319 | """ 320 | when 'text' 321 | lines.push """
322 | 323 | 324 | 325 |
326 |

<#{l.from}> #{htmlEntities(l.message)}

327 |
328 |
329 | """ 330 | return lines 331 | 332 | # Returns a User object to send a direct message to 333 | # Params: 334 | # id - the user's adapter ID 335 | # room - string representing the room the user is in (optional for some adapters) 336 | direct_user = (id, room=null) -> 337 | u = 338 | type: 'direct' 339 | id: id 340 | room: room 341 | 342 | # Calls back an array of JSON log objects representing the log 343 | # for the given ID 344 | # Params: 345 | # redis - a Redis client object 346 | # room - the room to look up logs for 347 | # id - the date to look up logs for 348 | # callback - a function that takes an array 349 | get_log = (redis, room, id, callback) -> 350 | log_key = "logs:#{room}:#{id}" 351 | return [] if not redis.exists log_key 352 | redis.lrange [log_key, 0, -1], (err, replies) -> 353 | callback(replies) 354 | 355 | # Calls back an array of JSON log objects representing the log 356 | # for every date ID in 357 | # Params: 358 | # redis - a Redis client object 359 | # room - the room to look up logs for 360 | # ids - an array of YYYYMMDD date id strings to pull logs for 361 | # callback - a function taking an array of log objects 362 | get_logs_for_array = (redis, room, ids, callback) -> 363 | m = redis.multi() 364 | for id in ids 365 | m.lrange("logs:#{room}:#{id}", 0, -1) 366 | m.exec (err, reply) -> 367 | ret = [] 368 | if reply[0] instanceof Array 369 | for r in reply 370 | ret = ret.concat r 371 | else 372 | ret = reply 373 | callback(ret) 374 | 375 | # Calls back an array of JSON log objects representing the log 376 | # for 377 | # Params: 378 | # redis - a Redis client object 379 | # date - Date or Moment object representing the date to look up 380 | # room - the room to look up 381 | # callback - function to pass an array of log objects for date to 382 | get_logs_for_day = (redis, date, room, callback) -> 383 | get_log redis, room, date_id(date), (reply) -> 384 | callback(reply) 385 | 386 | # Calls back an array of JSON log objects representing the log 387 | # between and 388 | # Params: 389 | # redis - a Redis client object 390 | # start - Date or Moment object representing the start of the range 391 | # end - Date or Moment object representing the end of the range (inclusive) 392 | # room - the room to look up logs for 393 | # callback - a function taking an array as an argument 394 | get_logs_for_range = (redis, start, end, room, callback) -> 395 | get_logs_for_array redis, room, enumerate_keys_for_date_range(start, end), (logs) -> 396 | # TODO: use a fuzzy binary search to find the start and end indices 397 | # of the log entries we want instead of iterating through the whole thing 398 | slice = [] 399 | for log in logs 400 | e = JSON.parse log 401 | slice.push log if e.timestamp >= start.valueOf() && e.timestamp <= end.valueOf() 402 | callback(slice) 403 | 404 | # Enables logging for the room that sent response 405 | # Params: 406 | # robot - a Robot instance 407 | # redis - a Redis client object 408 | # response - a Response that can be replied to 409 | enable_logging = (robot, redis, response) -> 410 | robot.brain.data.logging[response.message.user.room] ||= {} 411 | if robot.brain.data.logging[response.message.user.room].enabled 412 | response.reply "Logging is already enabled." 413 | return 414 | robot.brain.data.logging[response.message.user.room].enabled = true 415 | robot.brain.data.logging[response.message.user.room].pause = null 416 | 417 | room = response.message.user.room || response.message.user.name || "unknown" 418 | 419 | log_entry(redis, new Entry(robot.name, Date.now(), 'text', 420 | "#{response.message.user.name || response.message.user.id} restarted logging."), 421 | room) 422 | 423 | response.reply "I will log messages in #{room} at " + 424 | "http://#{process.env.HUBOT_HOSTNAME}/" + 425 | "logs/#{encodeURIComponent(room)}/#{date_id()} from now on.\n" + 426 | "Say `#{robot.name} stop logging forever' to disable logging indefinitely." 427 | robot.brain.save() 428 | 429 | # Disables logging for the room that sent response 430 | # Params: 431 | # robot - a Robot instance 432 | # redis - a Redis client object 433 | # response - a Response that can be replied to 434 | # end - a Moment representing the time at which to start logging again, or 435 | # - a number representing the number of milliseconds until logging should be resumed, or 436 | # - 0 or undefined to disable logging indefinitely 437 | disable_logging = (robot, redis, response, end=0) -> 438 | room = response.message.user.room 439 | robot.brain.data.logging[room] ||= {} 440 | 441 | # If logging was already disabled 442 | if robot.brain.data.logging[room].enabled == false 443 | if robot.brain.data.logging[room].pause 444 | pause = robot.brain.data.logging[room].pause 445 | if pause.time and pause.end and end and end != 0 446 | response.reply "Logging was already disabled #{pause.time.fromNow()} by " + 447 | "#{pause.user} until #{pause.end.format()}." 448 | else 449 | robot.brain.data.logging[room].pause = null 450 | response.reply "Logging is currently disabled." 451 | else 452 | response.reply "Logging is currently disabled." 453 | return 454 | 455 | # Otherwise, disable it 456 | robot.brain.data.logging[room].enabled = false 457 | if end != 0 458 | if not end instanceof moment 459 | if end instanceof Date 460 | end = moment(end) 461 | else 462 | end = moment().add('seconds', parseInt(end)) 463 | robot.brain.data.logging[room].pause = 464 | time: moment() 465 | user: response.message.user.name || response.message.user.id || 'unknown' 466 | end: end 467 | log_entry(redis, new Entry(robot.name, Date.now(), 'text', 468 | "#{response.message.user.name || response.message.user.id} disabled logging" + 469 | " until #{end.format()}."), room) 470 | 471 | # Re-enable logging after the set amount of time 472 | setTimeout (-> enable_logging(robot, redis, response) if not robot.brain.data.logging[room].enabled), 473 | end.diff(moment()) 474 | response.reply "OK, I'll stop logging until #{end.format()}." 475 | robot.brain.save() 476 | return 477 | log_entry(redis, new Entry(robot.name, Date.now(), 'text', 478 | "#{response.message.user.name || response.message.user.id} disabled logging indefinitely."), 479 | room) 480 | 481 | robot.brain.save() 482 | response.reply "OK, I'll stop logging from now on." 483 | 484 | # Logs an Entry object 485 | # Params: 486 | # redis - a Redis client instance 487 | # entry - an Entry object to log 488 | # room - the room to log it in 489 | log_entry = (redis, entry, room='general') -> 490 | if not entry.type && entry.timestamp 491 | throw new Error("Argument #{entry} to log_entry is not an entry object") 492 | entry = JSON.stringify entry 493 | redis.rpush("logs:#{room}:#{date_id()}", entry) 494 | 495 | # Listener callback to log message in redis 496 | # Params: 497 | # redis - a Redis client instance 498 | # response - a Response object emitted from a Listener 499 | log_message = (redis, robot, response) -> 500 | return if not robot.brain.data.logging[response.message.user.room]?.enabled 501 | if response.message instanceof hubot.TextMessage 502 | type = 'text' 503 | else if response.message instanceof hubot.EnterMessage 504 | type = 'join' 505 | else if response.message instanceof hubot.LeaveMessage 506 | type = 'part' 507 | return if process.env.LOG_MESSAGES_ONLY && type != 'text' 508 | 509 | userName = response.message.user?.name || response.message.user?['id'] 510 | entry = JSON.stringify(new Entry(userName, Date.now(), type, response.message.text)) 511 | room = response.message.user.room || 'general' 512 | redis.rpush("logs:#{room}:#{date_id()}", entry) 513 | 514 | 515 | #################### 516 | ## Views ## 517 | #################### 518 | 519 | views = 520 | index: """ 521 | 522 | 523 | 524 | View logs 525 | 526 | 527 | 528 |
529 |
530 |
531 |
532 |
533 | Search for logs 534 | 535 |
536 | 537 | 538 | 539 | 540 | 541 |

542 | 543 |
544 |
545 |
546 |
547 |
548 | 549 | """ 550 | log_view: 551 | head: """ 552 | 553 | 554 | 555 | Viewing logs 556 | 557 | 566 | 567 | 568 |
569 | """ 570 | tail: "
" 571 | 572 | --------------------------------------------------------------------------------