├── .gitignore ├── CODEOWNERS ├── external-scripts.json ├── k8s ├── storage.yaml ├── service.yaml ├── ingress.yaml └── deployment.yaml ├── scripts ├── clark.coffee ├── zzz-filebrain.coffee ├── ascii.coffee ├── administration.coffee ├── interactive-messages.js ├── onboard.coffee ├── forge.coffee ├── office-hours.coffee └── karma.coffee ├── Dockerfile ├── Rakefile ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | k8s/secret.yaml 2 | 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @puppetlabs/community-maintainers @puppetlabs/open-source-stewards 2 | -------------------------------------------------------------------------------- /external-scripts.json: -------------------------------------------------------------------------------- 1 | [ 2 | "hubot-diagnostics", 3 | "hubot-help", 4 | "hubot-rules" 5 | ] 6 | -------------------------------------------------------------------------------- /k8s/storage.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: bunsen-brain 5 | spec: 6 | accessModes: 7 | - ReadWriteOnce 8 | resources: 9 | requests: 10 | storage: 1Gi 11 | -------------------------------------------------------------------------------- /k8s/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: bunsen-service 5 | spec: 6 | ports: 7 | - port: 80 8 | protocol: TCP 9 | targetPort: 8080 10 | selector: 11 | deployment: bunsen 12 | -------------------------------------------------------------------------------- /k8s/ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: extensions/v1beta1 2 | kind: Ingress 3 | metadata: 4 | annotations: 5 | kubernetes.io/tls-acme: "true" 6 | kubernetes.io/ingress.class: "nginx" 7 | name: bunsen-ingress 8 | spec: 9 | backend: 10 | serviceName: bunsen-service 11 | servicePort: 80 12 | rules: 13 | - host: prod.bunsen.k8s.puppet.net 14 | http: 15 | paths: 16 | - backend: 17 | serviceName: bunsen-service 18 | servicePort: 80 19 | path: / 20 | tls: 21 | - secretName: bunsen-prod-tls 22 | hosts: 23 | - prod.bunsen.k8s.puppet.net 24 | -------------------------------------------------------------------------------- /scripts/clark.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # None 3 | # 4 | # Dependencies: 5 | # "clark": "0.0.5" 6 | # 7 | # Configuration: 8 | # None 9 | # 10 | # Commands: 11 | # hubot clark - build sparklines out of data 12 | # 13 | # Author: 14 | # ajacksified 15 | # 16 | # Category: social 17 | 18 | clark = require 'clark' 19 | 20 | module.exports = (robot) -> 21 | robot.respond /(?:clark|sparkline) (.*)/i, (msg) -> 22 | data = msg.match[1].trim().split(' ') 23 | msg.send(clark(data)) 24 | 25 | robot.hear /sparkline (.*)/i, (msg) -> 26 | data = msg.match[1].trim().split(' ') 27 | msg.send(clark(data)) 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | MAINTAINER community@puppet.com 3 | 4 | # Install hubot dependencies 5 | RUN apk update\ 6 | && apk upgrade\ 7 | && apk add jq\ 8 | && npm install -g yo generator-hubot@next\ 9 | && rm -rf /var/cache/apk/* 10 | 11 | # Create hubot user with privileges 12 | RUN addgroup -g 501 hubot\ 13 | && adduser -D -h /hubot -u 501 -G hubot hubot 14 | ENV HOME /home/hubot 15 | WORKDIR $HOME 16 | RUN chown -R hubot:hubot . 17 | USER hubot 18 | 19 | RUN yo hubot\ 20 | --adapter=slack\ 21 | --owner="Puppet Community "\ 22 | --name="bunsen"\ 23 | --description="Freeing you to do what the robots can't."\ 24 | --defaults 25 | 26 | COPY scripts/* /home/hubot/scripts/ 27 | COPY external-scripts.json /home/hubot 28 | 29 | # Add any npm scripts to install here 30 | RUN npm install --save clark @slack/interactive-messages cron 31 | 32 | EXPOSE 80 33 | 34 | CMD ["bin/hubot", "--adapter", "slack"] 35 | -------------------------------------------------------------------------------- /scripts/zzz-filebrain.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # None 3 | # 4 | # Dependencies: 5 | # None 6 | # 7 | # Configuration: 8 | # HUBOT_BRAIN_DIR 9 | # 10 | # Commands: 11 | # None 12 | # 13 | # Author: 14 | # dustyburwell, stahnma 15 | # 16 | # Note: 17 | # Grabbed from https://raw.githubusercontent.com/github/hubot-scripts/master/src/scripts/file-brain.coffee before modification 18 | # Note: this file _must_ be loaded last in order to have the brain working with everything else. Hence, why it starts with the letter z. 19 | # 20 | # Category: workflow 21 | 22 | fs = require 'fs' 23 | path = require 'path' 24 | 25 | module.exports = (robot) -> 26 | brainPath = process.env.HUBOT_BRAIN_DIR 27 | unless fs.existsSync brainPath 28 | brainPath = process.cwd() 29 | 30 | brainPath = path.join brainPath, 'hubot-brain.json' 31 | 32 | try 33 | data = fs.readFileSync brainPath, 'utf-8' 34 | if data 35 | robot.brain.mergeData JSON.parse(data) 36 | catch error 37 | console.log('Unable to read file', error) unless error.code is 'ENOENT' 38 | 39 | robot.brain.on 'save', (data) -> 40 | fs.writeFileSync brainPath, JSON.stringify(data), 'utf-8' 41 | -------------------------------------------------------------------------------- /scripts/ascii.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # ASCII art 3 | # 4 | # Commands: 5 | # hubot ascii me - Show text in ascii art 6 | # hubot ascii me in - Specify a font 7 | # hubot ascii me fonts - Big list of fonts 8 | 9 | base_url = "http://artii.herokuapp.com" 10 | 11 | module.exports = (robot) -> 12 | 13 | robot.respond /ascii( me)?( in)? (.+)/i, (msg) -> 14 | font_present = msg.match[2] 15 | sentence = msg.match[3] 16 | if (font_present) 17 | font = sentence.substr(0, sentence.indexOf(" ")) 18 | sentence = sentence.substr(sentence.indexOf(" ") + 1) 19 | msg 20 | .http("#{base_url}/make") 21 | .query(text: sentence, font: font) 22 | .get() (err, res, body) => 23 | msg.send "``` #{body}```" 24 | else if (sentence == "fonts") 25 | msg 26 | .http("#{base_url}/fonts_list") 27 | .get() (err, res, body) -> 28 | msg.send "```#{body}```" 29 | else 30 | msg 31 | .http("#{base_url}/make") 32 | .query(text: sentence) 33 | .get() (err, res, body) => 34 | msg.send "``` #{body}```" 35 | -------------------------------------------------------------------------------- /scripts/administration.coffee: -------------------------------------------------------------------------------- 1 | # Description 2 | # Bot administration utilities. 3 | # 4 | # Commands: 5 | # hubot restart - just kills the bot process so that k8s can recycle a new one (admin only) 6 | # 7 | # URLS: 8 | # GET /status - Status check used for k8s readiness probe 9 | # 10 | # Author: 11 | # binford2k 12 | # 13 | # Category: workflow 14 | 15 | module.exports = (robot) -> 16 | 17 | robot.respond /restart/i, (msg) -> 18 | if(msg.message.user.slack.is_admin != true) 19 | msg.send('You shall not pass! Only Slack admins can use this command. :closed_lock_with_key:') 20 | else 21 | robot.brain.save(); 22 | robot.adapter.client.web.reactions.add('stopwatch', {channel: msg.message.room, timestamp: msg.message.id}) 23 | robot.logger.warning "Restart triggered by #{msg.message.user.slack.real_name} (@#{msg.message.user.slack.name})" 24 | 25 | # Wait a moment to let any pending messages, etc finish up 26 | setTimeout () -> 27 | #Exit the process (Relies on a process monitor to restart) 28 | process.exit 0 29 | , 2000 30 | 31 | 32 | robot.router.get '/status', (req, res) -> 33 | robot.logger.debug "Status check" 34 | res.send 'Ok' 35 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | task :default do 4 | system("rake -T") 5 | end 6 | 7 | def version 8 | version = `git describe --tags --abbrev=0`.chomp.sub('v','') 9 | version.empty? ? '0.0.0' : version 10 | end 11 | 12 | def next_version(type = :patch) 13 | section = [:major,:minor,:patch].index type 14 | 15 | n = version.split '.' 16 | n[section] = n[section].to_i + 1 17 | n.join '.' 18 | end 19 | 20 | desc "Build and publish image" 21 | task :docker => [ 'docker:build', 'docker:push' ] do 22 | puts 'Published' 23 | end 24 | 25 | desc "Build Docker image" 26 | task 'docker:build' do 27 | system("docker build --no-cache=true -t puppetlabs/bunsen:#{version} -t puppetlabs/bunsen:latest .") 28 | puts 'Start container manually with: docker run -it puppetlabs/bunsen' 29 | puts 'Or rake docker::run' 30 | end 31 | 32 | desc "Run Bunsen image locally for debugging" 33 | task 'docker::run' do 34 | `docker run -it puppetlabs/bunsen` 35 | end 36 | 37 | desc "Upload image to GCE" 38 | task 'docker:push' do 39 | system("docker tag puppetlabs/bunsen gcr.io/puppetlabs.com/api-project-531226060619/bunsen") 40 | system("docker push gcr.io/puppetlabs.com/api-project-531226060619/bunsen:latest") 41 | end 42 | -------------------------------------------------------------------------------- /scripts/interactive-messages.js: -------------------------------------------------------------------------------- 1 | // Description 2 | // A hubot wrapper for @slack/interactive-messages. 3 | // 4 | // Configuration: 5 | // SLACK_SIGNING_SECRET, SLACK_ACTIONS_URL, SLACK_OPTIONS_URL 6 | // Commands 7 | // 8 | // Notes: 9 | // 10 | // Author: 11 | // Farid Nouri Neshat 12 | // https://gitlab.olindata.com/olindata/hubot-interactive-messages/ 13 | 14 | 'use strict'; 15 | 16 | const { createMessageAdapter } = require('@slack/interactive-messages'); 17 | 18 | module.exports = function (robot) { 19 | if (robot.setActionHandler || robot.setOptionsHandler) { 20 | throw new Error(`robot.setActionHandler and robot.setOptionsHandler are already defined. 21 | This module will not redefine them. Something is conflicting with this module.`); 22 | } 23 | 24 | const slackMessages = createMessageAdapter(process.env.SLACK_SIGNING_SECRET); 25 | 26 | robot.setActionHandler = slackMessages.action.bind(slackMessages); 27 | robot.setOptionsHandler = slackMessages.options.bind(slackMessages); 28 | 29 | const messageMiddleware = slackMessages.expressMiddleware(); 30 | 31 | robot.router.use(process.env.SLACK_ACTIONS_URL || '/slack/actions', messageMiddleware); 32 | robot.router.use(process.env.SLACK_OPTIONS_URL || '/slack/options', messageMiddleware); 33 | }; 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dr Bunsen Honeydew 2 | ## Slack bot extraordinaire, at your service. 3 | 4 | This is a simple Slackbot, based on [hubot](https://hubot.github.com), and the 5 | k8s config required to stand him up. 6 | 7 | ### Customization 8 | 9 | There are two ways to customize this bot. The simplest is just to add existing 10 | scripts from the [NPM registry](https://www.npmjs.com/browse/keyword/hubot-scripts). 11 | 12 | 1. Find the script you want and learn how to configure it. 13 | * Environment variables will go in `k8s/deployment.yaml`. 14 | 1. Add the name of the script to `external-scripts.json`. 15 | 1. Add the script and any dependencies to the `npm install` call in the `Dockerfile`. 16 | 17 | You can also *write* a custom script. 18 | 19 | 1. Write the script in the `scripts` directory. 20 | * Don't add this to `external-scripts.json`, that happens automatically. 21 | 1. Add any dependencies to the `npm install` call in the `Dockerfile`. 22 | 1. Add any environment variables needed to `k8s/deployment.yaml`. 23 | 24 | ### Building and testing 25 | 26 | * Build the image with `rake docker:build`. 27 | * Run the bot locally for testing with `rake docker:run`. This will drop you into 28 | a shell bot simulator where you can "direct message" with the bot. 29 | * If you need filesystem access, you can run the image directly: 30 | * `docker run -it puppetlabs/bunsen /bin/sh` 31 | * Start the shell with `bin/hubot` 32 | 33 | ### Publishing 34 | 35 | This requires push access to the `puppet-community` GCR namespace. Setting that 36 | up is out of scope for this document. You'll also need to be a slack admin to 37 | recycle the bot. 38 | 39 | 1. `rake docker:push` 40 | 1. Wait 30 seconds 41 | 1. In slack: `@bunsen restart` 42 | 43 | Yes, this process will be improved shortly. 44 | -------------------------------------------------------------------------------- /k8s/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apps/v1 3 | kind: StatefulSet 4 | metadata: 5 | labels: 6 | deployment: bunsen 7 | name: bunsen 8 | namespace: community-prod 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | deployment: bunsen 14 | serviceName: bunsen 15 | template: 16 | metadata: 17 | labels: 18 | deployment: bunsen 19 | spec: 20 | containers: 21 | - name: bunsen 22 | image: gcr.io/puppetlabs.com/api-project-531226060619/bunsen:latest 23 | imagePullPolicy: Always 24 | livenessProbe: 25 | httpGet: 26 | path: /status 27 | port: 8080 28 | initialDelaySeconds: 12 29 | timeoutSeconds: 2 30 | ports: 31 | - containerPort: 8080 32 | protocol: TCP 33 | env: 34 | - name: HUBOT_SLACK_TOKEN 35 | valueFrom: 36 | secretKeyRef: 37 | name: bunsen-secrets 38 | key: slack-token 39 | - name: SLACK_SIGNING_SECRET 40 | valueFrom: 41 | secretKeyRef: 42 | name: bunsen-secrets 43 | key: slack-signing-secret 44 | - name: GOOGLE_CALENDAR_APIKEY 45 | valueFrom: 46 | secretKeyRef: 47 | name: bunsen-secrets 48 | key: google-calendar-apikey 49 | - name: HUBOT_BRAIN_DIR 50 | value: /home/hubot/state 51 | volumeMounts: 52 | - mountPath: /home/hubot/state 53 | name: bunsen-state 54 | securityContext: 55 | runAsUser: 501 56 | runAsGroup: 501 57 | fsGroup: 501 58 | imagePullSecrets: 59 | - name: regcred 60 | restartPolicy: Always 61 | volumes: 62 | - name: bunsen-state 63 | persistentVolumeClaim: 64 | claimName: bunsen-brain 65 | -------------------------------------------------------------------------------- /scripts/onboard.coffee: -------------------------------------------------------------------------------- 1 | # Description 2 | # Onboard new users by presenting them with a welcome message and rooms according to their interests. 3 | # 4 | # Commands: 5 | # hubot welcome - Starts the welcome wizard. 6 | # 7 | # Author: 8 | # binford2k 9 | # 10 | # Category: workflow 11 | 12 | module.exports = (robot) -> 13 | 14 | robot.respond /welcome/i, (msg) -> 15 | robot.adapter.client.web.reactions.add('wave', {channel: msg.message.room, timestamp: msg.message.id}) 16 | msg.send({ 17 | "text": "Would you like to play a game?", 18 | "attachments": [ 19 | { 20 | "text": "Choose a game to play", 21 | "fallback": "You are unable to choose a game", 22 | "callback_id": "welcome_wizard", 23 | "color": "#3AA3E3", 24 | "attachment_type": "default", 25 | "actions": [ 26 | { 27 | "name": "game", 28 | "text": "Chess", 29 | "type": "button", 30 | "value": "chess" 31 | }, 32 | { 33 | "name": "game", 34 | "text": "Falken's Maze", 35 | "type": "button", 36 | "value": "maze" 37 | }, 38 | { 39 | "name": "game", 40 | "text": "Thermonuclear War", 41 | "style": "danger", 42 | "type": "button", 43 | "value": "war", 44 | "confirm": { 45 | "title": "Are you sure?", 46 | "text": "Wouldn't you prefer a good game of chess?", 47 | "ok_text": "Yes", 48 | "dismiss_text": "No" 49 | } 50 | } 51 | ] 52 | } 53 | ] 54 | }) 55 | 56 | robot.setActionHandler 'welcome_wizard', (payload, respond) => 57 | 58 | return 'Glad you could join us.' 59 | -------------------------------------------------------------------------------- /scripts/forge.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Automatically post Puppet Forge links when module names are seen 3 | # 4 | # Commands: 5 | # forge search - Links the top module results from the Forge 6 | # 7 | # Notes: 8 | # None 9 | # 10 | # Author: 11 | # Ben Ford 12 | 13 | base_url = "https://forge.puppet.com" 14 | query_url = "https://forgeapi.puppet.com/v3/modules" 15 | query_params = "hide_deprecated=true&limit=3&module_groups=base pe_only&query" 16 | slug_regex = /\b(\w+)[-\/](\w+)\b/ 17 | search_regex = /^forge search (.*)/ 18 | 19 | module.exports = (robot) -> 20 | 21 | robot.hear slug_regex, (msg) -> 22 | author = msg.match[1] 23 | name = msg.match[2] 24 | msg 25 | .http("#{query_url}/#{author}-#{name}") 26 | .get() (err, res, body) => 27 | 28 | mod = JSON.parse(body) 29 | if mod.owner? 30 | robot.adapter.client.web.conversations.list() 31 | .then (api_response) -> 32 | found = api_response.channels.find (iter) -> iter.id is msg.message.room 33 | channel = if found? then found.name else msg.message.room 34 | msg.send "See the `#{mod.slug}` module at #{base_url}/#{mod.owner.username}/#{mod.name}?src=slack&channel=#{channel}" 35 | 36 | 37 | robot.hear search_regex, (msg) -> 38 | query = msg.match[1] 39 | msg 40 | .http(encodeURI("#{query_url}?#{query_params}=#{query}")) 41 | .get() (err, res, body) => 42 | 43 | response = JSON.parse(body) 44 | if response.pagination.total > 0 45 | robot.adapter.client.web.conversations.list() 46 | .then (api_response) -> 47 | found = api_response.channels.find (iter) -> iter.id is msg.message.room 48 | channel = if found? then found.name else msg.message.room 49 | 50 | str = response.results.reduce (str, mod) -> 51 | str.concat "- #{base_url}/#{mod.owner.username}/#{mod.name}?src=slack&channel=#{channel}\n" 52 | , '' 53 | 54 | msg.send str.concat "<#{base_url}/modules?q=#{query}|See all #{response.pagination.total} results on the Forge>." 55 | 56 | else 57 | msg.send "No Forge modules found for that query." 58 | -------------------------------------------------------------------------------- /scripts/office-hours.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Puppet Community Office Hours 3 | # 4 | # Commands: 5 | # none 6 | # 7 | # Author: 8 | # Ben Ford 9 | 10 | CronJob = require('cron').CronJob 11 | calendarUrl = "https://www.googleapis.com/calendar/v3/calendars/puppet.com_1bv1p3pc433btsejtm5hikhmec@group.calendar.google.com/events" 12 | main_channel = 'puppet' 13 | office_hours = 'office-hours' 14 | 15 | module.exports = (robot) -> 16 | # Resolve channel names to IDs. Check for a client first so we can run via shell for testing 17 | if robot.adapter.client 18 | robot.adapter.client.web.conversations.list() 19 | .then (api_response) -> 20 | room = api_response.channels.find (channel) -> channel.name is main_channel 21 | main_channel = room.id if room? 22 | 23 | room = api_response.channels.find (channel) -> channel.name is office_hours 24 | office_hours = room.id if room? 25 | 26 | registerJob = (expr, cb) -> 27 | new CronJob expr, cb, null, true 28 | 29 | timeLink = (dt) -> 30 | timestamp = dt.toISOString().replace(/[-:Z]|(.000)/g,'') 31 | # the p1,2,3 elements are locations. Modify the list by adding locations via the web and observing the URL changes 32 | "https://www.timeanddate.com/worldclock/converter.html?iso=#{timestamp}&p1=202&p2=179&p3=919&p4=307&p5=3332&p6=236&p7=240" 33 | 34 | emoji = -> 35 | items = [ 36 | ':coffee:', 37 | ':kermit_typing:', 38 | ':the_more_you_know:', 39 | ':beaker:', 40 | ':businessparrot:', 41 | ':fry_dancing:', 42 | ':goodnews:', 43 | ':indeed:', 44 | ':letsplay:', 45 | ':meeting:', 46 | ':allthethings:', 47 | ':waiting:' 48 | ] 49 | items[~~(items.length * Math.random())] 50 | 51 | # unbounded next event, no matter how far out 52 | getNextSession = (cb) -> 53 | robot.http(calendarUrl) 54 | .query 55 | key: process.env.GOOGLE_CALENDAR_APIKEY 56 | maxResults: 1 57 | singleEvents: true 58 | orderBy: 'startTime' 59 | timeMin: new Date().toISOString() 60 | .get() (err, res, body) -> 61 | data = JSON.parse(body) 62 | cb data.items[0] 63 | 64 | # time bounded, get an event within a range of minutes from now 65 | getUpcomingSession = (cb, now = 15, next = 30) -> 66 | now = new Date(new Date().getTime() + now*60000) 67 | next = new Date(new Date().getTime() + next*60000) 68 | 69 | robot.http(calendarUrl) 70 | .query 71 | key: process.env.GOOGLE_CALENDAR_APIKEY 72 | maxResults: 1 73 | singleEvents: true 74 | orderBy: 'startTime' 75 | timeMin: now.toISOString() 76 | timeMax: next.toISOString() 77 | .get() (err, res, body) -> 78 | data = JSON.parse(body) 79 | if data.items.length != 0 80 | cb data.items[0] 81 | 82 | setSessionTimeout = (event) -> 83 | # Kick in shortly after the session finishes to make sure it's done. 84 | msToFinish = new Date(event.end.dateTime) - Date.now() + 2*60000 85 | 86 | setTimeout -> 87 | robot.logger.info " ↳ session finishing up, checking for the next one..." 88 | getNextSession (event) -> 89 | start = new Date(event.start.dateTime) 90 | hrsToStart = Math.round((start - Date.now())/(1000*60*60)) 91 | 92 | # if there's not a currently running session... 93 | if hrsToStart > 0 94 | robot.messageRoom(office_hours, { 95 | text: "#{emoji()} Next up is _#{event.summary}_ in <#{timeLink start}|#{hrsToStart} hours>", 96 | unfurl_links: false 97 | }) 98 | 99 | # This method is not allowed for bots belonging to a slack app. https://api.slack.com/methods/conversations.setTopic 100 | #robot.adapter.client.web.conversations.setTopic(office_hours, "#{emoji} Next up is _#{event.summary}_ at <#{timeLink start}|#{start.toUTCString()}>") 101 | , msToFinish 102 | 103 | 104 | # if we restart in the middle of a session, this will recover the end of session timeout 105 | # The super short time range should only return events running during that one minute 106 | robot.logger.info "Checking for a running session..." 107 | getUpcomingSession (event) -> 108 | robot.logger.info " ↳ setting timeout" 109 | setSessionTimeout event 110 | , 0, 1 111 | 112 | registerJob '0 45 * * * *', -> 113 | robot.logger.info "Checking for upcoming Office Hours..." 114 | getUpcomingSession (event) -> 115 | robot.logger.info " ↳ posting event info" 116 | start = new Date(event.start.dateTime) 117 | minsToStart = Math.round((start - Date.now())/(1000*60)) 118 | 119 | robot.messageRoom(main_channel, "#{emoji()} _#{event.summary}_ is about to start up in #office-hours") 120 | robot.messageRoom(office_hours, "#{emoji()} _#{event.summary}_ is about to start up in #{minsToStart} minutes") 121 | 122 | setSessionTimeout event 123 | 124 | 125 | robot.hear /next office hour.*\?/i, (msg) -> 126 | getNextSession (event) -> 127 | start = new Date(event.start.dateTime) 128 | hrsToStart = Math.round((start - Date.now())/(1000*60*60)) 129 | 130 | if hrsToStart < 0 131 | content = "#{emoji()} _#{event.summary}_ is running right now in #office-hours!" 132 | else 133 | content = "#{emoji()} The next Office Hour is _#{event.summary}_ in <#{timeLink start}|#{hrsToStart} hours> in #office-hours" 134 | 135 | # send rich content so slack doesn't unfurl the time zone page 136 | if robot.adapter.client 137 | msg.send({ text: content, unfurl_links: false }) 138 | else 139 | msg.send content 140 | 141 | -------------------------------------------------------------------------------- /scripts/karma.coffee: -------------------------------------------------------------------------------- 1 | # Description: 2 | # Track arbitrary karma 3 | # 4 | # Dependencies: 5 | # None 6 | # 7 | # Configuration: 8 | # None 9 | # 10 | # Commands: 11 | # ++ - give thing some karma 12 | # -- - take away some of thing's karma 13 | # hubot karma - check thing's karma (if is omitted, show the top 5) 14 | # hubot karma empty - empty a thing's karma 15 | # hubot karma best - show the top 5 16 | # hubot karma worst - show the bottom 5 17 | # 18 | # Author: 19 | # stuartf, branan, nlew, samwoods, stahnma 20 | # 21 | # Category: social 22 | 23 | ignorelist = [ 24 | /^(lib)?stdc$/ 25 | /-{2,}/ 26 | /^[rwx-]+$/ 27 | /```/ 28 | ] 29 | 30 | class Karma 31 | 32 | constructor: (@robot) -> 33 | @cache = {} 34 | 35 | @increment_responses = [ 36 | "+1!", "gained a level!", "is on the rise!", "leveled up!" 37 | ] 38 | 39 | @decrement_responses = [ 40 | "took a hit! Ouch.", "took a dive.", "lost a life.", "lost a level." 41 | ] 42 | 43 | @self_responses = [ 44 | "Nice try %name.", "I don't think so, %name.", "Hey everyone! Did you see what %name tried to do?" 45 | ] 46 | 47 | 48 | @userForMentionName = (name) -> 49 | name = normalizeSubject(name) 50 | lowerName = name.toLowerCase() 51 | 52 | for k of (@robot.brain.data.users or { }) 53 | mentionName = @robot.brain.data.users[k].slack.profile.display_name 54 | if mentionName? and mentionName.toLowerCase() is lowerName 55 | result = @robot.brain.data.users[k] 56 | return result 57 | return null 58 | 59 | @mentionName = (subject) -> 60 | 61 | @robot.brain.on 'loaded', => 62 | if @robot.brain.data.karma 63 | @cache = @robot.brain.data.karma 64 | 65 | kill: (thing) -> 66 | delete @cache[thing] 67 | @robot.brain.data.karma = @cache 68 | 69 | increment: (thing) -> 70 | @cache[thing] ?= 0 71 | @cache[thing] += 1 72 | @robot.brain.data.karma = @cache 73 | 74 | decrement: (thing) -> 75 | @cache[thing] ?= 0 76 | @cache[thing] -= 1 77 | @robot.brain.data.karma = @cache 78 | 79 | incrementResponse: -> 80 | @increment_responses[Math.floor(Math.random() * @increment_responses.length)] 81 | 82 | decrementResponse: -> 83 | @decrement_responses[Math.floor(Math.random() * @decrement_responses.length)] 84 | 85 | selfResponse: -> 86 | @self_responses[Math.floor(Math.random() * @self_responses.length)] 87 | 88 | 89 | 90 | get: (thing) -> 91 | k = if @cache[thing] then @cache[thing] else 0 92 | if thing == "e" 93 | k = k + 2.71828 94 | return k 95 | 96 | sort: -> 97 | s = [] 98 | for key, val of @cache 99 | s.push({ name: key, karma: val }) 100 | s.sort (a, b) -> b.karma - a.karma 101 | 102 | top: (n = 5) -> 103 | sorted = @sort() 104 | sorted.slice(0, n) 105 | 106 | bottom: (n = 5) -> 107 | sorted = @sort() 108 | sorted.slice(-n).reverse() 109 | 110 | mention_name = (robot, user) -> 111 | robot.brain.data.users[user.id].slack.profile.display_name 112 | 113 | filtered = (subject) -> 114 | ignorelist.some (re) -> 115 | subject.match re 116 | 117 | normalizeSubject = (subject) -> 118 | if subject.indexOf('@') == 0 119 | normalizeSubject subject[1..-1] 120 | else 121 | subject.trim() 122 | 123 | self_karma = (msg, subject) -> 124 | if msg.robot.adapterName == "slack" 125 | # demeter I apologize 126 | user = mention_name(msg.robot, msg.message.user).toLowerCase() 127 | else 128 | user = "shell" 129 | if user == subject 130 | true 131 | 132 | sub_response = (text, msg) -> 133 | text.replace('%name', mention_name(msg.robot, msg.message.user)).replace('%room', msg.message.room) 134 | 135 | increment_or_decrement_karma = (msg, karma, matches, increment_bool) -> 136 | unique_matches = new Set 137 | messages = [] 138 | for match in matches 139 | m = match[4] || match[5] || match[6] || match[7] || match[11] || match[12] || match[13] || match[14] 140 | subject = normalizeSubject m.toLowerCase() 141 | if unique_matches.has(subject) 142 | continue 143 | unique_matches.add(subject) 144 | if !filtered(subject) 145 | if !self_karma(msg, subject) 146 | if increment_bool 147 | karma.increment subject 148 | messages.push "#{subject} #{karma.incrementResponse()} (Karma: #{karma.get(subject)})" 149 | else 150 | karma.decrement subject 151 | messages.push "#{subject} #{karma.decrementResponse()} (Karma: #{karma.get(subject)})" 152 | else 153 | messages.push(sub_response(karma.selfResponse(), msg)) 154 | msg.send messages.join("\n") 155 | 156 | module.exports = (robot) -> 157 | karma = new Karma robot 158 | 159 | # match on any message including (inc item), (dec item), item++ or item-- 160 | robot.hear /(\((inc|dec)\s+\S.*\)(\s|$)|(\S|\s)(--|\+\+)(\s|$))/, (msg) -> 161 | # Positive karma 162 | pos_matches = [] 163 | pos_grouped_regex = /// 164 | ( # Group 1. 165 | ( # Group 2. 166 | ( # Group 3. 167 | @(\S+[^+:\s])\s # Group 4. Someone's name followed by a space 168 | |(\S+[^+:\s]) # Group 5. A single word not followed by a space, :, or + 169 | |\(([^\(\)]+\W[^\(\)]+)\) # Group 6. A parenthetic multi-word phrase 170 | |(:[^:\s]+:)\s? # Group 7. A single word between colons, optionally followed by a space 171 | ) # 172 | \+\+ # ++ for karma 173 | (\s|[!-~]|$) # Group 8. A space or punctuation or end of line 174 | )| # 175 | (\(inc\s+ # Group 9. inc for karma 176 | ( # Group 10. 177 | @(\S+[^+:\s])\s # Group 11. Someone's name followed by a space 178 | |(\S+[^+:\s]) # Group 12. A single word not followed by a space, :, or + 179 | |\(([^\(\)]+\W[^\(\)]+)\) # Group 13. A parenthetic multi-word phrase 180 | |(:[^:\s]+:)\s? # Group 14. A single word between colons, optionally followed by a space 181 | ) # 182 | (\s*\)) # Group 15. Closing paren 183 | ) 184 | ) 185 | ///g 186 | 187 | while (pos_match = pos_grouped_regex.exec(msg.message)) 188 | pos_matches.push(pos_match) 189 | 190 | increment_or_decrement_karma(msg, karma, pos_matches, true) 191 | 192 | # Negative karma 193 | neg_matches = [] 194 | neg_grouped_regex = /// 195 | ( # Group 1. 196 | ( # Group 2. 197 | \w # Don't match a name on the previous line (fixes code blocks) 198 | ( # Group 3. 199 | @(\S+[^+:\s])\s # Group 4. Someone's name followed by a space 200 | |(\S+[^+:\s]) # Group 5. A single word not followed by a space, :, or + 201 | |\(([^\(\)]+\W[^\(\)]+)\) # Group 6. A parenthetic multi-word phrase 202 | |(:[^:\s]+:)\s? # Group 7. A single word between colons, optionally followed by a space 203 | ) # 204 | -- # -- for karma 205 | (\s|[!-~]|$) # Group 8. A space or punctuation, or end of line 206 | )| # 207 | (\(dec\s+ # Group 9. dec for karma 208 | ( # Group 10. 209 | @(\S+[^+:\s])\s # Group 11. Someone's name followed by a space 210 | |(\S+[^+:\s]) # Group 12. A single word not followed by a space, :, or + 211 | |\(([^\(\)]+\W[^\(\)]+)\) # Group 13. A parenthetic multi-word phrase 212 | |(:[^:\s]+:)\s? # Group 14. A single word between colons, optionally followed by a space 213 | ) # 214 | (\s*\)) # Group 15. Closing paren 215 | ) 216 | ) 217 | ///g 218 | 219 | while (neg_match = neg_grouped_regex.exec(msg.message)) 220 | neg_matches.push(neg_match) 221 | 222 | increment_or_decrement_karma(msg, karma, neg_matches, false) 223 | 224 | robot.respond /karma empty ([\s\S]+)$/i, (msg) -> 225 | subject = msg.match[1].toLowerCase() 226 | # Don't kill karma if 'empty *' has karma. Use 'empty empty *' 227 | if karma.get("empty #{subject}") == 0 228 | # If you're attempting to nuke somebody's karma, hell to pay. 229 | # This only works when the user who attempted the action is known 230 | if karma.userForMentionName(subject) and msg.envelope.room.toLowerCase() != "shell" 231 | # find karma for subject. 232 | current_karma = karma.get(subject) 233 | # find karma for requestor. 234 | requester = normalizeSubject mention_name(msg.robot, msg.message.user).toLowerCase() 235 | # Append requester's karma to subject. 236 | robot.brain.data.karma[subject] += karma.get(requester) 237 | # Emtpry requesters karma. 238 | robot.brain.data.karma[requester] = 1 239 | msg.reply "Instant karma. Now #{subject} has all of your karma and your karma is reset." 240 | robot.brain.save(); 241 | else 242 | current_karma = karma.get(subject) 243 | karma.kill subject 244 | msg.send "#{subject} has had all #{current_karma} of its karma scattered to the winds." 245 | 246 | robot.respond /karma( best)?$/i, (msg) -> 247 | verbiage = ["The Best"] 248 | for item, rank in karma.top() 249 | verbiage.push "#{rank + 1}. #{item.name} - #{item.karma}" 250 | msg.send verbiage.join("\n") 251 | 252 | robot.respond /karma worst$/i, (msg) -> 253 | verbiage = ["The Worst"] 254 | for item, rank in karma.bottom() 255 | verbiage.push "#{rank + 1}. #{item.name} - #{item.karma}" 256 | msg.send verbiage.join("\n") 257 | 258 | robot.respond /karma (.+)$/i, (msg) -> 259 | match = msg.match[1].toLowerCase() 260 | if match != "best" && match != "worst" && ! /empty/.test(match) 261 | msg.send "\"#{match}\" has #{karma.get(match)} karma." 262 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------