├── deploy ├── ansible │ ├── init.retry │ ├── configure.retry │ ├── roles │ │ ├── deploy │ │ │ ├── templates │ │ │ │ └── build_info.txt │ │ │ └── tasks │ │ │ │ ├── build.yml │ │ │ │ ├── checkout.yml │ │ │ │ └── main.yml │ │ ├── configure │ │ │ ├── files │ │ │ │ └── 10periodic │ │ │ └── tasks │ │ │ │ ├── swap.yml │ │ │ │ └── main.yml │ │ ├── services │ │ │ ├── templates │ │ │ │ └── bots.conf │ │ │ ├── vars │ │ │ │ └── vault.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ └── base │ │ │ └── tasks │ │ │ └── main.yml │ ├── inventory │ │ └── production │ ├── init.yml │ ├── deploy.yml │ ├── provision.yml │ ├── configure.yml │ └── group_vars │ │ └── all.yml ├── encrypt-env.sh └── run-playbook.sh ├── .babelrc ├── .gitignore ├── .travis.yml ├── .eslintrc-es2015.yaml ├── .eslintrc-mocha.yaml ├── .eslintrc-node.yaml ├── src ├── index.js ├── lib │ ├── bot-helpers.js │ └── dialog.js └── bob │ └── index.js ├── config.js ├── Gruntfile.js ├── eslint ├── eslint-node-commonjs.yaml ├── eslint-es2015.yaml └── eslint-defaults.yaml ├── README.md ├── package.json └── Gruntfile.babel.js /deploy/ansible/init.retry: -------------------------------------------------------------------------------- 1 | bots.rj3.net 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /deploy/ansible/configure.retry: -------------------------------------------------------------------------------- 1 | bots.rj3.net 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .vagrant 4 | .env 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "6" 5 | - "4" 6 | -------------------------------------------------------------------------------- /.eslintrc-es2015.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - ".eslintrc-node.yaml" 4 | - "./eslint/eslint-es2015.yaml" 5 | -------------------------------------------------------------------------------- /deploy/ansible/roles/deploy/templates/build_info.txt: -------------------------------------------------------------------------------- 1 | date: {{ansible_date_time.iso8601}} 2 | sha: {{sha.stdout}} 3 | env: {{env}} 4 | -------------------------------------------------------------------------------- /.eslintrc-mocha.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | globals: 5 | assert: true 6 | expect: true 7 | extends: 8 | - ".eslintrc-es2015.yaml" 9 | -------------------------------------------------------------------------------- /.eslintrc-node.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | - "./eslint/eslint-defaults.yaml" 4 | - "./eslint/eslint-node-commonjs.yaml" 5 | globals: 6 | Intl: false 7 | -------------------------------------------------------------------------------- /deploy/ansible/inventory/production: -------------------------------------------------------------------------------- 1 | # Specify your production app server here. This should match the ansible 2 | # group_vars/all site_fqdn setting. 3 | 4 | bots.rj3.net 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | 3 | import createBot from './bob'; 4 | if (config.tokens.bob) { 5 | const bot = createBot(config.tokens.bob); 6 | bot.login(); 7 | } 8 | -------------------------------------------------------------------------------- /deploy/ansible/roles/configure/files/10periodic: -------------------------------------------------------------------------------- 1 | APT::Periodic::Update-Package-Lists "1"; 2 | APT::Periodic::Download-Upgradeable-Packages "1"; 3 | APT::Periodic::AutocleanInterval "7"; 4 | APT::Periodic::Unattended-Upgrade "1"; 5 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('dotenv').config(); 4 | 5 | module.exports = { 6 | isProduction: process.env.NODE_ENV === 'production', 7 | tokens: { 8 | bob: process.env.TOKEN_CKB_BOB, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // babel gruntfile bootstrapper 4 | 5 | require('babel-register'); 6 | 7 | module.exports = function(grunt) { 8 | module.exports.grunt = grunt; 9 | require('./Gruntfile.babel'); 10 | }; 11 | -------------------------------------------------------------------------------- /deploy/ansible/init.yml: -------------------------------------------------------------------------------- 1 | # This playbook saves the trouble of running each of the following playbooks 2 | # individually, and is provided for convenience. After "vagrant up", this 3 | # playbook will be run on the new Vagrant box. 4 | 5 | - include: provision.yml 6 | - include: configure.yml 7 | -------------------------------------------------------------------------------- /deploy/ansible/roles/services/templates/bots.conf: -------------------------------------------------------------------------------- 1 | description "daemon for bots" 2 | 3 | start on startup 4 | stop on shutdown 5 | respawn 6 | 7 | env NODE_ENV={{env}} 8 | {% if env == "production" %} 9 | {% for key in vault_env.splitlines() %} 10 | env {{key}} 11 | {% endfor %} 12 | {% endif %} 13 | 14 | script 15 | /usr/bin/npm start --prefix {{site_path}} 16 | end script 17 | -------------------------------------------------------------------------------- /deploy/ansible/deploy.yml: -------------------------------------------------------------------------------- 1 | # Clone, build, and deploy, restarting nginx if necessary. This playbook must 2 | # be run after provision and configure, and is used to deploy and build the 3 | # specified commit (overridable via extra vars) on the server. Running this 4 | # playbook in Vagrant will override the vagrant-link playbook, and vice-versa. 5 | 6 | - hosts: all 7 | become: yes 8 | become_method: sudo 9 | roles: 10 | - deploy 11 | -------------------------------------------------------------------------------- /deploy/ansible/provision.yml: -------------------------------------------------------------------------------- 1 | # Provision server. This playbook must be run when a server is first created 2 | # and is typically only run once. It may be run again if you make server-level 3 | # changes or need to update any installed apt modules to their latest versions. 4 | # If you were creating a new AMI or base box, you'd do so after running only 5 | # this playbook. 6 | 7 | - hosts: all 8 | become: yes 9 | become_method: sudo 10 | roles: 11 | - {role: base, tags: base} 12 | -------------------------------------------------------------------------------- /deploy/ansible/configure.yml: -------------------------------------------------------------------------------- 1 | # Configure server. This playbook is run after a server is provisioned but 2 | # before a project is deployed, to configure the system, add user accounts, 3 | # and setup long-running processes like nginx, postgres, etc. 4 | 5 | - hosts: all 6 | become: yes 7 | become_method: sudo 8 | roles: 9 | - {role: configure, tags: configure} 10 | - {role: services, tags: services} 11 | handlers: 12 | - name: restart sshd 13 | service: name=ssh state=restarted 14 | -------------------------------------------------------------------------------- /eslint/eslint-node-commonjs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | node: true 4 | 5 | ecmaFeatures: 6 | blockBindings: true 7 | 8 | rules: 9 | 10 | # General but applicable here 11 | 12 | strict: 13 | - 2 14 | - global 15 | 16 | # Node.js and CommonJS 17 | 18 | callback-return: 19 | - 2 20 | - [ callback, cb, done, next ] 21 | handle-callback-err: 22 | - 2 23 | - "^err(?:or)?$" 24 | no-mixed-requires: 0 25 | no-new-require: 2 26 | no-path-concat: 2 27 | no-process-exit: 2 28 | no-restricted-modules: 0 29 | no-sync: 0 30 | -------------------------------------------------------------------------------- /deploy/ansible/roles/services/vars/vault.yml: -------------------------------------------------------------------------------- 1 | $ANSIBLE_VAULT;1.1;AES256 2 | 30353738636262656130393938383965623234373035396464393834626462643261633666323263 3 | 3137363364626466633661656532393130613639633037350a666537353237643065363631353631 4 | 36376362616463303234343732386665306234333665623833626538396663363237363934383761 5 | 3965366531346534610a373536326432636335376632386665656339326664646161346534343037 6 | 66623465343861396665653438303464653563333661343165363061666633316137313030653561 7 | 37663864353630636434643332343464313961316563333135393164333963313366313936333039 8 | 64313436386566333262383532616530656234666461643335623861306361653835313938303836 9 | 39393538623064346161 10 | -------------------------------------------------------------------------------- /deploy/ansible/roles/services/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - include_vars: vault.yml 2 | - name: bot daemon is loaded 3 | template: src=bots.conf dest=/etc/init/ backup=no 4 | 5 | # https://myodroid.wordpress.com/2015/12/13/restart-upstart-service-from-crontab/ 6 | # http://stackoverflow.com/questions/31129348/can-upstarts-service-start-be-used-inside-a-cron-job 7 | - name: ensure env is set properly in cron for upstart to work 8 | cron: 9 | name: PATH 10 | env: yes 11 | value: "/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin" 12 | 13 | - name: ensure bot is restarted daily 14 | cron: 15 | name: restart bot daemon 16 | job: /usr/sbin/service bots restart 17 | hour: 5 18 | minute: 0 19 | -------------------------------------------------------------------------------- /eslint/eslint-es2015.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | ecmaFeatures: 3 | modules: true 4 | 5 | env: 6 | es6: true 7 | 8 | rules: 9 | # General but applicable here 10 | 11 | no-inner-declarations: 0 12 | no-iterator: 0 13 | radix: 0 14 | 15 | # ECMAScript 6 16 | 17 | arrow-parens: 18 | - 2 19 | - as-needed 20 | arrow-spacing: 21 | - 2 22 | - before: true 23 | after: true 24 | constructor-super: 2 25 | generator-star-spacing: 26 | - 2 27 | - before 28 | no-class-assign: 2 29 | no-const-assign: 2 30 | no-this-before-super: 2 31 | no-var: 2 32 | object-shorthand: 33 | - 2 34 | - always 35 | prefer-const: 2 36 | prefer-spread: 2 37 | prefer-reflect: 0 38 | require-yield: 2 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bots 2 | 3 | ## Deployment 4 | 5 | ### Setting up a new server 6 | 7 | 1. Log into Digital Ocean and [create a droplet](https://cloud.digitalocean.com/droplets/new?size=512mb®ion=nyc3&distro=ubuntu&distroImage=ubuntu-14-04-x64). 8 | 2. Add SSH key. 9 | 3. Set Hostname to `bots`. 10 | 4. Click the "Create" button. 11 | 5. [Assign the floating IP](https://cloud.digitalocean.com/networking#actions-floating-ip) 12 | `138.197.63.241` to the new `bots` droplet. 13 | 6. Run `./deploy/run-playbook.sh init production --ask-vault-pass` and enter the 14 | password used to encrypt the vault file(s). 15 | 16 | ### Deploying 17 | 18 | 1. Ensure latest code is pushed to master. 19 | 2. Run `./deploy/run-playbook.sh deploy production` 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bots", 3 | "private": true, 4 | "version": "0.0.1", 5 | "scripts": { 6 | "start": "babel-node ./src/index", 7 | "dev": "grunt", 8 | "test": "grunt test" 9 | }, 10 | "dependencies": { 11 | "@slack/client": "^3.4.0", 12 | "babel-cli": "^6.8.0", 13 | "babel-core": "^6.8.0", 14 | "babel-plugin-transform-runtime": "^6.4.0", 15 | "babel-preset-es2015": "^6.3.13", 16 | "babel-register": "^6.4.3", 17 | "babel-runtime": "^6.3.19", 18 | "bluebird": "^3.4.0", 19 | "chatter": "^0.5.0", 20 | "dotenv": "^2.0.0", 21 | "heredoc-tag": "^0.1.0" 22 | }, 23 | "devDependencies": { 24 | "grunt": "^0.4.5", 25 | "grunt-cli": "^0.1.13", 26 | "grunt-contrib-watch": "^0.6.1", 27 | "grunt-eslint": "^17.3.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /deploy/ansible/roles/deploy/tasks/build.yml: -------------------------------------------------------------------------------- 1 | # All project build tasks go here. These tasks will only be run for the 2 | # specified commit if the commit hasn't been deployed before or if "force" 3 | # is true. 4 | 5 | # Modify as-needed! 6 | 7 | - name: compare package.json of current deploy with previous deploy 8 | command: diff {{site_path}}/package.json {{clone_path}}/package.json 9 | register: package_diff 10 | ignore_errors: true 11 | no_log: true 12 | 13 | - name: copy existing npm modules 14 | command: cp -R {{site_path}}/node_modules {{clone_path}} 15 | when: package_diff.rc == 0 16 | 17 | - name: install npm modules 18 | npm: path="{{clone_path}}" 19 | when: package_diff.rc != 0 20 | 21 | - name: generate build info file 22 | template: src=build_info.txt dest={{clone_path}}/{{build_info_path}} 23 | when: build_info_path is defined 24 | -------------------------------------------------------------------------------- /deploy/ansible/roles/base/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # Get the box up and running. These tasks run before the box is configured 2 | # or the project is cloned or built. All system dependencies should be 3 | # installed here. 4 | 5 | - name: ensure apt cache is updated 6 | apt: update_cache=yes cache_valid_time=3600 7 | 8 | - name: ensure all packages are upgraded safely 9 | apt: upgrade=safe 10 | when: env != "development" 11 | 12 | - name: add keys to apt 13 | apt_key: url={{item}} state=present 14 | with_items: apt_keys 15 | 16 | - name: add ppas to apt 17 | apt_repository: 18 | repo: "{{item}}" 19 | state: present 20 | with_items: apt_ppas 21 | 22 | - name: install apt packages 23 | apt: 24 | name: "{{item}}" 25 | state: latest 26 | with_items: apt_packages 27 | 28 | - name: update npm to latest 29 | npm: name=npm state=latest global=yes 30 | -------------------------------------------------------------------------------- /deploy/ansible/roles/configure/tasks/swap.yml: -------------------------------------------------------------------------------- 1 | - name: check if swap file exists 2 | stat: path={{swap_file_path}} 3 | register: swap_file_check 4 | 5 | - name: ensure swapfile exists 6 | command: fallocate -l {{swap_file_size}} /swap 7 | when: not swap_file_check.stat.exists 8 | args: 9 | creates: "{{swap_file_path}}" 10 | 11 | - name: ensure swap file has correct permissions 12 | file: path={{swap_file_path}} owner=root group=root mode=0600 13 | 14 | - name: ensure swapfile is formatted 15 | command: mkswap {{swap_file_path}} 16 | when: not swap_file_check.stat.exists 17 | 18 | # the quotes around integers here can be removed when this is resolved 19 | # https://github.com/ansible/ansible-modules-core/issues/1861 20 | - name: ensure swap file can be mounted 21 | mount: 22 | name: none 23 | src: "{{swap_file_path}}" 24 | fstype: swap 25 | opts: sw 26 | passno: "0" 27 | dump: "0" 28 | state: present 29 | 30 | - name: ensure swap is activited 31 | command: swapon -a 32 | 33 | - name: ensure swap is used as a last resort 34 | sysctl: 35 | name: vm.swappiness 36 | value: 0 37 | -------------------------------------------------------------------------------- /deploy/encrypt-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) 4 | env_file="$script_dir/../.env" 5 | vault_file="$script_dir/ansible/roles/services/vars/vault.yml" 6 | tmp_file="$(mktemp /tmp/vault.XXXXXX)" 7 | 8 | function abort() { 9 | echo "[ERROR] $*, aborting." 10 | exit 1 11 | } 12 | 13 | function cat_file() { 14 | echo "$1" 15 | echo "================================= FILE CONTENTS ================================" 16 | cat "$1" 17 | echo "================================================================================" 18 | } 19 | 20 | if [[ ! -f "$env_file" ]]; then 21 | abort ".env file not found" 22 | fi 23 | 24 | echo "Copying .env file contents to temp file..." 25 | echo -e "---\nvault_env: |\n$(cat "$env_file" | sed 's/^/ /')" > $tmp_file 26 | 27 | echo 28 | echo "Encypting temp file..." 29 | ansible-vault encrypt "$tmp_file" || abort "Unsuccessfully encrypted temp file" 30 | 31 | echo 32 | echo "Replacing vault file with encrypted temp file..." 33 | mv $tmp_file "$vault_file" 34 | 35 | echo 36 | echo "Double-check that the following file is encryped before committing:" 37 | cat_file "$vault_file" 38 | -------------------------------------------------------------------------------- /deploy/ansible/roles/deploy/tasks/checkout.yml: -------------------------------------------------------------------------------- 1 | # Clone the repo, checking out the specified commit. If a canonical-sha-named 2 | # directory for that commit doesn't already exist, or if "force" is true, 3 | # clone the repo and build it. 4 | 5 | - name: ensure pre-existing temp directory is removed 6 | file: path={{clone_path}} state=absent 7 | 8 | - name: clone git repo into temp directory 9 | git: 10 | repo={{synced_folder if local else git_repo}} 11 | dest={{clone_path}} 12 | version={{commit}} 13 | 14 | - name: get sha of cloned repo 15 | command: git rev-parse HEAD 16 | args: 17 | chdir: "{{clone_path}}" 18 | register: sha 19 | changed_when: false 20 | 21 | - name: check if specified commit sha has already been deployed 22 | stat: path={{base_path}}/{{sha.stdout}} get_checksum=no get_md5=no 23 | register: sha_dir 24 | 25 | - include: build.yml 26 | when: force or not sha_dir.stat.exists 27 | 28 | - name: delete pre-existing sha-named directory 29 | file: path={{base_path}}/{{sha.stdout}} state=absent 30 | when: force and sha_dir.stat.exists 31 | 32 | - name: move cloned repo to sha-named directory 33 | command: mv {{clone_path}} {{base_path}}/{{sha.stdout}} 34 | when: force or not sha_dir.stat.exists 35 | 36 | - name: ensure just-created temp directory is removed 37 | file: path={{clone_path}} state=absent 38 | -------------------------------------------------------------------------------- /src/lib/bot-helpers.js: -------------------------------------------------------------------------------- 1 | // ===================== 2 | // Misc SlackBot helpers 3 | // ===================== 4 | 5 | const bot = {}; 6 | 7 | export default function mixinBotHelpers(target) { 8 | for (const prop in bot) { 9 | target[prop] = bot[prop].bind(target); 10 | } 11 | } 12 | 13 | // Convenience methods from rtmClient.dataStore: 14 | const dataStoreMethods = [ 15 | 'getUserById', 16 | 'getUserByName', 17 | 'getTeamById', 18 | 'getChannelGroupOrDMById', 19 | 'getChannelOrGroupByName', 20 | ]; 21 | 22 | dataStoreMethods.forEach(name => { 23 | bot[name] = function(id) { 24 | return this.slack.rtmClient.dataStore[name](id); 25 | }; 26 | }); 27 | 28 | // Get user name sans leading sigil, eg: cowboy 29 | bot.getName = function(name) { 30 | return this.parseMessage(name || '').replace(/^@/, ''); 31 | }; 32 | 33 | // Get user object. 34 | bot.getUser = function(name) { 35 | if (typeof name === 'object') { 36 | return name; 37 | } 38 | return this.getUserByName(this.getName(name)); 39 | }; 40 | 41 | // Get real name and fallback to user name. 42 | bot.getRealName = function(name) { 43 | const user = this.getUser(name); 44 | return user.real_name || user.name; 45 | }; 46 | 47 | // Get formatted slackname, eg: <@U025GMQTB> 48 | bot.formatUser = function(name) { 49 | return `<@${this.getUser(name).id}>`; 50 | }; 51 | 52 | // Get formatted slackname, eg: <@U025GMQTB> 53 | bot.formatId = function(id) { 54 | return `<@${id}>`; 55 | }; 56 | -------------------------------------------------------------------------------- /src/bob/index.js: -------------------------------------------------------------------------------- 1 | import {createSlackBot} from 'chatter'; 2 | import {RtmClient, WebClient, MemoryDataStore} from '@slack/client'; 3 | import mixinBotHelpers from '../lib/bot-helpers'; 4 | 5 | // Message handlers. 6 | const lolHandler = (text, {user}) => { 7 | if (/OLO|LOL/.test(text) && user.name === 'falcon') { 8 | const fingers = ':middle_finger:'.repeat(3); 9 | return `${fingers} WHO'S LAUGHING OUT LOUD NOW, BITCH ${fingers}`; 10 | } 11 | const lolRe = /(l+)\s*([o0]+)\s*(l+)/gi; 12 | if (lolRe.test(text)) { 13 | const words = ['laugh', 'out', 'loud']; 14 | const newText = text.replace(lolRe, (_, ...args) => words.reduce((arr, s, i) => 15 | [...arr, ...Array.from({length: args[i].length}, () => s)], []).join(' ')); 16 | return `More like "${newText}" amirite`; 17 | } 18 | return false; 19 | }; 20 | 21 | export default function createBot(token) { 22 | 23 | const bot = createSlackBot({ 24 | name: 'Bob', 25 | icon: 'https://dl.dropboxusercontent.com/u/294332/ckb/images/bob48x48.png', 26 | verbose: true, 27 | getSlack() { 28 | return { 29 | rtmClient: new RtmClient(token, { 30 | dataStore: new MemoryDataStore(), 31 | autoReconnect: true, 32 | logLevel: 'error', 33 | }), 34 | webClient: new WebClient(token), 35 | }; 36 | }, 37 | createMessageHandler(id) { 38 | return [ 39 | lolHandler, 40 | ]; 41 | }, 42 | }); 43 | 44 | mixinBotHelpers(bot); 45 | 46 | return bot; 47 | 48 | } 49 | -------------------------------------------------------------------------------- /deploy/ansible/roles/configure/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # Configure the box. This happens after the base initialization, but before 2 | # the project is cloned or built. 3 | 4 | - name: set hostname 5 | hostname: name={{hostname}} 6 | 7 | - name: ensure unattended upgrades are configured 8 | copy: src=10periodic dest=/etc/apt/apt.conf.d/ 9 | 10 | - name: add loopback references to our domain in /etc/hosts 11 | lineinfile: 12 | dest: /etc/hosts 13 | state: present 14 | line: "127.0.0.1 {{hostname}} {{site_fqdn}}" 15 | 16 | - name: disallow password authentication 17 | lineinfile: 18 | dest: /etc/ssh/sshd_config 19 | state: present 20 | regexp: "^PasswordAuthentication" 21 | line: "PasswordAuthentication no" 22 | notify: restart sshd 23 | 24 | - name: disallow challenge response authentication 25 | lineinfile: 26 | dest: /etc/ssh/sshd_config 27 | state: present 28 | regexp: "^ChallengeResponseAuthentication" 29 | line: "ChallengeResponseAuthentication no" 30 | notify: restart sshd 31 | 32 | - name: ensure github.com is a known host 33 | lineinfile: 34 | dest: /etc/ssh/ssh_known_hosts 35 | state: present 36 | create: yes 37 | regexp: "^github\\.com" 38 | line: "{{ lookup('pipe', 'ssh-keyscan -t rsa github.com') }}" 39 | 40 | - name: ensure ssh agent socket environment variable persists when sudoing 41 | lineinfile: 42 | dest: /etc/sudoers 43 | state: present 44 | insertafter: "^Defaults" 45 | line: "Defaults\tenv_keep += \"SSH_AUTH_SOCK\"" 46 | validate: "visudo -cf %s" 47 | 48 | - name: allow passwordless sudo - development only! 49 | lineinfile: 50 | dest: /etc/sudoers 51 | state: present 52 | regexp: "^%sudo" 53 | line: "%sudo\tALL=(ALL:ALL) NOPASSWD:ALL" 54 | validate: "visudo -cf %s" 55 | when: env == "development" 56 | 57 | - include: swap.yml 58 | when: swap_file_path is defined and swap_file_size is defined 59 | -------------------------------------------------------------------------------- /Gruntfile.babel.js: -------------------------------------------------------------------------------- 1 | import {spawn} from 'child_process'; 2 | import {grunt} from './Gruntfile'; 3 | 4 | const eslint = { 5 | src: { 6 | options: { 7 | configFile: '.eslintrc-es2015.yaml', 8 | }, 9 | src: 'src/**/*.js', 10 | }, 11 | gruntfile: { 12 | options: { 13 | configFile: '.eslintrc-es2015.yaml', 14 | }, 15 | src: 'Gruntfile.babel.js', 16 | }, 17 | root: { 18 | options: { 19 | configFile: '.eslintrc-node.yaml', 20 | }, 21 | src: [ 22 | '*.js', 23 | '!Gruntfile.babel.js', 24 | ], 25 | }, 26 | }; 27 | 28 | const watch = { 29 | options: { 30 | spawn: false, 31 | }, 32 | config: { 33 | files: ['.env', 'config.js'], 34 | tasks: ['kill', 'start'], 35 | }, 36 | src: { 37 | files: ['<%= eslint.src.src %>'], 38 | tasks: ['eslint:src', 'kill', 'start'], 39 | }, 40 | gruntfile: { 41 | files: ['<%= eslint.gruntfile.src %>'], 42 | tasks: ['eslint:gruntfile'], 43 | }, 44 | root: { 45 | files: ['<%= eslint.root.src %>'], 46 | tasks: ['eslint:root'], 47 | }, 48 | lint: { 49 | options: { 50 | reload: true, 51 | }, 52 | files: ['.eslintrc*', 'eslint/*'], 53 | tasks: ['eslint'], 54 | }, 55 | // Reload the bot if chatter files change. This makes dev MUCH easier! 56 | chatter: { 57 | files: ['node_modules/chatter/dist/**/*'], 58 | tasks: ['kill', 'start'], 59 | }, 60 | }; 61 | 62 | grunt.initConfig({ 63 | eslint, 64 | watch, 65 | }); 66 | 67 | grunt.registerTask('start', function() { 68 | global._BOT = spawn('node', ['--require', 'babel-register', 'src/index'], {stdio: 'inherit'}); 69 | }); 70 | 71 | grunt.registerTask('kill', function() { 72 | global._BOT.kill('SIGKILL'); 73 | }); 74 | 75 | grunt.registerTask('test', ['eslint']); 76 | grunt.registerTask('default', ['start', 'watch']); 77 | 78 | grunt.loadNpmTasks('grunt-contrib-watch'); 79 | grunt.loadNpmTasks('grunt-eslint'); 80 | -------------------------------------------------------------------------------- /deploy/ansible/roles/deploy/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # Clone the repo and check out the specified "commit" (defaults to master) 2 | # unless it has already been checked out. Specifying "force" will clone and 3 | # build regardless of prior status. When done, symlink the specified commit 4 | # to make it go live, and remove old clones to free up disk space. 5 | 6 | - name: check if specified commit has already been deployed 7 | stat: path={{base_path}}/{{commit}} get_checksum=no get_md5=no 8 | register: commit_dir 9 | 10 | - include: checkout.yml 11 | when: force or not commit_dir.stat.exists 12 | 13 | - name: link sha-named clone to make it live 14 | file: 15 | path={{site_path}} 16 | state=link src={{base_path}}/{{ sha.stdout | default(commit) }} 17 | force=yes 18 | 19 | - name: update last-modification time of sha-named clone 20 | file: path={{base_path}}/{{ sha.stdout | default(commit) }} state=touch 21 | 22 | - name: remove old clones to free up disk space 23 | shell: | 24 | # Find all 40-char-SHA-named child directories and for each directory, print 25 | # out the last-modified timestamp and the SHA. 26 | find . -mindepth 1 -maxdepth 1 -type d \ 27 | -regextype posix-extended -regex './[0-9a-f]{40}' -printf '%T@ %P\n' | 28 | # Sort numerically in ascending order (on the timestamp), remove the 29 | # timestamp from each line (leaving only the SHA), then remove the most 30 | # recent SHAs from the list (leaving only the old SHAs-to-be-removed). 31 | sort -n | cut -d ' ' -f 2 | head -n -{{keep_n_most_recent}} | 32 | # Remove each remaining SHA-named directory and echo the SHA (so the task 33 | # can display whether or not changes were made). 34 | xargs -I % sh -c 'rm -rf "$1"; echo "$1"' -- % 35 | register: remove_result 36 | changed_when: remove_result.stdout != "" 37 | args: 38 | chdir: "{{base_path}}" 39 | when: keep_n_most_recent is defined 40 | 41 | - name: restart service 42 | service: name=bots state=restarted 43 | -------------------------------------------------------------------------------- /src/lib/dialog.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import {normalizeResponse} from 'chatter'; 3 | 4 | export function ask(options = {}) { 5 | const { 6 | header, 7 | headers = header ? [header] : [], 8 | question, 9 | onAnswer, 10 | onExit = () => 'Canceled.', 11 | strExit = 'exit', 12 | fmtPrompt = exit => `_Please answer now, or type *${exit}* to cancel._`, 13 | } = options; 14 | return { 15 | messages: [ 16 | ...headers, 17 | [ 18 | typeof question === 'function' ? question() : question, 19 | fmtPrompt(strExit), 20 | ], 21 | ], 22 | dialog(answer, ...args) { 23 | const exits = Array.isArray(strExit) ? strExit : [strExit]; 24 | const exit = exits.find(s => s.toLowerCase() === answer.toLowerCase()); 25 | if (exit) { 26 | return onExit(exit, ...args); 27 | } 28 | return onAnswer(answer, ...args); 29 | }, 30 | }; 31 | } 32 | 33 | export function choose(options = {}) { 34 | const { 35 | question, 36 | choices, 37 | onAnswer, 38 | fmtIncorrect = answer => `_Sorry, but \`${answer}\` is not a valid response. Please try again._`, 39 | fmtChoice = (k, v) => `[*${k}*] ${v}`, 40 | } = options; 41 | let choiceKeys, choiceMap; 42 | if (Array.isArray(choices)) { 43 | choiceKeys = choices.map((_, i) => i + 1); 44 | choiceMap = choices.reduce((memo, description, i) => { 45 | memo[i + 1] = description; 46 | return memo; 47 | }, {}); 48 | } 49 | else { 50 | choiceKeys = Object.keys(choices); 51 | choiceMap = choices; 52 | } 53 | return ask(Object.assign({}, options, { 54 | question: [ 55 | typeof question === 'function' ? question() : question, 56 | choiceKeys.map(k => fmtChoice(k, choiceMap[k])), 57 | ], 58 | onAnswer(answer, ...args) { 59 | const choice = choiceKeys.find(k => String(k).toLowerCase() === answer.toLowerCase()); 60 | if (choice) { 61 | return onAnswer(choice, ...args); 62 | } 63 | return choose(Object.assign({}, options, { 64 | headers: [fmtIncorrect(answer)], 65 | })); 66 | }, 67 | })); 68 | } 69 | 70 | function nextQuestion([question, ...remain], options, response) { 71 | if (typeof question === 'function') { 72 | question = question(); 73 | } 74 | if (Array.isArray(question)) { 75 | return nextQuestion([...question, ...remain], options, response); 76 | } 77 | else if (!question) { 78 | if (remain.length === 0) { 79 | return response; 80 | } 81 | return nextQuestion(remain, options, response); 82 | } 83 | const _onAnswer = question.onAnswer || options.onAnswer; 84 | const mergedOptions = Object.assign({}, options, question, { 85 | onAnswer(...args) { 86 | return Promise.try(() => _onAnswer(...args)) 87 | .then(resp => nextQuestion(remain, options, resp)); 88 | }, 89 | }); 90 | if (response) { 91 | mergedOptions.headers = normalizeResponse(response); 92 | } 93 | const fn = mergedOptions.choices ? choose : ask; 94 | return fn(mergedOptions); 95 | } 96 | 97 | export function questions(options = {}) { 98 | const qs = options.questions; 99 | options = Object.assign({}, options); 100 | delete options.questions; 101 | return nextQuestion(Array.isArray(qs) ? qs : [qs], options, null); 102 | } 103 | -------------------------------------------------------------------------------- /deploy/ansible/group_vars/all.yml: -------------------------------------------------------------------------------- 1 | ######### 2 | # PROJECT 3 | ######### 4 | 5 | # Certain tasks may operate in a less secure (but more convenient) manner, eg. 6 | # enabling passwordless sudo or generating self-signed ssl certs, when testing 7 | # locally, in Vagrant. But not in production! 8 | env: production 9 | 10 | # This var is referenced by a few other vars, eg. git_repo, hostname, site_fqdn. 11 | project_name: bots 12 | 13 | # This is what you'll see at the bash prompt if/when you ssh into your server. 14 | hostname: "{{project_name}}" 15 | 16 | # This is the fully qualified domain name of your production server. Because 17 | # nginx checks this value against the URL being requested, it must be the same 18 | # as the server's DNS name. This value is overridden for Vagrant and staging 19 | # servers. 20 | site_fqdn: "{{project_name}}.rj3.net" 21 | 22 | ############## 23 | # PROVISIONING 24 | ############## 25 | 26 | # Keys to be added to apt. 27 | apt_keys: 28 | - "https://deb.nodesource.com/gpgkey/nodesource.gpg.key" 29 | 30 | # Ppas to be added to apt. Useful ppas (replace trusty with your Ubuntu 31 | # version codename, if necessary): 32 | # Git latest: ppa:git-core/ppa 33 | # Node.js 4.2.x (LTS): deb https://deb.nodesource.com/node_4.x trusty main 34 | # Node.js 5.x.x: deb https://deb.nodesource.com/node_5.x trusty main 35 | apt_ppas: 36 | - "deb https://deb.nodesource.com/node_4.x trusty main" 37 | - "ppa:git-core/ppa" 38 | 39 | # Any apt packages to install. Apt package versions may be specified like 40 | # - git=2.1.0 41 | apt_packages: 42 | - unattended-upgrades 43 | - git 44 | - nodejs 45 | 46 | ######## 47 | # DEPLOY 48 | ######## 49 | 50 | # Parent directory for cloned repository directories. The clone_path and 51 | # site_path should be children of this directory. 52 | base_path: /mnt 53 | 54 | # Where the production code symlink will exist. 55 | site_path: "{{base_path}}/site" 56 | 57 | # Temporary location where the Git repo will be cloned and the build scripts 58 | # will be run before going live. 59 | clone_path: "{{base_path}}/temp" 60 | 61 | # If defined, only this many of the most recent clone directories (including the 62 | # current specified commit) will be retained. Anything older will be removed, 63 | # once the current clone has been made live. 64 | keep_n_most_recent: 3 65 | 66 | # If these variables are uncommented, add swap space to the machine when the 67 | # configure playbook is run. The swap configuration controlled by this is 68 | # meant to address installation problems on machines with minimal ram (e.g. 69 | # npm bails during install because it runs out of memory) 70 | swap_file_path: /swap 71 | swap_file_size: 2GB 72 | 73 | ################### 74 | # DEPLOY EXTRA VARS 75 | ################### 76 | 77 | # Specify any valid remote (typically a github user) 78 | remote: cowboy 79 | 80 | # Specify any ref (eg. branch, tag, SHA) to be deployed. This ref must be 81 | # pushed to the remote git_repo before it can be deployed. 82 | commit: master 83 | 84 | # Git repo address. 85 | # For private repositories: git@github.com:{{remote}}/{{project_name}}.git 86 | # For public: https://github.com/{{remote}}/{{project_name}} 87 | git_repo: https://github.com/{{remote}}/{{project_name}} 88 | 89 | # Uncomment this if if you are checking out a private repo 90 | ansible_ssh_common_args: -o ForwardAgent=yes 91 | 92 | # Clone and build the specified commit SHA, regardless of prior build status. 93 | force: false 94 | 95 | # Use the local project Git repo instead of the remote git_repo. This option 96 | # only works with the vagrant inventory, and not with staging or production. 97 | local: false 98 | -------------------------------------------------------------------------------- /deploy/run-playbook.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Based on the script here: 4 | # https://github.com/bocoup/deployment-workflow/blob/master/deploy/run-playbook.sh 5 | 6 | bin=ansible-playbook 7 | 8 | function usage() { 9 | cat <