diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..29ee038 --- /dev/null +++ b/.env.sample @@ -0,0 +1,4 @@ +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= +STRIPE_API_PUBLISHABLE_KEY= +STRIPE_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aea4ab7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +.rvmrc +.irbrc +.bundle +log +.env diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..412e568 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--color +--format=documentation + diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..6314100 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,5 @@ +Style/FrozenStringLiteralComment: + Enabled: false + +inherit_from: .rubocop_todo.yml + diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..31adea5 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,57 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2017-08-16 12:45:08 -0400 using RuboCop version 0.49.1. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 1 +Lint/UselessAssignment: + Exclude: + - 'slack-sup/commands/subscription.rb' + +# Offense count: 6 +Metrics/AbcSize: + Max: 36 + +# Offense count: 29 +# Configuration parameters: CountComments, ExcludedMethods. +Metrics/BlockLength: + Max: 71 + +# Offense count: 2 +Metrics/CyclomaticComplexity: + Max: 11 + +# Offense count: 115 +# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. +# URISchemes: http, https +Metrics/LineLength: + Max: 242 + +# Offense count: 5 +# Configuration parameters: CountComments. +Metrics/MethodLength: + Max: 23 + +# Offense count: 2 +Metrics/PerceivedComplexity: + Max: 11 + +# Offense count: 26 +Style/Documentation: + Enabled: false + +# Offense count: 1 +# Configuration parameters: ExpectMatchingDefinition, Regex, IgnoreExecutableScripts, AllowedAcronyms. +# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS +Style/FileName: + Exclude: + - 'slack-sup.rb' + +# Offense count: 3 +Style/MultilineBlockChain: + Exclude: + - 'spec/api/endpoints/credit_cards_endpoint_spec.rb' + - 'spec/api/endpoints/subscriptions_endpoint_spec.rb' diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..005119b --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.4.1 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7bf5205 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,20 @@ +rvm: + - 2.3.1 + +language: ruby + +cache: bundler + +services: + - mongodb + +addons: + firefox: 54.0 + +before_install: + - export DISPLAY=:99.0 + - sh -e /etc/init.d/xvfb start + - wget https://github.com/mozilla/geckodriver/releases/download/v0.18.0/geckodriver-v0.18.0-linux64.tar.gz + - mkdir geckodriver + - tar -xzf geckodriver-v0.18.0-linux64.tar.gz -C geckodriver + - export PATH=$PATH:$PWD/geckodriver diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ab059a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +### Changelog + +* 8/16/2017: Initial public release, Artsy hackathon 2017 - [@dblock](https://github.com/dblock). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..aa4ed90 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing to SlackSup + +This project is work of [many contributors](https://github.com/dblock/slack-sup/graphs/contributors). + +You're encouraged to submit [pull requests](https://github.com/dblock/slack-sup/pulls), [propose features and discuss issues](https://github.com/dblock/slack-sup/issues). + +In the examples below, substitute your Github username for `contributor` in URLs. + +## Fork the Project + +Fork the [project on Github](https://github.com/dblock/slack-sup) and check out your copy. + +``` +git clone https://github.com/contributor/slack-sup.git +cd slack-sup +git remote add upstream https://github.com/dblock/slack-sup.git +``` + +## Create a Topic Branch + +Make sure your fork is up-to-date and create a topic branch for your feature or bug fix. + +``` +git checkout master +git pull upstream master +git checkout -b my-feature-branch +``` + +## Bundle Install and Test + +Ensure that you can build the project and run tests. + +``` +bundle install +bundle exec rake +``` + +## Write Tests + +Try to write a test that reproduces the problem you're trying to fix or describes a feature that you want to build. +Add to [spec](spec). + +We definitely appreciate pull requests that highlight or reproduce a problem, even without a fix. + +## Write Code + +Implement your feature or bug fix. + +Ruby style is enforced with [Rubocop](https://github.com/bbatsov/rubocop). +Run `bundle exec rubocop` and fix any style issues highlighted. + +Make sure that `bundle exec rake` completes without errors. + +## Write Documentation + +Document any external behavior in the [README](README.md). + +## Update Changelog + +Add a line to [CHANGELOG](CHANGELOG.md) under *Next Release*. +Make it look like every other line, including your name and link to your Github account. + +## Commit Changes + +Make sure git knows your name and email address: + +``` +git config --global user.name "Your Name" +git config --global user.email "contributor@example.com" +``` + +Writing good commit logs is important. A commit log should describe what changed and why. + +``` +git add ... +git commit +``` + +## Push + +``` +git push origin my-feature-branch +``` + +## Make a Pull Request + +Go to https://github.com/contributor/slack-sup and select your feature branch. +Click the 'Pull Request' button and fill out the form. Pull requests are usually reviewed within a few days. + +## Rebase + +If you've been working on a change for a while, rebase with upstream/master. + +``` +git fetch upstream +git rebase upstream/master +git push origin my-feature-branch -f +``` + +## Update CHANGELOG Again + +Update the [CHANGELOG](CHANGELOG.md) with the pull request number. A typical entry looks as follows. + +``` +* [#123](https://github.com/dblock/slack-sup/pull/123): Reticulated splines - [@contributor](https://github.com/contributor). +``` + +Amend your previous commit and force push the changes. + +``` +git commit --amend +git push origin my-feature-branch -f +``` + +## Check on Your Pull Request + +Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. + +## Be Patient + +It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! + +## Thank You + +Please do know that we really appreciate and value your time and work. We love you, really. diff --git a/DEBUGGING.md b/DEBUGGING.md new file mode 100644 index 0000000..438304e --- /dev/null +++ b/DEBUGGING.md @@ -0,0 +1,25 @@ +## Debugging + +### Locally + +You can debug your instance of slack-sup with a built-in `script/console`. + +### Silence Mongoid Logger + +If Mongoid logging is annoying you. + +```ruby +Mongoid.logger.level = Logger::INFO +Mongo::Logger.logger.level = Logger::INFO +``` + +### Heroku + +``` +heroku run script/console --app=... + +Running `script/console` attached to terminal... up, run.7593 + +2.2.1 > Team.count +=> 3 +``` diff --git a/DEV.md b/DEV.md new file mode 100644 index 0000000..759d1fd --- /dev/null +++ b/DEV.md @@ -0,0 +1,60 @@ +## Development Environment + +You may want to watch [Your First Slack Bot Service video](http://code.dblock.org/2016/03/11/your-first-slack-bot-service-video.html) first. + +### Prerequisites + +Ensure that you can build the project and run tests. You will need these. + +- [MongoDB](https://docs.mongodb.com/manual/installation/) +- [Firefox](https://www.mozilla.org/firefox/new/) +- [Geckodriver](https://github.com/mozilla/geckodriver), download, `tar vfxz` and move to `/usr/local/bin` +- Ruby 2.3.1 + +``` +bundle install +bundle exec rake +``` + +### Slack Team + +Create a Slack team [here](https://slack.com/create). + +### Slack App + +Create a test app [here](https://api.slack.com/apps). This gives you a client ID and a client secret. + +Under _Features/OAuth & Permissions_, configure the redirect URL to `http://localhost:5000`. + +Add the following Permission Scope. + +* Add a bot user with the username @bot. + +### Slack Keys + +Create a `.env` file. + +``` +SLACK_CLIENT_ID=slack_client_id +SLACK_CLIENT_SECRET=slack_client_secret +``` + +### Stripe Keys + +If you want to test paid features or payment-related functions you need a [Stripe](https://www.stripe.com) account and test keys. Add to `.env` file. + +``` +STRIPE_API_PUBLISHABLE_KEY=pk_test_key +STRIPE_API_KEY=sk_test_key +``` + +### Start the Bot + +``` +$ foreman start + +08:54:07 web.1 | started with pid 32503 +08:54:08 web.1 | I, [2017-08-04T08:54:08.138999 #32503] INFO -- : listening on addr=0.0.0.0:5000 fd=11 +``` + +Navigate to [localhost:5000](http://localhost:5000). diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..0e969fb --- /dev/null +++ b/Gemfile @@ -0,0 +1,36 @@ +source 'http://rubygems.org' + +ruby '2.4.1' + +gem 'mongoid' +gem 'mongoid-scroll' +gem 'newrelic_rpm' +gem 'rack-robotz' +gem 'rack-server-pages' +gem 'slack-ruby-bot-server' +gem 'slack-ruby-client' +gem 'stripe', '~> 1.58.0' + +group :development, :test do + gem 'foreman' + gem 'rake', '~> 10.4' + gem 'rubocop', '0.49.1' +end + +group :development do + gem 'mongoid-shell' +end + +group :test do + gem 'capybara' + gem 'database_cleaner' + gem 'fabrication' + gem 'faker' + gem 'hyperclient' + gem 'rack-test' + gem 'rspec' + gem 'selenium-webdriver' + gem 'stripe-ruby-mock', '~> 2.4.1', require: 'stripe_mock' + gem 'vcr' + gem 'webmock' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..e49defd --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,297 @@ +GEM + remote: http://rubygems.org/ + specs: + activemodel (5.1.3) + activesupport (= 5.1.3) + activesupport (5.1.3) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) + addressable (2.5.1) + public_suffix (~> 2.0, >= 2.0.2) + ast (2.3.0) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) + bson (4.2.2) + builder (3.2.3) + capybara (2.15.1) + addressable + mini_mime (>= 0.1.3) + nokogiri (>= 1.3.3) + rack (>= 1.0.0) + rack-test (>= 0.5.4) + xpath (~> 2.0) + celluloid (0.17.3) + celluloid-essentials + celluloid-extras + celluloid-fsm + celluloid-pool + celluloid-supervision + timers (>= 4.1.1) + celluloid-essentials (0.20.5) + timers (>= 4.1.1) + celluloid-extras (0.20.5) + timers (>= 4.1.1) + celluloid-fsm (0.20.5) + timers (>= 4.1.1) + celluloid-io (0.17.3) + celluloid (>= 0.17.2) + nio4r (>= 1.1) + timers (>= 4.1.1) + celluloid-pool (0.20.5) + timers (>= 4.1.1) + celluloid-supervision (0.20.6) + timers (>= 4.1.1) + childprocess (0.7.1) + ffi (~> 1.0, >= 1.0.11) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) + concurrent-ruby (1.0.5) + crack (0.4.3) + safe_yaml (~> 1.0.0) + dante (0.2.0) + database_cleaner (1.6.1) + declarative (0.0.9) + declarative-option (0.1.0) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) + diff-lcs (1.3) + domain_name (0.5.20170404) + unf (>= 0.0.5, < 1.0.0) + equalizer (0.0.11) + fabrication (2.16.2) + faker (1.8.4) + i18n (~> 0.5) + faraday (0.9.2) + multipart-post (>= 1.2, < 3) + faraday-digestauth (0.2.1) + faraday (~> 0.7) + net-http-digest_auth (~> 1.4) + faraday_hal_middleware (0.0.1) + faraday_middleware (>= 0.9, < 0.10) + faraday_middleware (0.9.2) + faraday (>= 0.7.4, < 0.10) + ffi (1.9.18) + foreman (0.84.0) + thor (~> 0.19.1) + futuroscope (0.1.11) + gli (2.16.1) + grape (1.0.0) + activesupport + builder + mustermann-grape (~> 1.0.0) + rack (>= 1.3.0) + rack-accept + virtus (>= 1.0.0) + grape-roar (0.4.1) + grape + multi_json + roar (~> 1.1.0) + grape-swagger (0.27.3) + grape (>= 0.16.2) + hashdiff (0.3.5) + hashie (3.5.6) + hitimes (1.2.6) + http-cookie (1.0.3) + domain_name (~> 0.5) + hyperclient (0.8.5) + faraday (>= 0.9.0) + faraday-digestauth + faraday_hal_middleware + faraday_middleware + futuroscope + net-http-digest_auth + uri_template + i18n (0.8.6) + ice_nine (0.11.2) + json (2.1.0) + kaminari-core (1.0.1) + kaminari-grape (1.0.1) + grape + kaminari-core (~> 1.0) + kgio (2.11.0) + mime-types (3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0521) + mini_mime (0.1.4) + mini_portile2 (2.2.0) + minitest (5.10.3) + mongo (2.4.3) + bson (>= 4.2.1, < 5.0.0) + mongoid (6.2.1) + activemodel (~> 5.1) + mongo (>= 2.4.1, < 3.0.0) + mongoid-compatibility (0.4.1) + activesupport + mongoid (>= 2.0) + mongoid-scroll (0.3.5) + i18n + mongoid (>= 3.0) + mongoid-compatibility + mongoid-shell (0.4.4) + i18n + mongoid (>= 3.0.0) + mongoid-compatibility + multi_json (1.12.1) + multipart-post (2.0.0) + mustermann (1.0.0) + mustermann-grape (1.0.0) + mustermann (~> 1.0.0) + net-http-digest_auth (1.4.1) + netrc (0.11.0) + newrelic_rpm (4.3.0.335) + nio4r (2.1.0) + nokogiri (1.8.0) + mini_portile2 (~> 2.2.0) + parallel (1.12.0) + parser (2.4.0.0) + ast (~> 2.2) + powerpack (0.1.1) + public_suffix (2.0.5) + rack (2.0.3) + rack-accept (0.4.5) + rack (>= 0.4) + rack-cors (1.0.1) + rack-rewrite (1.5.1) + rack-robotz (0.0.4) + rack + rack-server-pages (0.1.0) + rack + rack-test (0.7.0) + rack (>= 1.0, < 3) + rainbow (2.2.2) + rake + raindrops (0.19.0) + rake (10.5.0) + representable (3.0.4) + declarative (< 0.1.0) + declarative-option (< 0.2.0) + uber (< 0.2.0) + rest-client (2.0.2) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + roar (1.1.0) + representable (~> 3.0.0) + rspec (3.6.0) + rspec-core (~> 3.6.0) + rspec-expectations (~> 3.6.0) + rspec-mocks (~> 3.6.0) + rspec-core (3.6.0) + rspec-support (~> 3.6.0) + rspec-expectations (3.6.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.6.0) + rspec-mocks (3.6.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.6.0) + rspec-support (3.6.0) + rubocop (0.49.1) + parallel (~> 1.10) + parser (>= 2.3.3.1, < 3.0) + powerpack (~> 0.1) + rainbow (>= 1.99.1, < 3.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + ruby-progressbar (1.8.1) + rubyzip (1.2.1) + safe_yaml (1.0.4) + selenium-webdriver (3.5.1) + childprocess (~> 0.5) + rubyzip (~> 1.0) + slack-ruby-bot (0.10.4) + hashie + slack-ruby-client (>= 0.6.0) + slack-ruby-bot-server (0.6.1) + celluloid-io + foreman + grape + grape-roar (>= 0.4.0) + grape-swagger + kaminari-grape + rack-cors + rack-rewrite + rack-server-pages + slack-ruby-bot + unicorn + slack-ruby-client (0.9.0) + activesupport + faraday (>= 0.9) + faraday_middleware + gli + hashie + json + websocket-driver + stripe (1.58.0) + rest-client (>= 1.4, < 4.0) + stripe-ruby-mock (2.4.1) + dante (>= 0.2.0) + multi_json (~> 1.0) + stripe (>= 1.31.0, <= 1.58.0) + thor (0.19.4) + thread_safe (0.3.6) + timers (4.1.2) + hitimes + tzinfo (1.2.3) + thread_safe (~> 0.1) + uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.4) + unicode-display_width (1.3.0) + unicorn (5.3.0) + kgio (~> 2.6) + raindrops (~> 0.7) + uri_template (0.7.0) + vcr (3.0.3) + virtus (1.0.5) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + equalizer (~> 0.0, >= 0.0.9) + webmock (3.0.1) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff + websocket-driver (0.6.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.2) + xpath (2.1.0) + nokogiri (~> 1.3) + +PLATFORMS + ruby + +DEPENDENCIES + capybara + database_cleaner + fabrication + faker + foreman + hyperclient + mongoid + mongoid-scroll + mongoid-shell + newrelic_rpm + rack-robotz + rack-server-pages + rack-test + rake (~> 10.4) + rspec + rubocop (= 0.49.1) + selenium-webdriver + slack-ruby-bot-server + slack-ruby-client + stripe (~> 1.58.0) + stripe-ruby-mock (~> 2.4.1) + vcr + webmock + +RUBY VERSION + ruby 2.4.1p111 + +BUNDLED WITH + 1.15.3 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..efd406b --- /dev/null +++ b/Guardfile @@ -0,0 +1,8 @@ +guard 'bundler' do + watch('Gemfile') +end + +guard 'rack' do + watch('Gemfile.lock') + watch(%r{^(config|app|api)/.*}) +end diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9e4e55c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) 2017 Daniel Doubrovkine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Icons made by [Vectors Market](https://www.flaticon.com/authors/vectors-market) from [Flaticon](http://www.flaticon.com) +is licensed by [Creative Commons CC BY 3.0](http://creativecommons.org/licenses/by/3.0). diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..5784a17 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: bundle exec unicorn -p $PORT diff --git a/README.md b/README.md new file mode 100644 index 0000000..4d52f6c --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +Slack Sup +========= + +[![Build Status](https://travis-ci.org/dblock/slack-sup.svg?branch=master)](https://travis-ci.org/dblock/slack-sup) +[![Dependency Status](https://gemnasium.com/dblock/slack-sup.svg)](https://gemnasium.com/dblock/slack-sup) +[![Code Climate](https://codeclimate.com/github/dblock/slack-sup.svg)](https://codeclimate.com/github/dblock/slack-sup) + +A Sup' bot for Slack. + +### Copyright & License + +Copyright [Daniel Doubrovkine](http://code.dblock.org), 2017 + +[MIT License](LICENSE) diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..04cea0f --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require 'rubygems' +require 'bundler' + +Bundler.setup :default, :development + +import "tasks/#{ENV['RACK_ENV'] || 'development'}.rake" diff --git a/app.json b/app.json new file mode 100644 index 0000000..84f2105 --- /dev/null +++ b/app.json @@ -0,0 +1,9 @@ +{ + "name": "Slack Sup", + "description": "A Slack bot for team sup.", + "respository": "https://github.com/dblock/slack-sup", + "keywords": ["slack", "bots", "sup", "teams"], + "addons": [ + "mongolab" + ] +} diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..0d31739 --- /dev/null +++ b/config.ru @@ -0,0 +1,26 @@ +$LOAD_PATH.unshift(File.dirname(__FILE__)) + +ENV['RACK_ENV'] ||= 'development' + +require 'bundler/setup' +Bundler.require :default, ENV['RACK_ENV'] + +require 'slack-ruby-bot-server' +require 'slack-sup' + +SlackRubyBotServer.configure do |config| + config.server_class = SlackSup::Server +end + +NewRelic::Agent.manual_start + +SlackSup::App.instance.prepare! + +Thread.abort_on_exception = true + +Thread.new do + SlackSup::Service.instance.start_from_database! + SlackSup::App.instance.after_start! +end + +run Api::Middleware.instance diff --git a/config/initializers/array.rb b/config/initializers/array.rb new file mode 100644 index 0000000..541de80 --- /dev/null +++ b/config/initializers/array.rb @@ -0,0 +1,19 @@ +class Array + def and + join_with 'and' + end + + def or + join_with 'or' + end + + private + + def join_with(separator) + if count > 1 + "#{self[0..-2].join(', ')} #{separator} #{self[-1]}" + else + first + end + end +end diff --git a/config/initializers/slack-ruby-bot/hooks/message.rb b/config/initializers/slack-ruby-bot/hooks/message.rb new file mode 100644 index 0000000..1983cb1 --- /dev/null +++ b/config/initializers/slack-ruby-bot/hooks/message.rb @@ -0,0 +1,13 @@ +module SlackRubyBot + module Hooks + class Message + # HACK: order command classes predictably + def command_classes + [ + SlackSup::Commands::Help, + SlackSup::Commands::Subscription + ] + end + end + end +end diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 0000000..3bd9955 --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1 @@ +Stripe.api_key = ENV['STRIPE_API_KEY'] if ENV.key?('STRIPE_API_KEY') diff --git a/config/mongoid.yml b/config/mongoid.yml new file mode 100644 index 0000000..150c7b7 --- /dev/null +++ b/config/mongoid.yml @@ -0,0 +1,27 @@ +development: + clients: + default: + database: slack_sup_development + hosts: + - 127.0.0.1:27017 + options: + raise_not_found_error: false + use_utc: true + +test: + clients: + default: + database: slack_sup_test + hosts: + - 127.0.0.1:27017 + options: + raise_not_found_error: false + use_utc: true + +production: + clients: + default: + uri: <%= ENV['MONGO_URL'] || ENV['MONGOHQ_URI'] || ENV['MONGOLAB_URI'] %> + options: + raise_not_found_error: false + use_utc: true diff --git a/config/newrelic.yml b/config/newrelic.yml new file mode 100644 index 0000000..364789b --- /dev/null +++ b/config/newrelic.yml @@ -0,0 +1,217 @@ +# Here are the settings that are common to all environments +common: &default_settings + # ============================== LICENSE KEY =============================== + + # You must specify the license key associated with your New Relic + # account. This key binds your Agent's data to your account in the + # New Relic service. + license_key: '<%= ENV["NEW_RELIC_LICENSE_KEY"] %>' + + # Application Name Set this to be the name of your application as + # you'd like it show up in New Relic. The service will then auto-map + # instances of your application into an "application" on your + # dashboard page. If you want to map this instance into multiple + # apps, like "AJAX Requests" and "All UI" then specify a semicolon + # separated list of up to three distinct names, or a yaml list. + app_name: <%= ENV["NEW_RELIC_APP_NAME"] || 'SlackSup' %> + + # When "true", the agent collects performance data about your + # application and reports this data to the New Relic service at + # newrelic.com. This global switch is normally overridden for each + # environment below. (formerly called 'enabled') + monitor_mode: true + + # Developer mode should be off in every environment but + # development as it has very high overhead in memory. + developer_mode: false + + # The newrelic agent generates its own log file to keep its logging + # information separate from that of your application. Specify its + # log level here. + log_level: info + + # Optionally set the path to the log file This is expanded from the + # root directory (may be relative or absolute, e.g. 'log/' or + # '/var/log/') The agent will attempt to create this directory if it + # does not exist. + # log_file_path: 'log' + + # Optionally set the name of the log file, defaults to 'newrelic_agent.log' + # log_file_name: 'newrelic_agent.log' + + # The newrelic agent communicates with the service via http by + # default. If you want to communicate via https to increase + # security, then turn on SSL by setting this value to true. Note, + # this will result in increased CPU overhead to perform the + # encryption involved in SSL communication, but this work is done + # asynchronously to the threads that process your application code, + # so it should not impact response times. + ssl: false + + # EXPERIMENTAL: enable verification of the SSL certificate sent by + # the server. This setting has no effect unless SSL is enabled + # above. This may block your application. Only enable it if the data + # you send us needs end-to-end verified certificates. + # + # This means we cannot cache the DNS lookup, so each request to the + # service will perform a lookup. It also means that we cannot + # use a non-blocking lookup, so in a worst case, if you have DNS + # problems, your app may block indefinitely. + # verify_certificate: true + + # Set your application's Apdex threshold value with the 'apdex_t' + # setting, in seconds. The apdex_t value determines the buckets used + # to compute your overall Apdex score. + # Requests that take less than apdex_t seconds to process will be + # classified as Satisfying transactions; more than apdex_t seconds + # as Tolerating transactions; and more than four times the apdex_t + # value as Frustrating transactions. + # For more about the Apdex standard, see + # http://newrelic.com/docs/general/apdex + + apdex_t: 0.5 + + #============================== Browser Monitoring =============================== + # New Relic Real User Monitoring gives you insight into the performance real users are + # experiencing with your website. This is accomplished by measuring the time it takes for + # your users' browsers to download and render your web pages by injecting a small amount + # of JavaScript code into the header and footer of each page. + browser_monitoring: + # By default the agent automatically injects the monitoring JavaScript + # into web pages. Set this attribute to false to turn off this behavior. + auto_instrument: true + + # Proxy settings for connecting to the service. + # + # If a proxy is used, the host setting is required. Other settings + # are optional. Default port is 8080. + # + # proxy_host: hostname + # proxy_port: 8080 + # proxy_user: + # proxy_pass: + + + # Tells transaction tracer and error collector (when enabled) + # whether or not to capture HTTP params. When true, frameworks can + # exclude HTTP parameters from being captured. + # Rails: the RoR filter_parameter_logging excludes parameters + # Java: create a config setting called "ignored_params" and set it to + # a comma separated list of HTTP parameter names. + # ex: ignored_params: credit_card, ssn, password + capture_params: true + + + # Transaction tracer captures deep information about slow + # transactions and sends this to the service once a + # minute. Included in the transaction is the exact call sequence of + # the transactions including any SQL statements issued. + transaction_tracer: + + # Transaction tracer is enabled by default. Set this to false to + # turn it off. This feature is only available at the Professional + # and above product levels. + enabled: true + + # Threshold in seconds for when to collect a transaction + # trace. When the response time of a controller action exceeds + # this threshold, a transaction trace will be recorded and sent to + # the service. Valid values are any float value, or (default) + # "apdex_f", which will use the threshold for an dissatisfying + # Apdex controller action - four times the Apdex T value. + transaction_threshold: apdex_f + + # When transaction tracer is on, SQL statements can optionally be + # recorded. The recorder has three modes, "off" which sends no + # SQL, "raw" which sends the SQL statement in its original form, + # and "obfuscated", which strips out numeric and string literals + record_sql: obfuscated + + # Threshold in seconds for when to collect stack trace for a SQL + # call. In other words, when SQL statements exceed this threshold, + # then capture and send the current stack trace. This is + # helpful for pinpointing where long SQL calls originate from + stack_trace_threshold: 0.500 + + # Determines whether the agent will capture query plans for slow + # SQL queries. Only supported in mysql and postgres. Should be + # set to false when using other adapters. + # explain_enabled: true + + # Threshold for query execution time below which query plans will not + # not be captured. Relevant only when `explain_enabled` is true. + # explain_threshold: 0.5 + + # Error collector captures information about uncaught exceptions and + # sends them to the service for viewing + error_collector: + + # Error collector is enabled by default. Set this to false to turn + # it off. This feature is only available at the Professional and above + # product levels + enabled: true + + # Rails Only - tells error collector whether or not to capture a + # source snippet around the place of the error when errors are View + # related. + capture_source: true + + # To stop specific errors from reporting to New Relic, set this property + # to comma separated values. Default is to ignore routing errors + # which are how 404's get triggered. + # + ignore_errors: ActionController::RoutingError + + # (Advanced) Uncomment this to ensure the cpu and memory samplers + # won't run. Useful when you are using the agent to monitor an + # external resource + # disable_samplers: true + + # If you aren't interested in visibility in these areas, you can + # disable the instrumentation to reduce overhead. + # + # disable_view_instrumentation: true + # disable_activerecord_instrumentation: true + # disable_memcache_instrumentation: true + # disable_dj: true + + # If you're interested in capturing memcache keys as though they + # were SQL uncomment this flag. Note that this does increase + # overhead slightly on every memcached call, and can have security + # implications if your memcached keys are sensitive + # capture_memcache_keys: true + + # Certain types of instrumentation such as GC stats will not work if + # you are running multi-threaded. Please let us know. + # multi_threaded = false + +# Application Environments +# ------------------------------------------ + +development: + <<: *default_settings + # Turn off communication to New Relic service in development mode (also + # 'enabled'). + # NOTE: for initial evaluation purposes, you may want to temporarily + # turn the agent on in development mode. + monitor_mode: false + + # Rails Only - when running in Developer Mode, the New Relic Agent will + # present performance information on the last 100 transactions you have + # executed since starting the mongrel. + # NOTE: There is substantial overhead when running in developer mode. + # Do not use for production or load testing. + developer_mode: true + + # Enable textmate links + # textmate: true + +test: + <<: *default_settings + # It almost never makes sense to turn on the agent when running + # unit, functional or integration tests or the like. + monitor_mode: false + +production: + <<: *default_settings + monitor_mode: true diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a22f4cb Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/help.html b/public/help.html new file mode 100644 index 0000000..fc3628a --- /dev/null +++ b/public/help.html @@ -0,0 +1,15 @@ + + + Sup for Slack: Help + + +

+ +

+

Help

+

+ Want another feature? Fork me on Github, open an issue, tweet at @dblockdotorg, or e-mail dblock[at]dblock[dot]org. +

+ + + diff --git a/public/img/icon.png b/public/img/icon.png new file mode 100644 index 0000000..e4dc01a Binary files /dev/null and b/public/img/icon.png differ diff --git a/public/img/stripe.png b/public/img/stripe.png new file mode 100644 index 0000000..73ce2f3 Binary files /dev/null and b/public/img/stripe.png differ diff --git a/public/index.html.erb b/public/index.html.erb new file mode 100644 index 0000000..1af9bc0 --- /dev/null +++ b/public/index.html.erb @@ -0,0 +1,29 @@ + + + Sup for Slack + <%= partial 'public/partials/_head.html.erb' %> + + + +

+ +

+

+

Sup for Slack

+

+

+

+ Add to Slack +

+

+ + Free 7 day trial, no credit card required. Subscribe for 39.99$/yr after that. + +

+

+ +

+ <%= partial 'public/partials/_footer.html.erb' %> + + + diff --git a/public/partials/_footer.html.erb b/public/partials/_footer.html.erb new file mode 100644 index 0000000..0a405e4 --- /dev/null +++ b/public/partials/_footer.html.erb @@ -0,0 +1,6 @@ +

+ + made by @dblockdotorg, fork me on github, privacy policy, help, hosted on DigitalOcean
+ Questions? Contact dblock[at]dblock[dot]org or DM @playplayio. +
+

diff --git a/public/partials/_head.html.erb b/public/partials/_head.html.erb new file mode 100644 index 0000000..e2adbad --- /dev/null +++ b/public/partials/_head.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/public/privacy.html b/public/privacy.html new file mode 100644 index 0000000..30df272 --- /dev/null +++ b/public/privacy.html @@ -0,0 +1,62 @@ + + + Sup for Slack: Privacy Policy + + +

+ +

+

tl;dr

+ + Sup for Slack does not want to know who you are and tries to collect just enough data to enable our services. + +

1 - Definition and Nature of Personal Data

+ + During your use of the services that can be accessed on the Website http://api-explorer.playplay.io (hereinafter referred to as the "Website"), including our Slack bots (hereinafter referred to as the "Bots"), we do not require you to provide us with any personal data, however we may collect data that indirectly allows us to indentify you. Therefore, the term "personal data" means any data that enables a person to be identified, which corresponds in particular to your family name, first name, photograph, postal address, email address, telephone numbers, date of birth, data relating to your transactions on the Website, detail of your orders and subscriptions, bank card number, messages, as well as any other information about you that you choose to provide us with. + +

2 - Types of Data We Collect

+ + We do not collect personal information from users, but we do collect data that may indirectly identify you. + +

2.1 - Information Collected About Your Team from Slack

+ + We collect the team's Slack ID, Team Name and Domain upon registration. Please see the the team data model for details. + +

2.2 - Information Collected About You from Slack

+ + We do not collect any information about you from Slack. + +

2.3 - Information You Provide to Us

+ + We may collect personal information from you, such as your first and last name, e-mail or Twitter handle if you provide us feedback or contact us. We will collect your name and e-mail address, as well as any other content included in the e-mail, in order to send you a reply. + +

2.4 - Information Collected from Slack Messages

+ + We require you to communicate with Bots through Instant Messaging (Slack). This requires special care on our side, and a more restrictive privacy measures: We do not collect or keep information from incoming conversations, and we do not process messages transmitted, except for messages related to the explicit usage of the service. We do not monitor team channels. + +

2.5 - Information Collected via Technology

+ + Logs. As is true of most websites, we gather certain information automatically and store it in logs. This information includes IP addresses, browser type, Internet service provider ("ISP"), referring/exit pages, operating system, date/time stamp, and clickstream data. We use this information to analyze trends, administer the Site, track users’ movements around the Site, gather demographic information about our user base as a whole, and better tailor our Services to our users’ needs. For example, some of the information may be collected so that when you visit the Site or the Services again, it will recognize you and the information could then be used to serve advertisements and other information appropriate to your interests. Except as noted in this Privacy Policy, we do not link this automatically-collected data to personal information. Cookies. Like many online services, we use cookies to collect information. "Cookies" are small pieces of information that a website sends to your computer’s hard drive while you are viewing the website. We may use both session Cookies (which expire once you close your web browser) and persistent Cookies (which stay on your computer until you delete them) to provide you with a more personal and interactive experience on our Service. This type of information is collected to make the Service more useful to you and to tailor the experience with us to meet your special interests and needs. Traffic analytics. + + We use a number of third party service provides, such as Google Analytics, to help analyze how users use the Service ("Analytics Companies"). These Analytics Companies uses Cookies to collect information such as how often users visit the Service, what features they use on our Services, and what other sites they used prior to coming to the Site. We use the information we get from these Analytics Companies only to improve our Site and Services. These Analytics Companies collect only the IP address assigned to you on the date you visit the Service, rather than your name or other personally identifying information. We do not combine the information generated through the use of our Analytics Companies with your personal information. [NOTE: Please confirm] Although these Analytics Companies may plant a persistent Cookie on your web browser or mobile device to identify you as a unique user the next time you visit the Service, the Cookie cannot be used by anyone but the Analytics Company that placed the applicable Cookie. This Policy does not apply to and we are not responsible for the Cookies used by these Analytics Companies. + +

3 - Information Available Publicly

+ + We do not make any information about you or your team available publicly. + +

4 - Transfer or Sale of Personal Data

+ + We shall not sell, transfer or lease out your personal data to third parties. + +

5 - Safety

+ + We reserve the right, at our sole discretion, to modify this Privacy policy or any portion thereof. Any changes will be effective from the time of publication of the new Privacy policy. Your use of the Website after the changes have been implemented implicitly expresses your acknowledgement and acceptance of the new Privacy policy. Otherwise, and if the new Privacy policy does not suit you, you must no longer use the Services. + +

6 - Modifications

+ + We reserve the right, at our sole discretion, to modify this Privacy policy or any portion thereof. Any changes will be effective from the time of publication of the new Privacy policy. Your use of the Website after the changes have been implemented implicitly expresses your acknowledgement and acceptance of the new Privacy policy. Otherwise, and if the new Privacy policy does not suit you, you must no longer use the Services. + + If you have any questions or concerns or complaints about our Privacy Policy or our data collection or processing practices, or if you want to report any security violations to us, please message @dblockdotorg on Twitter or e-mail dblock[at]dblock[dot]org. + + + diff --git a/public/scripts/jquery-1.7.1.min.js b/public/scripts/jquery-1.7.1.min.js new file mode 100644 index 0000000..198b3ff --- /dev/null +++ b/public/scripts/jquery-1.7.1.min.js @@ -0,0 +1,4 @@ +/*! jQuery v1.7.1 jquery.com | jquery.org/license */ +(function(a,b){function cy(a){return f.isWindow(a)?a:a.nodeType===9?a.defaultView||a.parentWindow:!1}function cv(a){if(!ck[a]){var b=c.body,d=f("<"+a+">").appendTo(b),e=d.css("display");d.remove();if(e==="none"||e===""){cl||(cl=c.createElement("iframe"),cl.frameBorder=cl.width=cl.height=0),b.appendChild(cl);if(!cm||!cl.createElement)cm=(cl.contentWindow||cl.contentDocument).document,cm.write((c.compatMode==="CSS1Compat"?"":"")+""),cm.close();d=cm.createElement(a),cm.body.appendChild(d),e=f.css(d,"display"),b.removeChild(cl)}ck[a]=e}return ck[a]}function cu(a,b){var c={};f.each(cq.concat.apply([],cq.slice(0,b)),function(){c[this]=a});return c}function ct(){cr=b}function cs(){setTimeout(ct,0);return cr=f.now()}function cj(){try{return new a.ActiveXObject("Microsoft.XMLHTTP")}catch(b){}}function ci(){try{return new a.XMLHttpRequest}catch(b){}}function cc(a,c){a.dataFilter&&(c=a.dataFilter(c,a.dataType));var d=a.dataTypes,e={},g,h,i=d.length,j,k=d[0],l,m,n,o,p;for(g=1;g0){if(c!=="border")for(;g=0===c})}function S(a){return!a||!a.parentNode||a.parentNode.nodeType===11}function K(){return!0}function J(){return!1}function n(a,b,c){var d=b+"defer",e=b+"queue",g=b+"mark",h=f._data(a,d);h&&(c==="queue"||!f._data(a,e))&&(c==="mark"||!f._data(a,g))&&setTimeout(function(){!f._data(a,e)&&!f._data(a,g)&&(f.removeData(a,d,!0),h.fire())},0)}function m(a){for(var b in a){if(b==="data"&&f.isEmptyObject(a[b]))continue;if(b!=="toJSON")return!1}return!0}function l(a,c,d){if(d===b&&a.nodeType===1){var e="data-"+c.replace(k,"-$1").toLowerCase();d=a.getAttribute(e);if(typeof d=="string"){try{d=d==="true"?!0:d==="false"?!1:d==="null"?null:f.isNumeric(d)?parseFloat(d):j.test(d)?f.parseJSON(d):d}catch(g){}f.data(a,c,d)}else d=b}return d}function h(a){var b=g[a]={},c,d;a=a.split(/\s+/);for(c=0,d=a.length;c)[^>]*$|#([\w\-]*)$)/,j=/\S/,k=/^\s+/,l=/\s+$/,m=/^<(\w+)\s*\/?>(?:<\/\1>)?$/,n=/^[\],:{}\s]*$/,o=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,p=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,q=/(?:^|:|,)(?:\s*\[)+/g,r=/(webkit)[ \/]([\w.]+)/,s=/(opera)(?:.*version)?[ \/]([\w.]+)/,t=/(msie) ([\w.]+)/,u=/(mozilla)(?:.*? rv:([\w.]+))?/,v=/-([a-z]|[0-9])/ig,w=/^-ms-/,x=function(a,b){return(b+"").toUpperCase()},y=d.userAgent,z,A,B,C=Object.prototype.toString,D=Object.prototype.hasOwnProperty,E=Array.prototype.push,F=Array.prototype.slice,G=String.prototype.trim,H=Array.prototype.indexOf,I={};e.fn=e.prototype={constructor:e,init:function(a,d,f){var g,h,j,k;if(!a)return this;if(a.nodeType){this.context=this[0]=a,this.length=1;return this}if(a==="body"&&!d&&c.body){this.context=c,this[0]=c.body,this.selector=a,this.length=1;return this}if(typeof a=="string"){a.charAt(0)!=="<"||a.charAt(a.length-1)!==">"||a.length<3?g=i.exec(a):g=[null,a,null];if(g&&(g[1]||!d)){if(g[1]){d=d instanceof e?d[0]:d,k=d?d.ownerDocument||d:c,j=m.exec(a),j?e.isPlainObject(d)?(a=[c.createElement(j[1])],e.fn.attr.call(a,d,!0)):a=[k.createElement(j[1])]:(j=e.buildFragment([g[1]],[k]),a=(j.cacheable?e.clone(j.fragment):j.fragment).childNodes);return e.merge(this,a)}h=c.getElementById(g[2]);if(h&&h.parentNode){if(h.id!==g[2])return f.find(a);this.length=1,this[0]=h}this.context=c,this.selector=a;return this}return!d||d.jquery?(d||f).find(a):this.constructor(d).find(a)}if(e.isFunction(a))return f.ready(a);a.selector!==b&&(this.selector=a.selector,this.context=a.context);return e.makeArray(a,this)},selector:"",jquery:"1.7.1",length:0,size:function(){return this.length},toArray:function(){return F.call(this,0)},get:function(a){return a==null?this.toArray():a<0?this[this.length+a]:this[a]},pushStack:function(a,b,c){var d=this.constructor();e.isArray(a)?E.apply(d,a):e.merge(d,a),d.prevObject=this,d.context=this.context,b==="find"?d.selector=this.selector+(this.selector?" ":"")+c:b&&(d.selector=this.selector+"."+b+"("+c+")");return d},each:function(a,b){return e.each(this,a,b)},ready:function(a){e.bindReady(),A.add(a);return this},eq:function(a){a=+a;return a===-1?this.slice(a):this.slice(a,a+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(F.apply(this,arguments),"slice",F.call(arguments).join(","))},map:function(a){return this.pushStack(e.map(this,function(b,c){return a.call(b,c,b)}))},end:function(){return this.prevObject||this.constructor(null)},push:E,sort:[].sort,splice:[].splice},e.fn.init.prototype=e.fn,e.extend=e.fn.extend=function(){var a,c,d,f,g,h,i=arguments[0]||{},j=1,k=arguments.length,l=!1;typeof i=="boolean"&&(l=i,i=arguments[1]||{},j=2),typeof i!="object"&&!e.isFunction(i)&&(i={}),k===j&&(i=this,--j);for(;j0)return;A.fireWith(c,[e]),e.fn.trigger&&e(c).trigger("ready").off("ready")}},bindReady:function(){if(!A){A=e.Callbacks("once memory");if(c.readyState==="complete")return setTimeout(e.ready,1);if(c.addEventListener)c.addEventListener("DOMContentLoaded",B,!1),a.addEventListener("load",e.ready,!1);else if(c.attachEvent){c.attachEvent("onreadystatechange",B),a.attachEvent("onload",e.ready);var b=!1;try{b=a.frameElement==null}catch(d){}c.documentElement.doScroll&&b&&J()}}},isFunction:function(a){return e.type(a)==="function"},isArray:Array.isArray||function(a){return e.type(a)==="array"},isWindow:function(a){return a&&typeof a=="object"&&"setInterval"in a},isNumeric:function(a){return!isNaN(parseFloat(a))&&isFinite(a)},type:function(a){return a==null?String(a):I[C.call(a)]||"object"},isPlainObject:function(a){if(!a||e.type(a)!=="object"||a.nodeType||e.isWindow(a))return!1;try{if(a.constructor&&!D.call(a,"constructor")&&!D.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}var d;for(d in a);return d===b||D.call(a,d)},isEmptyObject:function(a){for(var b in a)return!1;return!0},error:function(a){throw new Error(a)},parseJSON:function(b){if(typeof b!="string"||!b)return null;b=e.trim(b);if(a.JSON&&a.JSON.parse)return a.JSON.parse(b);if(n.test(b.replace(o,"@").replace(p,"]").replace(q,"")))return(new Function("return "+b))();e.error("Invalid JSON: "+b)},parseXML:function(c){var d,f;try{a.DOMParser?(f=new DOMParser,d=f.parseFromString(c,"text/xml")):(d=new ActiveXObject("Microsoft.XMLDOM"),d.async="false",d.loadXML(c))}catch(g){d=b}(!d||!d.documentElement||d.getElementsByTagName("parsererror").length)&&e.error("Invalid XML: "+c);return d},noop:function(){},globalEval:function(b){b&&j.test(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(w,"ms-").replace(v,x)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toUpperCase()===b.toUpperCase()},each:function(a,c,d){var f,g=0,h=a.length,i=h===b||e.isFunction(a);if(d){if(i){for(f in a)if(c.apply(a[f],d)===!1)break}else for(;g0&&a[0]&&a[j-1]||j===0||e.isArray(a));if(k)for(;i1?i.call(arguments,0):b,j.notifyWith(k,e)}}function l(a){return function(c){b[a]=arguments.length>1?i.call(arguments,0):c,--g||j.resolveWith(j,b)}}var b=i.call(arguments,0),c=0,d=b.length,e=Array(d),g=d,h=d,j=d<=1&&a&&f.isFunction(a.promise)?a:f.Deferred(),k=j.promise();if(d>1){for(;c
a",d=q.getElementsByTagName("*"),e=q.getElementsByTagName("a")[0];if(!d||!d.length||!e)return{};g=c.createElement("select"),h=g.appendChild(c.createElement("option")),i=q.getElementsByTagName("input")[0],b={leadingWhitespace:q.firstChild.nodeType===3,tbody:!q.getElementsByTagName("tbody").length,htmlSerialize:!!q.getElementsByTagName("link").length,style:/top/.test(e.getAttribute("style")),hrefNormalized:e.getAttribute("href")==="/a",opacity:/^0.55/.test(e.style.opacity),cssFloat:!!e.style.cssFloat,checkOn:i.value==="on",optSelected:h.selected,getSetAttribute:q.className!=="t",enctype:!!c.createElement("form").enctype,html5Clone:c.createElement("nav").cloneNode(!0).outerHTML!=="<:nav>",submitBubbles:!0,changeBubbles:!0,focusinBubbles:!1,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0},i.checked=!0,b.noCloneChecked=i.cloneNode(!0).checked,g.disabled=!0,b.optDisabled=!h.disabled;try{delete q.test}catch(s){b.deleteExpando=!1}!q.addEventListener&&q.attachEvent&&q.fireEvent&&(q.attachEvent("onclick",function(){b.noCloneEvent=!1}),q.cloneNode(!0).fireEvent("onclick")),i=c.createElement("input"),i.value="t",i.setAttribute("type","radio"),b.radioValue=i.value==="t",i.setAttribute("checked","checked"),q.appendChild(i),k=c.createDocumentFragment(),k.appendChild(q.lastChild),b.checkClone=k.cloneNode(!0).cloneNode(!0).lastChild.checked,b.appendChecked=i.checked,k.removeChild(i),k.appendChild(q),q.innerHTML="",a.getComputedStyle&&(j=c.createElement("div"),j.style.width="0",j.style.marginRight="0",q.style.width="2px",q.appendChild(j),b.reliableMarginRight=(parseInt((a.getComputedStyle(j,null)||{marginRight:0}).marginRight,10)||0)===0);if(q.attachEvent)for(o in{submit:1,change:1,focusin:1})n="on"+o,p=n in q,p||(q.setAttribute(n,"return;"),p=typeof q[n]=="function"),b[o+"Bubbles"]=p;k.removeChild(q),k=g=h=j=q=i=null,f(function(){var a,d,e,g,h,i,j,k,m,n,o,r=c.getElementsByTagName("body")[0];!r||(j=1,k="position:absolute;top:0;left:0;width:1px;height:1px;margin:0;",m="visibility:hidden;border:0;",n="style='"+k+"border:5px solid #000;padding:0;'",o="
"+""+"
",a=c.createElement("div"),a.style.cssText=m+"width:0;height:0;position:static;top:0;margin-top:"+j+"px",r.insertBefore(a,r.firstChild),q=c.createElement("div"),a.appendChild(q),q.innerHTML="
t
",l=q.getElementsByTagName("td"),p=l[0].offsetHeight===0,l[0].style.display="",l[1].style.display="none",b.reliableHiddenOffsets=p&&l[0].offsetHeight===0,q.innerHTML="",q.style.width=q.style.paddingLeft="1px",f.boxModel=b.boxModel=q.offsetWidth===2,typeof q.style.zoom!="undefined"&&(q.style.display="inline",q.style.zoom=1,b.inlineBlockNeedsLayout=q.offsetWidth===2,q.style.display="",q.innerHTML="
",b.shrinkWrapBlocks=q.offsetWidth!==2),q.style.cssText=k+m,q.innerHTML=o,d=q.firstChild,e=d.firstChild,h=d.nextSibling.firstChild.firstChild,i={doesNotAddBorder:e.offsetTop!==5,doesAddBorderForTableAndCells:h.offsetTop===5},e.style.position="fixed",e.style.top="20px",i.fixedPosition=e.offsetTop===20||e.offsetTop===15,e.style.position=e.style.top="",d.style.overflow="hidden",d.style.position="relative",i.subtractsBorderForOverflowNotVisible=e.offsetTop===-5,i.doesNotIncludeMarginInBodyOffset=r.offsetTop!==j,r.removeChild(a),q=a=null,f.extend(b,i))});return b}();var j=/^(?:\{.*\}|\[.*\])$/,k=/([A-Z])/g;f.extend({cache:{},uuid:0,expando:"jQuery"+(f.fn.jquery+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(a){a=a.nodeType?f.cache[a[f.expando]]:a[f.expando];return!!a&&!m(a)},data:function(a,c,d,e){if(!!f.acceptData(a)){var g,h,i,j=f.expando,k=typeof c=="string",l=a.nodeType,m=l?f.cache:a,n=l?a[j]:a[j]&&j,o=c==="events";if((!n||!m[n]||!o&&!e&&!m[n].data)&&k&&d===b)return;n||(l?a[j]=n=++f.uuid:n=j),m[n]||(m[n]={},l||(m[n].toJSON=f.noop));if(typeof c=="object"||typeof c=="function")e?m[n]=f.extend(m[n],c):m[n].data=f.extend(m[n].data,c);g=h=m[n],e||(h.data||(h.data={}),h=h.data),d!==b&&(h[f.camelCase(c)]=d);if(o&&!h[c])return g.events;k?(i=h[c],i==null&&(i=h[f.camelCase(c)])):i=h;return i}},removeData:function(a,b,c){if(!!f.acceptData(a)){var d,e,g,h=f.expando,i=a.nodeType,j=i?f.cache:a,k=i?a[h]:h;if(!j[k])return;if(b){d=c?j[k]:j[k].data;if(d){f.isArray(b)||(b in d?b=[b]:(b=f.camelCase(b),b in d?b=[b]:b=b.split(" ")));for(e=0,g=b.length;e-1)return!0;return!1},val:function(a){var c,d,e,g=this[0];{if(!!arguments.length){e=f.isFunction(a);return this.each(function(d){var g=f(this),h;if(this.nodeType===1){e?h=a.call(this,d,g.val()):h=a,h==null?h="":typeof h=="number"?h+="":f.isArray(h)&&(h=f.map(h,function(a){return a==null?"":a+""})),c=f.valHooks[this.nodeName.toLowerCase()]||f.valHooks[this.type];if(!c||!("set"in c)||c.set(this,h,"value")===b)this.value=h}})}if(g){c=f.valHooks[g.nodeName.toLowerCase()]||f.valHooks[g.type];if(c&&"get"in c&&(d=c.get(g,"value"))!==b)return d;d=g.value;return typeof d=="string"?d.replace(q,""):d==null?"":d}}}}),f.extend({valHooks:{option:{get:function(a){var b=a.attributes.value;return!b||b.specified?a.value:a.text}},select:{get:function(a){var b,c,d,e,g=a.selectedIndex,h=[],i=a.options,j=a.type==="select-one";if(g<0)return null;c=j?g:0,d=j?g+1:i.length;for(;c=0}),c.length||(a.selectedIndex=-1);return c}}},attrFn:{val:!0,css:!0,html:!0,text:!0,data:!0,width:!0,height:!0,offset:!0},attr:function(a,c,d,e){var g,h,i,j=a.nodeType;if(!!a&&j!==3&&j!==8&&j!==2){if(e&&c in f.attrFn)return f(a)[c](d);if(typeof a.getAttribute=="undefined")return f.prop(a,c,d);i=j!==1||!f.isXMLDoc(a),i&&(c=c.toLowerCase(),h=f.attrHooks[c]||(u.test(c)?x:w));if(d!==b){if(d===null){f.removeAttr(a,c);return}if(h&&"set"in h&&i&&(g=h.set(a,d,c))!==b)return g;a.setAttribute(c,""+d);return d}if(h&&"get"in h&&i&&(g=h.get(a,c))!==null)return g;g=a.getAttribute(c);return g===null?b:g}},removeAttr:function(a,b){var c,d,e,g,h=0;if(b&&a.nodeType===1){d=b.toLowerCase().split(p),g=d.length;for(;h=0}})});var z=/^(?:textarea|input|select)$/i,A=/^([^\.]*)?(?:\.(.+))?$/,B=/\bhover(\.\S+)?\b/,C=/^key/,D=/^(?:mouse|contextmenu)|click/,E=/^(?:focusinfocus|focusoutblur)$/,F=/^(\w*)(?:#([\w\-]+))?(?:\.([\w\-]+))?$/,G=function(a){var b=F.exec(a);b&&(b[1]=(b[1]||"").toLowerCase(),b[3]=b[3]&&new RegExp("(?:^|\\s)"+b[3]+"(?:\\s|$)"));return b},H=function(a,b){var c=a.attributes||{};return(!b[1]||a.nodeName.toLowerCase()===b[1])&&(!b[2]||(c.id||{}).value===b[2])&&(!b[3]||b[3].test((c["class"]||{}).value))},I=function(a){return f.event.special.hover?a:a.replace(B,"mouseenter$1 mouseleave$1")}; +f.event={add:function(a,c,d,e,g){var h,i,j,k,l,m,n,o,p,q,r,s;if(!(a.nodeType===3||a.nodeType===8||!c||!d||!(h=f._data(a)))){d.handler&&(p=d,d=p.handler),d.guid||(d.guid=f.guid++),j=h.events,j||(h.events=j={}),i=h.handle,i||(h.handle=i=function(a){return typeof f!="undefined"&&(!a||f.event.triggered!==a.type)?f.event.dispatch.apply(i.elem,arguments):b},i.elem=a),c=f.trim(I(c)).split(" ");for(k=0;k=0&&(h=h.slice(0,-1),k=!0),h.indexOf(".")>=0&&(i=h.split("."),h=i.shift(),i.sort());if((!e||f.event.customEvent[h])&&!f.event.global[h])return;c=typeof c=="object"?c[f.expando]?c:new f.Event(h,c):new f.Event(h),c.type=h,c.isTrigger=!0,c.exclusive=k,c.namespace=i.join("."),c.namespace_re=c.namespace?new RegExp("(^|\\.)"+i.join("\\.(?:.*\\.)?")+"(\\.|$)"):null,o=h.indexOf(":")<0?"on"+h:"";if(!e){j=f.cache;for(l in j)j[l].events&&j[l].events[h]&&f.event.trigger(c,d,j[l].handle.elem,!0);return}c.result=b,c.target||(c.target=e),d=d!=null?f.makeArray(d):[],d.unshift(c),p=f.event.special[h]||{};if(p.trigger&&p.trigger.apply(e,d)===!1)return;r=[[e,p.bindType||h]];if(!g&&!p.noBubble&&!f.isWindow(e)){s=p.delegateType||h,m=E.test(s+h)?e:e.parentNode,n=null;for(;m;m=m.parentNode)r.push([m,s]),n=m;n&&n===e.ownerDocument&&r.push([n.defaultView||n.parentWindow||a,s])}for(l=0;le&&i.push({elem:this,matches:d.slice(e)});for(j=0;j0?this.on(b,null,a,c):this.trigger(b)},f.attrFn&&(f.attrFn[b]=!0),C.test(b)&&(f.event.fixHooks[b]=f.event.keyHooks),D.test(b)&&(f.event.fixHooks[b]=f.event.mouseHooks)}),function(){function x(a,b,c,e,f,g){for(var h=0,i=e.length;h0){k=j;break}}j=j[a]}e[h]=k}}}function w(a,b,c,e,f,g){for(var h=0,i=e.length;h+~,(\[\\]+)+|[>+~])(\s*,\s*)?((?:.|\r|\n)*)/g,d="sizcache"+(Math.random()+"").replace(".",""),e=0,g=Object.prototype.toString,h=!1,i=!0,j=/\\/g,k=/\r\n/g,l=/\W/;[0,0].sort(function(){i=!1;return 0});var m=function(b,d,e,f){e=e||[],d=d||c;var h=d;if(d.nodeType!==1&&d.nodeType!==9)return[];if(!b||typeof b!="string")return e;var i,j,k,l,n,q,r,t,u=!0,v=m.isXML(d),w=[],x=b;do{a.exec(""),i=a.exec(x);if(i){x=i[3],w.push(i[1]);if(i[2]){l=i[3];break}}}while(i);if(w.length>1&&p.exec(b))if(w.length===2&&o.relative[w[0]])j=y(w[0]+w[1],d,f);else{j=o.relative[w[0]]?[d]:m(w.shift(),d);while(w.length)b=w.shift(),o.relative[b]&&(b+=w.shift()),j=y(b,j,f)}else{!f&&w.length>1&&d.nodeType===9&&!v&&o.match.ID.test(w[0])&&!o.match.ID.test(w[w.length-1])&&(n=m.find(w.shift(),d,v),d=n.expr?m.filter(n.expr,n.set)[0]:n.set[0]);if(d){n=f?{expr:w.pop(),set:s(f)}:m.find(w.pop(),w.length===1&&(w[0]==="~"||w[0]==="+")&&d.parentNode?d.parentNode:d,v),j=n.expr?m.filter(n.expr,n.set):n.set,w.length>0?k=s(j):u=!1;while(w.length)q=w.pop(),r=q,o.relative[q]?r=w.pop():q="",r==null&&(r=d),o.relative[q](k,r,v)}else k=w=[]}k||(k=j),k||m.error(q||b);if(g.call(k)==="[object Array]")if(!u)e.push.apply(e,k);else if(d&&d.nodeType===1)for(t=0;k[t]!=null;t++)k[t]&&(k[t]===!0||k[t].nodeType===1&&m.contains(d,k[t]))&&e.push(j[t]);else for(t=0;k[t]!=null;t++)k[t]&&k[t].nodeType===1&&e.push(j[t]);else s(k,e);l&&(m(l,h,e,f),m.uniqueSort(e));return e};m.uniqueSort=function(a){if(u){h=i,a.sort(u);if(h)for(var b=1;b0},m.find=function(a,b,c){var d,e,f,g,h,i;if(!a)return[];for(e=0,f=o.order.length;e":function(a,b){var c,d=typeof b=="string",e=0,f=a.length;if(d&&!l.test(b)){b=b.toLowerCase();for(;e=0)?c||d.push(h):c&&(b[g]=!1));return!1},ID:function(a){return a[1].replace(j,"")},TAG:function(a,b){return a[1].replace(j,"").toLowerCase()},CHILD:function(a){if(a[1]==="nth"){a[2]||m.error(a[0]),a[2]=a[2].replace(/^\+|\s*/g,"");var b=/(-?)(\d*)(?:n([+\-]?\d*))?/.exec(a[2]==="even"&&"2n"||a[2]==="odd"&&"2n+1"||!/\D/.test(a[2])&&"0n+"+a[2]||a[2]);a[2]=b[1]+(b[2]||1)-0,a[3]=b[3]-0}else a[2]&&m.error(a[0]);a[0]=e++;return a},ATTR:function(a,b,c,d,e,f){var g=a[1]=a[1].replace(j,"");!f&&o.attrMap[g]&&(a[1]=o.attrMap[g]),a[4]=(a[4]||a[5]||"").replace(j,""),a[2]==="~="&&(a[4]=" "+a[4]+" ");return a},PSEUDO:function(b,c,d,e,f){if(b[1]==="not")if((a.exec(b[3])||"").length>1||/^\w/.test(b[3]))b[3]=m(b[3],null,null,c);else{var g=m.filter(b[3],c,d,!0^f);d||e.push.apply(e,g);return!1}else if(o.match.POS.test(b[0])||o.match.CHILD.test(b[0]))return!0;return b},POS:function(a){a.unshift(!0);return a}},filters:{enabled:function(a){return a.disabled===!1&&a.type!=="hidden"},disabled:function(a){return a.disabled===!0},checked:function(a){return a.checked===!0},selected:function(a){a.parentNode&&a.parentNode.selectedIndex;return a.selected===!0},parent:function(a){return!!a.firstChild},empty:function(a){return!a.firstChild},has:function(a,b,c){return!!m(c[3],a).length},header:function(a){return/h\d/i.test(a.nodeName)},text:function(a){var b=a.getAttribute("type"),c=a.type;return a.nodeName.toLowerCase()==="input"&&"text"===c&&(b===c||b===null)},radio:function(a){return a.nodeName.toLowerCase()==="input"&&"radio"===a.type},checkbox:function(a){return a.nodeName.toLowerCase()==="input"&&"checkbox"===a.type},file:function(a){return a.nodeName.toLowerCase()==="input"&&"file"===a.type},password:function(a){return a.nodeName.toLowerCase()==="input"&&"password"===a.type},submit:function(a){var b=a.nodeName.toLowerCase();return(b==="input"||b==="button")&&"submit"===a.type},image:function(a){return a.nodeName.toLowerCase()==="input"&&"image"===a.type},reset:function(a){var b=a.nodeName.toLowerCase();return(b==="input"||b==="button")&&"reset"===a.type},button:function(a){var b=a.nodeName.toLowerCase();return b==="input"&&"button"===a.type||b==="button"},input:function(a){return/input|select|textarea|button/i.test(a.nodeName)},focus:function(a){return a===a.ownerDocument.activeElement}},setFilters:{first:function(a,b){return b===0},last:function(a,b,c,d){return b===d.length-1},even:function(a,b){return b%2===0},odd:function(a,b){return b%2===1},lt:function(a,b,c){return bc[3]-0},nth:function(a,b,c){return c[3]-0===b},eq:function(a,b,c){return c[3]-0===b}},filter:{PSEUDO:function(a,b,c,d){var e=b[1],f=o.filters[e];if(f)return f(a,c,b,d);if(e==="contains")return(a.textContent||a.innerText||n([a])||"").indexOf(b[3])>=0;if(e==="not"){var g=b[3];for(var h=0,i=g.length;h=0}},ID:function(a,b){return a.nodeType===1&&a.getAttribute("id")===b},TAG:function(a,b){return b==="*"&&a.nodeType===1||!!a.nodeName&&a.nodeName.toLowerCase()===b},CLASS:function(a,b){return(" "+(a.className||a.getAttribute("class"))+" ").indexOf(b)>-1},ATTR:function(a,b){var c=b[1],d=m.attr?m.attr(a,c):o.attrHandle[c]?o.attrHandle[c](a):a[c]!=null?a[c]:a.getAttribute(c),e=d+"",f=b[2],g=b[4];return d==null?f==="!=":!f&&m.attr?d!=null:f==="="?e===g:f==="*="?e.indexOf(g)>=0:f==="~="?(" "+e+" ").indexOf(g)>=0:g?f==="!="?e!==g:f==="^="?e.indexOf(g)===0:f==="$="?e.substr(e.length-g.length)===g:f==="|="?e===g||e.substr(0,g.length+1)===g+"-":!1:e&&d!==!1},POS:function(a,b,c,d){var e=b[2],f=o.setFilters[e];if(f)return f(a,c,b,d)}}},p=o.match.POS,q=function(a,b){return"\\"+(b-0+1)};for(var r in o.match)o.match[r]=new RegExp(o.match[r].source+/(?![^\[]*\])(?![^\(]*\))/.source),o.leftMatch[r]=new RegExp(/(^(?:.|\r|\n)*?)/.source+o.match[r].source.replace(/\\(\d+)/g,q));var s=function(a,b){a=Array.prototype.slice.call(a,0);if(b){b.push.apply(b,a);return b}return a};try{Array.prototype.slice.call(c.documentElement.childNodes,0)[0].nodeType}catch(t){s=function(a,b){var c=0,d=b||[];if(g.call(a)==="[object Array]")Array.prototype.push.apply(d,a);else if(typeof a.length=="number")for(var e=a.length;c",e.insertBefore(a,e.firstChild),c.getElementById(d)&&(o.find.ID=function(a,c,d){if(typeof c.getElementById!="undefined"&&!d){var e=c.getElementById(a[1]);return e?e.id===a[1]||typeof e.getAttributeNode!="undefined"&&e.getAttributeNode("id").nodeValue===a[1]?[e]:b:[]}},o.filter.ID=function(a,b){var c=typeof a.getAttributeNode!="undefined"&&a.getAttributeNode("id");return a.nodeType===1&&c&&c.nodeValue===b}),e.removeChild(a),e=a=null}(),function(){var a=c.createElement("div");a.appendChild(c.createComment("")),a.getElementsByTagName("*").length>0&&(o.find.TAG=function(a,b){var c=b.getElementsByTagName(a[1]);if(a[1]==="*"){var d=[];for(var e=0;c[e];e++)c[e].nodeType===1&&d.push(c[e]);c=d}return c}),a.innerHTML="",a.firstChild&&typeof a.firstChild.getAttribute!="undefined"&&a.firstChild.getAttribute("href")!=="#"&&(o.attrHandle.href=function(a){return a.getAttribute("href",2)}),a=null}(),c.querySelectorAll&&function(){var a=m,b=c.createElement("div"),d="__sizzle__";b.innerHTML="

";if(!b.querySelectorAll||b.querySelectorAll(".TEST").length!==0){m=function(b,e,f,g){e=e||c;if(!g&&!m.isXML(e)){var h=/^(\w+$)|^\.([\w\-]+$)|^#([\w\-]+$)/.exec(b);if(h&&(e.nodeType===1||e.nodeType===9)){if(h[1])return s(e.getElementsByTagName(b),f);if(h[2]&&o.find.CLASS&&e.getElementsByClassName)return s(e.getElementsByClassName(h[2]),f)}if(e.nodeType===9){if(b==="body"&&e.body)return s([e.body],f);if(h&&h[3]){var i=e.getElementById(h[3]);if(!i||!i.parentNode)return s([],f);if(i.id===h[3])return s([i],f)}try{return s(e.querySelectorAll(b),f)}catch(j){}}else if(e.nodeType===1&&e.nodeName.toLowerCase()!=="object"){var k=e,l=e.getAttribute("id"),n=l||d,p=e.parentNode,q=/^\s*[+~]/.test(b);l?n=n.replace(/'/g,"\\$&"):e.setAttribute("id",n),q&&p&&(e=e.parentNode);try{if(!q||p)return s(e.querySelectorAll("[id='"+n+"'] "+b),f)}catch(r){}finally{l||k.removeAttribute("id")}}}return a(b,e,f,g)};for(var e in a)m[e]=a[e];b=null}}(),function(){var a=c.documentElement,b=a.matchesSelector||a.mozMatchesSelector||a.webkitMatchesSelector||a.msMatchesSelector;if(b){var d=!b.call(c.createElement("div"),"div"),e=!1;try{b.call(c.documentElement,"[test!='']:sizzle")}catch(f){e=!0}m.matchesSelector=function(a,c){c=c.replace(/\=\s*([^'"\]]*)\s*\]/g,"='$1']");if(!m.isXML(a))try{if(e||!o.match.PSEUDO.test(c)&&!/!=/.test(c)){var f=b.call(a,c);if(f||!d||a.document&&a.document.nodeType!==11)return f}}catch(g){}return m(c,null,null,[a]).length>0}}}(),function(){var a=c.createElement("div");a.innerHTML="
";if(!!a.getElementsByClassName&&a.getElementsByClassName("e").length!==0){a.lastChild.className="e";if(a.getElementsByClassName("e").length===1)return;o.order.splice(1,0,"CLASS"),o.find.CLASS=function(a,b,c){if(typeof b.getElementsByClassName!="undefined"&&!c)return b.getElementsByClassName(a[1])},a=null}}(),c.documentElement.contains?m.contains=function(a,b){return a!==b&&(a.contains?a.contains(b):!0)}:c.documentElement.compareDocumentPosition?m.contains=function(a,b){return!!(a.compareDocumentPosition(b)&16)}:m.contains=function(){return!1},m.isXML=function(a){var b=(a?a.ownerDocument||a:0).documentElement;return b?b.nodeName!=="HTML":!1};var y=function(a,b,c){var d,e=[],f="",g=b.nodeType?[b]:b;while(d=o.match.PSEUDO.exec(a))f+=d[0],a=a.replace(o.match.PSEUDO,"");a=o.relative[a]?a+"*":a;for(var h=0,i=g.length;h0)for(h=g;h=0:f.filter(a,this).length>0:this.filter(a).length>0)},closest:function(a,b){var c=[],d,e,g=this[0];if(f.isArray(a)){var h=1;while(g&&g.ownerDocument&&g!==b){for(d=0;d-1:f.find.matchesSelector(g,a)){c.push(g);break}g=g.parentNode;if(!g||!g.ownerDocument||g===b||g.nodeType===11)break}}c=c.length>1?f.unique(c):c;return this.pushStack(c,"closest",a)},index:function(a){if(!a)return this[0]&&this[0].parentNode?this.prevAll().length:-1;if(typeof a=="string")return f.inArray(this[0],f(a));return f.inArray(a.jquery?a[0]:a,this)},add:function(a,b){var c=typeof a=="string"?f(a,b):f.makeArray(a&&a.nodeType?[a]:a),d=f.merge(this.get(),c);return this.pushStack(S(c[0])||S(d[0])?d:f.unique(d))},andSelf:function(){return this.add(this.prevObject)}}),f.each({parent:function(a){var b=a.parentNode;return b&&b.nodeType!==11?b:null},parents:function(a){return f.dir(a,"parentNode")},parentsUntil:function(a,b,c){return f.dir(a,"parentNode",c)},next:function(a){return f.nth(a,2,"nextSibling")},prev:function(a){return f.nth(a,2,"previousSibling")},nextAll:function(a){return f.dir(a,"nextSibling")},prevAll:function(a){return f.dir(a,"previousSibling")},nextUntil:function(a,b,c){return f.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return f.dir(a,"previousSibling",c)},siblings:function(a){return f.sibling(a.parentNode.firstChild,a)},children:function(a){return f.sibling(a.firstChild)},contents:function(a){return f.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:f.makeArray(a.childNodes)}},function(a,b){f.fn[a]=function(c,d){var e=f.map(this,b,c);L.test(a)||(d=c),d&&typeof d=="string"&&(e=f.filter(d,e)),e=this.length>1&&!R[a]?f.unique(e):e,(this.length>1||N.test(d))&&M.test(a)&&(e=e.reverse());return this.pushStack(e,a,P.call(arguments).join(","))}}),f.extend({filter:function(a,b,c){c&&(a=":not("+a+")");return b.length===1?f.find.matchesSelector(b[0],a)?[b[0]]:[]:f.find.matches(a,b)},dir:function(a,c,d){var e=[],g=a[c];while(g&&g.nodeType!==9&&(d===b||g.nodeType!==1||!f(g).is(d)))g.nodeType===1&&e.push(g),g=g[c];return e},nth:function(a,b,c,d){b=b||1;var e=0;for(;a;a=a[c])if(a.nodeType===1&&++e===b)break;return a},sibling:function(a,b){var c=[];for(;a;a=a.nextSibling)a.nodeType===1&&a!==b&&c.push(a);return c}});var V="abbr|article|aside|audio|canvas|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",W=/ jQuery\d+="(?:\d+|null)"/g,X=/^\s+/,Y=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/ig,Z=/<([\w:]+)/,$=/",""],legend:[1,"
","
"],thead:[1,"","
"],tr:[2,"","
"],td:[3,"","
"],col:[2,"","
"],area:[1,"",""],_default:[0,"",""]},bh=U(c);bg.optgroup=bg.option,bg.tbody=bg.tfoot=bg.colgroup=bg.caption=bg.thead,bg.th=bg.td,f.support.htmlSerialize||(bg._default=[1,"div
","
"]),f.fn.extend({text:function(a){if(f.isFunction(a))return this.each(function(b){var c=f(this);c.text(a.call(this,b,c.text()))});if(typeof a!="object"&&a!==b)return this.empty().append((this[0]&&this[0].ownerDocument||c).createTextNode(a));return f.text(this)},wrapAll:function(a){if(f.isFunction(a))return this.each(function(b){f(this).wrapAll(a.call(this,b))});if(this[0]){var b=f(a,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstChild&&a.firstChild.nodeType===1)a=a.firstChild;return a}).append(this)}return this},wrapInner:function(a){if(f.isFunction(a))return this.each(function(b){f(this).wrapInner(a.call(this,b))});return this.each(function(){var b=f(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=f.isFunction(a);return this.each(function(c){f(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(){return this.parent().each(function(){f.nodeName(this,"body")||f(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(a){this.nodeType===1&&this.appendChild(a)})},prepend:function(){return this.domManip(arguments,!0,function(a){this.nodeType===1&&this.insertBefore(a,this.firstChild)})},before:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,!1,function(a){this.parentNode.insertBefore(a,this)});if(arguments.length){var a=f.clean(arguments);a.push.apply(a,this.toArray());return this.pushStack(a,"before",arguments)}},after:function(){if(this[0]&&this[0].parentNode)return this.domManip(arguments,!1,function(a){this.parentNode.insertBefore(a,this.nextSibling)});if(arguments.length){var a=this.pushStack(this,"after",arguments);a.push.apply(a,f.clean(arguments));return a}},remove:function(a,b){for(var c=0,d;(d=this[c])!=null;c++)if(!a||f.filter(a,[d]).length)!b&&d.nodeType===1&&(f.cleanData(d.getElementsByTagName("*")),f.cleanData([d])),d.parentNode&&d.parentNode.removeChild(d);return this},empty:function() +{for(var a=0,b;(b=this[a])!=null;a++){b.nodeType===1&&f.cleanData(b.getElementsByTagName("*"));while(b.firstChild)b.removeChild(b.firstChild)}return this},clone:function(a,b){a=a==null?!1:a,b=b==null?a:b;return this.map(function(){return f.clone(this,a,b)})},html:function(a){if(a===b)return this[0]&&this[0].nodeType===1?this[0].innerHTML.replace(W,""):null;if(typeof a=="string"&&!ba.test(a)&&(f.support.leadingWhitespace||!X.test(a))&&!bg[(Z.exec(a)||["",""])[1].toLowerCase()]){a=a.replace(Y,"<$1>");try{for(var c=0,d=this.length;c1&&l0?this.clone(!0):this).get();f(e[h])[b](j),d=d.concat(j)}return this.pushStack(d,a,e.selector)}}),f.extend({clone:function(a,b,c){var d,e,g,h=f.support.html5Clone||!bc.test("<"+a.nodeName)?a.cloneNode(!0):bo(a);if((!f.support.noCloneEvent||!f.support.noCloneChecked)&&(a.nodeType===1||a.nodeType===11)&&!f.isXMLDoc(a)){bk(a,h),d=bl(a),e=bl(h);for(g=0;d[g];++g)e[g]&&bk(d[g],e[g])}if(b){bj(a,h);if(c){d=bl(a),e=bl(h);for(g=0;d[g];++g)bj(d[g],e[g])}}d=e=null;return h},clean:function(a,b,d,e){var g;b=b||c,typeof b.createElement=="undefined"&&(b=b.ownerDocument||b[0]&&b[0].ownerDocument||c);var h=[],i;for(var j=0,k;(k=a[j])!=null;j++){typeof k=="number"&&(k+="");if(!k)continue;if(typeof k=="string")if(!_.test(k))k=b.createTextNode(k);else{k=k.replace(Y,"<$1>");var l=(Z.exec(k)||["",""])[1].toLowerCase(),m=bg[l]||bg._default,n=m[0],o=b.createElement("div");b===c?bh.appendChild(o):U(b).appendChild(o),o.innerHTML=m[1]+k+m[2];while(n--)o=o.lastChild;if(!f.support.tbody){var p=$.test(k),q=l==="table"&&!p?o.firstChild&&o.firstChild.childNodes:m[1]===""&&!p?o.childNodes:[];for(i=q.length-1;i>=0;--i)f.nodeName(q[i],"tbody")&&!q[i].childNodes.length&&q[i].parentNode.removeChild(q[i])}!f.support.leadingWhitespace&&X.test(k)&&o.insertBefore(b.createTextNode(X.exec(k)[0]),o.firstChild),k=o.childNodes}var r;if(!f.support.appendChecked)if(k[0]&&typeof (r=k.length)=="number")for(i=0;i=0)return b+"px"}}}),f.support.opacity||(f.cssHooks.opacity={get:function(a,b){return br.test((b&&a.currentStyle?a.currentStyle.filter:a.style.filter)||"")?parseFloat(RegExp.$1)/100+"":b?"1":""},set:function(a,b){var c=a.style,d=a.currentStyle,e=f.isNumeric(b)?"alpha(opacity="+b*100+")":"",g=d&&d.filter||c.filter||"";c.zoom=1;if(b>=1&&f.trim(g.replace(bq,""))===""){c.removeAttribute("filter");if(d&&!d.filter)return}c.filter=bq.test(g)?g.replace(bq,e):g+" "+e}}),f(function(){f.support.reliableMarginRight||(f.cssHooks.marginRight={get:function(a,b){var c;f.swap(a,{display:"inline-block"},function(){b?c=bz(a,"margin-right","marginRight"):c=a.style.marginRight});return c}})}),c.defaultView&&c.defaultView.getComputedStyle&&(bA=function(a,b){var c,d,e;b=b.replace(bs,"-$1").toLowerCase(),(d=a.ownerDocument.defaultView)&&(e=d.getComputedStyle(a,null))&&(c=e.getPropertyValue(b),c===""&&!f.contains(a.ownerDocument.documentElement,a)&&(c=f.style(a,b)));return c}),c.documentElement.currentStyle&&(bB=function(a,b){var c,d,e,f=a.currentStyle&&a.currentStyle[b],g=a.style;f===null&&g&&(e=g[b])&&(f=e),!bt.test(f)&&bu.test(f)&&(c=g.left,d=a.runtimeStyle&&a.runtimeStyle.left,d&&(a.runtimeStyle.left=a.currentStyle.left),g.left=b==="fontSize"?"1em":f||0,f=g.pixelLeft+"px",g.left=c,d&&(a.runtimeStyle.left=d));return f===""?"auto":f}),bz=bA||bB,f.expr&&f.expr.filters&&(f.expr.filters.hidden=function(a){var b=a.offsetWidth,c=a.offsetHeight;return b===0&&c===0||!f.support.reliableHiddenOffsets&&(a.style&&a.style.display||f.css(a,"display"))==="none"},f.expr.filters.visible=function(a){return!f.expr.filters.hidden(a)});var bD=/%20/g,bE=/\[\]$/,bF=/\r?\n/g,bG=/#.*$/,bH=/^(.*?):[ \t]*([^\r\n]*)\r?$/mg,bI=/^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i,bJ=/^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/,bK=/^(?:GET|HEAD)$/,bL=/^\/\//,bM=/\?/,bN=/)<[^<]*)*<\/script>/gi,bO=/^(?:select|textarea)/i,bP=/\s+/,bQ=/([?&])_=[^&]*/,bR=/^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+))?)?/,bS=f.fn.load,bT={},bU={},bV,bW,bX=["*/"]+["*"];try{bV=e.href}catch(bY){bV=c.createElement("a"),bV.href="",bV=bV.href}bW=bR.exec(bV.toLowerCase())||[],f.fn.extend({load:function(a,c,d){if(typeof a!="string"&&bS)return bS.apply(this,arguments);if(!this.length)return this;var e=a.indexOf(" ");if(e>=0){var g=a.slice(e,a.length);a=a.slice(0,e)}var h="GET";c&&(f.isFunction(c)?(d=c,c=b):typeof c=="object"&&(c=f.param(c,f.ajaxSettings.traditional),h="POST"));var i=this;f.ajax({url:a,type:h,dataType:"html",data:c,complete:function(a,b,c){c=a.responseText,a.isResolved()&&(a.done(function(a){c=a}),i.html(g?f("
").append(c.replace(bN,"")).find(g):c)),d&&i.each(d,[c,b,a])}});return this},serialize:function(){return f.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?f.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||bO.test(this.nodeName)||bI.test(this.type))}).map(function(a,b){var c=f(this).val();return c==null?null:f.isArray(c)?f.map(c,function(a,c){return{name:b.name,value:a.replace(bF,"\r\n")}}):{name:b.name,value:c.replace(bF,"\r\n")}}).get()}}),f.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "),function(a,b){f.fn[b]=function(a){return this.on(b,a)}}),f.each(["get","post"],function(a,c){f[c]=function(a,d,e,g){f.isFunction(d)&&(g=g||e,e=d,d=b);return f.ajax({type:c,url:a,data:d,success:e,dataType:g})}}),f.extend({getScript:function(a,c){return f.get(a,b,c,"script")},getJSON:function(a,b,c){return f.get(a,b,c,"json")},ajaxSetup:function(a,b){b?b_(a,f.ajaxSettings):(b=a,a=f.ajaxSettings),b_(a,b);return a},ajaxSettings:{url:bV,isLocal:bJ.test(bW[1]),global:!0,type:"GET",contentType:"application/x-www-form-urlencoded",processData:!0,async:!0,accepts:{xml:"application/xml, text/xml",html:"text/html",text:"text/plain",json:"application/json, text/javascript","*":bX},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText"},converters:{"* text":a.String,"text html":!0,"text json":f.parseJSON,"text xml":f.parseXML},flatOptions:{context:!0,url:!0}},ajaxPrefilter:bZ(bT),ajaxTransport:bZ(bU),ajax:function(a,c){function w(a,c,l,m){if(s!==2){s=2,q&&clearTimeout(q),p=b,n=m||"",v.readyState=a>0?4:0;var o,r,u,w=c,x=l?cb(d,v,l):b,y,z;if(a>=200&&a<300||a===304){if(d.ifModified){if(y=v.getResponseHeader("Last-Modified"))f.lastModified[k]=y;if(z=v.getResponseHeader("Etag"))f.etag[k]=z}if(a===304)w="notmodified",o=!0;else try{r=cc(d,x),w="success",o=!0}catch(A){w="parsererror",u=A}}else{u=w;if(!w||a)w="error",a<0&&(a=0)}v.status=a,v.statusText=""+(c||w),o?h.resolveWith(e,[r,w,v]):h.rejectWith(e,[v,w,u]),v.statusCode(j),j=b,t&&g.trigger("ajax"+(o?"Success":"Error"),[v,d,o?r:u]),i.fireWith(e,[v,w]),t&&(g.trigger("ajaxComplete",[v,d]),--f.active||f.event.trigger("ajaxStop"))}}typeof a=="object"&&(c=a,a=b),c=c||{};var d=f.ajaxSetup({},c),e=d.context||d,g=e!==d&&(e.nodeType||e instanceof f)?f(e):f.event,h=f.Deferred(),i=f.Callbacks("once memory"),j=d.statusCode||{},k,l={},m={},n,o,p,q,r,s=0,t,u,v={readyState:0,setRequestHeader:function(a,b){if(!s){var c=a.toLowerCase();a=m[c]=m[c]||a,l[a]=b}return this},getAllResponseHeaders:function(){return s===2?n:null},getResponseHeader:function(a){var c;if(s===2){if(!o){o={};while(c=bH.exec(n))o[c[1].toLowerCase()]=c[2]}c=o[a.toLowerCase()]}return c===b?null:c},overrideMimeType:function(a){s||(d.mimeType=a);return this},abort:function(a){a=a||"abort",p&&p.abort(a),w(0,a);return this}};h.promise(v),v.success=v.done,v.error=v.fail,v.complete=i.add,v.statusCode=function(a){if(a){var b;if(s<2)for(b in a)j[b]=[j[b],a[b]];else b=a[v.status],v.then(b,b)}return this},d.url=((a||d.url)+"").replace(bG,"").replace(bL,bW[1]+"//"),d.dataTypes=f.trim(d.dataType||"*").toLowerCase().split(bP),d.crossDomain==null&&(r=bR.exec(d.url.toLowerCase()),d.crossDomain=!(!r||r[1]==bW[1]&&r[2]==bW[2]&&(r[3]||(r[1]==="http:"?80:443))==(bW[3]||(bW[1]==="http:"?80:443)))),d.data&&d.processData&&typeof d.data!="string"&&(d.data=f.param(d.data,d.traditional)),b$(bT,d,c,v);if(s===2)return!1;t=d.global,d.type=d.type.toUpperCase(),d.hasContent=!bK.test(d.type),t&&f.active++===0&&f.event.trigger("ajaxStart");if(!d.hasContent){d.data&&(d.url+=(bM.test(d.url)?"&":"?")+d.data,delete d.data),k=d.url;if(d.cache===!1){var x=f.now(),y=d.url.replace(bQ,"$1_="+x);d.url=y+(y===d.url?(bM.test(d.url)?"&":"?")+"_="+x:"")}}(d.data&&d.hasContent&&d.contentType!==!1||c.contentType)&&v.setRequestHeader("Content-Type",d.contentType),d.ifModified&&(k=k||d.url,f.lastModified[k]&&v.setRequestHeader("If-Modified-Since",f.lastModified[k]),f.etag[k]&&v.setRequestHeader("If-None-Match",f.etag[k])),v.setRequestHeader("Accept",d.dataTypes[0]&&d.accepts[d.dataTypes[0]]?d.accepts[d.dataTypes[0]]+(d.dataTypes[0]!=="*"?", "+bX+"; q=0.01":""):d.accepts["*"]);for(u in d.headers)v.setRequestHeader(u,d.headers[u]);if(d.beforeSend&&(d.beforeSend.call(e,v,d)===!1||s===2)){v.abort();return!1}for(u in{success:1,error:1,complete:1})v[u](d[u]);p=b$(bU,d,c,v);if(!p)w(-1,"No Transport");else{v.readyState=1,t&&g.trigger("ajaxSend",[v,d]),d.async&&d.timeout>0&&(q=setTimeout(function(){v.abort("timeout")},d.timeout));try{s=1,p.send(l,w)}catch(z){if(s<2)w(-1,z);else throw z}}return v},param:function(a,c){var d=[],e=function(a,b){b=f.isFunction(b)?b():b,d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(b)};c===b&&(c=f.ajaxSettings.traditional);if(f.isArray(a)||a.jquery&&!f.isPlainObject(a))f.each(a,function(){e(this.name,this.value)});else for(var g in a)ca(g,a[g],c,e);return d.join("&").replace(bD,"+")}}),f.extend({active:0,lastModified:{},etag:{}});var cd=f.now(),ce=/(\=)\?(&|$)|\?\?/i;f.ajaxSetup({jsonp:"callback",jsonpCallback:function(){return f.expando+"_"+cd++}}),f.ajaxPrefilter("json jsonp",function(b,c,d){var e=b.contentType==="application/x-www-form-urlencoded"&&typeof b.data=="string";if(b.dataTypes[0]==="jsonp"||b.jsonp!==!1&&(ce.test(b.url)||e&&ce.test(b.data))){var g,h=b.jsonpCallback=f.isFunction(b.jsonpCallback)?b.jsonpCallback():b.jsonpCallback,i=a[h],j=b.url,k=b.data,l="$1"+h+"$2";b.jsonp!==!1&&(j=j.replace(ce,l),b.url===j&&(e&&(k=k.replace(ce,l)),b.data===k&&(j+=(/\?/.test(j)?"&":"?")+b.jsonp+"="+h))),b.url=j,b.data=k,a[h]=function(a){g=[a]},d.always(function(){a[h]=i,g&&f.isFunction(i)&&a[h](g[0])}),b.converters["script json"]=function(){g||f.error(h+" was not called");return g[0]},b.dataTypes[0]="json";return"script"}}),f.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/javascript|ecmascript/},converters:{"text script":function(a){f.globalEval(a);return a}}}),f.ajaxPrefilter("script",function(a){a.cache===b&&(a.cache=!1),a.crossDomain&&(a.type="GET",a.global=!1)}),f.ajaxTransport("script",function(a){if(a.crossDomain){var d,e=c.head||c.getElementsByTagName("head")[0]||c.documentElement;return{send:function(f,g){d=c.createElement("script"),d.async="async",a.scriptCharset&&(d.charset=a.scriptCharset),d.src=a.url,d.onload=d.onreadystatechange=function(a,c){if(c||!d.readyState||/loaded|complete/.test(d.readyState))d.onload=d.onreadystatechange=null,e&&d.parentNode&&e.removeChild(d),d=b,c||g(200,"success")},e.insertBefore(d,e.firstChild)},abort:function(){d&&d.onload(0,1)}}}});var cf=a.ActiveXObject?function(){for(var a in ch)ch[a](0,1)}:!1,cg=0,ch;f.ajaxSettings.xhr=a.ActiveXObject?function(){return!this.isLocal&&ci()||cj()}:ci,function(a){f.extend(f.support,{ajax:!!a,cors:!!a&&"withCredentials"in a})}(f.ajaxSettings.xhr()),f.support.ajax&&f.ajaxTransport(function(c){if(!c.crossDomain||f.support.cors){var d;return{send:function(e,g){var h=c.xhr(),i,j;c.username?h.open(c.type,c.url,c.async,c.username,c.password):h.open(c.type,c.url,c.async);if(c.xhrFields)for(j in c.xhrFields)h[j]=c.xhrFields[j];c.mimeType&&h.overrideMimeType&&h.overrideMimeType(c.mimeType),!c.crossDomain&&!e["X-Requested-With"]&&(e["X-Requested-With"]="XMLHttpRequest");try{for(j in e)h.setRequestHeader(j,e[j])}catch(k){}h.send(c.hasContent&&c.data||null),d=function(a,e){var j,k,l,m,n;try{if(d&&(e||h.readyState===4)){d=b,i&&(h.onreadystatechange=f.noop,cf&&delete ch[i]);if(e)h.readyState!==4&&h.abort();else{j=h.status,l=h.getAllResponseHeaders(),m={},n=h.responseXML,n&&n.documentElement&&(m.xml=n),m.text=h.responseText;try{k=h.statusText}catch(o){k=""}!j&&c.isLocal&&!c.crossDomain?j=m.text?200:404:j===1223&&(j=204)}}}catch(p){e||g(-1,p)}m&&g(j,k,m,l)},!c.async||h.readyState===4?d():(i=++cg,cf&&(ch||(ch={},f(a).unload(cf)),ch[i]=d),h.onreadystatechange=d)},abort:function(){d&&d(0,1)}}}});var ck={},cl,cm,cn=/^(?:toggle|show|hide)$/,co=/^([+\-]=)?([\d+.\-]+)([a-z%]*)$/i,cp,cq=[["height","marginTop","marginBottom","paddingTop","paddingBottom"],["width","marginLeft","marginRight","paddingLeft","paddingRight"],["opacity"]],cr;f.fn.extend({show:function(a,b,c){var d,e;if(a||a===0)return this.animate(cu("show",3),a,b,c);for(var g=0,h=this.length;g=i.duration+this.startTime){this.now=this.end,this.pos=this.state=1,this.update(),i.animatedProperties[this.prop]=!0;for(b in i.animatedProperties)i.animatedProperties[b]!==!0&&(g=!1);if(g){i.overflow!=null&&!f.support.shrinkWrapBlocks&&f.each(["","X","Y"],function(a,b){h.style["overflow"+b]=i.overflow[a]}),i.hide&&f(h).hide();if(i.hide||i.show)for(b in i.animatedProperties)f.style(h,b,i.orig[b]),f.removeData(h,"fxshow"+b,!0),f.removeData(h,"toggle"+b,!0);d=i.complete,d&&(i.complete=!1,d.call(h))}return!1}i.duration==Infinity?this.now=e:(c=e-this.startTime,this.state=c/i.duration,this.pos=f.easing[i.animatedProperties[this.prop]](this.state,c,0,1,i.duration),this.now=this.start+(this.end-this.start)*this.pos),this.update();return!0}},f.extend(f.fx,{tick:function(){var a,b=f.timers,c=0;for(;c-1,k={},l={},m,n;j?(l=e.position(),m=l.top,n=l.left):(m=parseFloat(h)||0,n=parseFloat(i)||0),f.isFunction(b)&&(b=b.call(a,c,g)),b.top!=null&&(k.top=b.top-g.top+m),b.left!=null&&(k.left=b.left-g.left+n),"using"in b?b.using.call(a,k):e.css(k)}},f.fn.extend({position:function(){if(!this[0])return null;var a=this[0],b=this.offsetParent(),c=this.offset(),d=cx.test(b[0].nodeName)?{top:0,left:0}:b.offset();c.top-=parseFloat(f.css(a,"marginTop"))||0,c.left-=parseFloat(f.css(a,"marginLeft"))||0,d.top+=parseFloat(f.css(b[0],"borderTopWidth"))||0,d.left+=parseFloat(f.css(b[0],"borderLeftWidth"))||0;return{top:c.top-d.top,left:c.left-d.left}},offsetParent:function(){return this.map(function(){var a=this.offsetParent||c.body;while(a&&!cx.test(a.nodeName)&&f.css(a,"position")==="static")a=a.offsetParent;return a})}}),f.each(["Left","Top"],function(a,c){var d="scroll"+c;f.fn[d]=function(c){var e,g;if(c===b){e=this[0];if(!e)return null;g=cy(e);return g?"pageXOffset"in g?g[a?"pageYOffset":"pageXOffset"]:f.support.boxModel&&g.document.documentElement[d]||g.document.body[d]:e[d]}return this.each(function(){g=cy(this),g?g.scrollTo(a?f(g).scrollLeft():c,a?c:f(g).scrollTop()):this[d]=c})}}),f.each(["Height","Width"],function(a,c){var d=c.toLowerCase();f.fn["inner"+c]=function(){var a=this[0];return a?a.style?parseFloat(f.css(a,d,"padding")):this[d]():null},f.fn["outer"+c]=function(a){var b=this[0];return b?b.style?parseFloat(f.css(b,d,a?"margin":"border")):this[d]():null},f.fn[d]=function(a){var e=this[0];if(!e)return a==null?null:this;if(f.isFunction(a))return this.each(function(b){var c=f(this);c[d](a.call(this,b,c[d]()))});if(f.isWindow(e)){var g=e.document.documentElement["client"+c],h=e.document.body;return e.document.compatMode==="CSS1Compat"&&g||h&&h["client"+c]||g}if(e.nodeType===9)return Math.max(e.documentElement["client"+c],e.body["scroll"+c],e.documentElement["scroll"+c],e.body["offset"+c],e.documentElement["offset"+c]);if(a===b){var i=f.css(e,d),j=parseFloat(i);return f.isNumeric(j)?j:i}return this.css(d,typeof a=="string"?a:a+"px")}}),a.jQuery=a.$=f,typeof define=="function"&&define.amd&&define.amd.jQuery&&define("jquery",[],function(){return f})})(window); \ No newline at end of file diff --git a/public/scripts/register.js b/public/scripts/register.js new file mode 100644 index 0000000..32588ad --- /dev/null +++ b/public/scripts/register.js @@ -0,0 +1,19 @@ +$(document).ready(function() { + // Slack OAuth + var code = $.url('?code') + if (code) { + SlackSup.message('Working, please wait ...'); + $('#register').hide(); + $.ajax({ + type: "POST", + url: "/api/teams", + data: { + code: code + }, + success: function(data) { + SlackSup.message('Team successfully registered!

DM @sup or create a #channel and invite @sup to it.'); + }, + error: SlackSup.error + }); + } +}); diff --git a/public/scripts/sup.js b/public/scripts/sup.js new file mode 100644 index 0000000..66a815b --- /dev/null +++ b/public/scripts/sup.js @@ -0,0 +1,37 @@ +var SlackSup = {}; + +$(document).ready(function() { + + SlackSup.message = function(text) { + $('#messages').fadeOut('slow', function() { + $('#messages').fadeIn('slow').html(text) + }); + }; + + SlackSup.error = function(xhr) { + try { + var message; + if (xhr.responseText) { + var rc = JSON.parse(xhr.responseText); + if (rc && rc.error) { + message = rc.error; + } else if (rc && rc.message) { + message = rc.message; + if (message == 'invalid_code') { + message = 'The code returned from the OAuth workflow was invalid.' + } else if (message == 'code_already_used') { + message = 'The code returned from the OAuth workflow has already been used.' + } + } else if (rc && rc.error) { + message = rc.error; + } + } + + SlackSup.message(message || xhr.statusText || xhr.responseText || 'Unexpected Error'); + + } catch(err) { + SlackSup.message(err.message); + } + }; + +}); diff --git a/public/scripts/url.min.js b/public/scripts/url.min.js new file mode 100755 index 0000000..4ca6320 --- /dev/null +++ b/public/scripts/url.min.js @@ -0,0 +1 @@ +/*! js-url - v2.0.2 - 2015-09-17 */window.url=function(){function a(){}function b(a){return decodeURIComponent(a.replace(/\+/g," "))}function c(a,b){var c=a.charAt(0),d=b.split(c);return c===a?d:(a=parseInt(a.substring(1),10),d[0>a?d.length+a:a-1])}function d(a,c){var d=a.charAt(0),e=c.split("&"),f=[],g={},h=[],i=a.substring(1);for(var j in e)if(f=e[j].match(/(.*?)=(.*)/),f||(f=[e[j],e[j],""]),""!==f[1].replace(/\s/g,"")){if(f[2]=b(f[2]||""),i===f[1])return f[2];h=f[1].match(/(.*)\[([0-9]+)\]/),h?(g[h[1]]=g[h[1]]||[],g[h[1]][h[2]]=f[2]):g[f[1]]=f[2]}return d===a?g:g[i]}return function(b,e){var f,g={};if("tld?"===b)return a();if(e=e||window.location.toString(),!b)return e;if(b=b.toString(),f=e.match(/^mailto:([^\/].+)/))g.protocol="mailto",g.email=f[1];else{if((f=e.match(/(.*?)#(.*)/))&&(g.hash=f[2],e=f[1]),g.hash&&b.match(/^#/))return d(b,g.hash);if((f=e.match(/(.*?)\?(.*)/))&&(g.query=f[2],e=f[1]),g.query&&b.match(/^\?/))return d(b,g.query);if((f=e.match(/(.*?)\:?\/\/(.*)/))&&(g.protocol=f[1].toLowerCase(),e=f[2]),(f=e.match(/(.*?)(\/.*)/))&&(g.path=f[2],e=f[1]),g.path=(g.path||"").replace(/^([^\/])/,"/$1").replace(/\/$/,""),b.match(/^[\-0-9]+$/)&&(b=b.replace(/^([^\/])/,"/$1")),b.match(/^\//))return c(b,g.path.substring(1));if(f=c("/-1",g.path.substring(1)),f&&(f=f.match(/(.*?)\.(.*)/))&&(g.file=f[0],g.filename=f[1],g.fileext=f[2]),(f=e.match(/(.*)\:([0-9]+)$/))&&(g.port=f[2],e=f[1]),(f=e.match(/(.*?)@(.*)/))&&(g.auth=f[1],e=f[2]),g.auth&&(f=g.auth.match(/(.*)\:(.*)/),g.user=f?f[1]:g.auth,g.pass=f?f[2]:void 0),g.hostname=e.toLowerCase(),"."===b.charAt(0))return c(b,g.hostname);a()&&(f=g.hostname.match(a()),f&&(g.tld=f[3],g.domain=f[2]?f[2]+"."+f[3]:void 0,g.sub=f[1]||void 0)),g.port=g.port||("https"===g.protocol?"443":"80"),g.protocol=g.protocol||("443"===g.port?"https":"http")}return b in g?g[b]:"[]"===b?g:void 0}}(),"undefined"!=typeof jQuery&&jQuery.extend({url:function(a,b){return window.url(a,b)}}); \ No newline at end of file diff --git a/public/subscribe.html.erb b/public/subscribe.html.erb new file mode 100644 index 0000000..0964231 --- /dev/null +++ b/public/subscribe.html.erb @@ -0,0 +1,85 @@ + + + Sup for Slack: Subscribe + <%= partial 'public/partials/_head.html.erb' %> + + + + +

+ +

+

+

Sup for Slack: Subscribe

+

+

+

+ +

+ +

+ +

+ <%= partial 'public/partials/_footer.html.erb' %> + + diff --git a/public/update_cc.html.erb b/public/update_cc.html.erb new file mode 100644 index 0000000..7b1d0a3 --- /dev/null +++ b/public/update_cc.html.erb @@ -0,0 +1,70 @@ + + + Sup for Slack: Update Credit Card Info + <%= partial 'public/partials/_head.html' %> + <% + team = Team.where(team_id: request['team_id']).first + stripe_token = request['stripeToken'] + stripe_token_type = request['stripeTokenType'] + stripe_email = request['stripeEmail'] + %> + + +

+ +

+

+

Sup for Slack: Update Credit Card Info

+

+

+

+
+ + +

+ +

+ Questions? Contact dblock[at]dblock[dot]org or DM @playplayio. +
+

+ +

+ + diff --git a/script/console b/script/console new file mode 100755 index 0000000..463c288 --- /dev/null +++ b/script/console @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Usage: script/console +# Starts an IRB console with slack-sup loaded. + +exec bundle exec irb -I . -r "slack-sup" diff --git a/slack-sup.rb b/slack-sup.rb new file mode 100644 index 0000000..4f0dc23 --- /dev/null +++ b/slack-sup.rb @@ -0,0 +1,20 @@ +ENV['RACK_ENV'] ||= 'development' + +require 'bundler/setup' +Bundler.require :default, ENV['RACK_ENV'] + +Dir[File.expand_path('../config/initializers', __FILE__) + '/**/*.rb'].each do |file| + require file +end + +Mongoid.load! File.expand_path('../config/mongoid.yml', __FILE__), ENV['RACK_ENV'] + +require 'slack-ruby-bot' +require 'slack-sup/version' +require 'slack-sup/service' +require 'slack-sup/info' +require 'slack-sup/models' +require 'slack-sup/api' +require 'slack-sup/app' +require 'slack-sup/server' +require 'slack-sup/commands' diff --git a/slack-sup/api.rb b/slack-sup/api.rb new file mode 100644 index 0000000..7afe59b --- /dev/null +++ b/slack-sup/api.rb @@ -0,0 +1,8 @@ +require 'grape' +require 'roar' +require 'grape-roar' + +require 'slack-sup/api/helpers' +require 'slack-sup/api/presenters' +require 'slack-sup/api/endpoints' +require 'slack-sup/api/middleware' diff --git a/slack-sup/api/endpoints.rb b/slack-sup/api/endpoints.rb new file mode 100644 index 0000000..8167fe8 --- /dev/null +++ b/slack-sup/api/endpoints.rb @@ -0,0 +1,5 @@ +require 'slack-sup/api/endpoints/teams_endpoint' +require 'slack-sup/api/endpoints/subscriptions_endpoint' +require 'slack-sup/api/endpoints/status_endpoint' +require 'slack-sup/api/endpoints/credit_cards_endpoint' +require 'slack-sup/api/endpoints/root_endpoint' diff --git a/slack-sup/api/endpoints/credit_cards_endpoint.rb b/slack-sup/api/endpoints/credit_cards_endpoint.rb new file mode 100644 index 0000000..ea5743a --- /dev/null +++ b/slack-sup/api/endpoints/credit_cards_endpoint.rb @@ -0,0 +1,26 @@ +module Api + module Endpoints + class CreditCardsEndpoint < Grape::API + format :json + + namespace :credit_cards do + desc 'Updates a credit card.' + params do + requires :stripe_token, type: String + optional :stripe_token_type, type: String + optional :stripe_email, type: String + requires :team_id, type: String + end + post do + team = Team.find(params[:team_id]) || error!('Not Found', 404) + error!('Not a Subscriber', 400) unless team.stripe_customer_id + customer = Stripe::Customer.retrieve(team.stripe_customer_id) + customer.source = params['stripe_token'] + customer.save + Api::Middleware.logger.info "Updated credit card for team #{team}, email=#{params[:stripe_email]}." + present team, with: Api::Presenters::TeamPresenter + end + end + end + end +end diff --git a/slack-sup/api/endpoints/root_endpoint.rb b/slack-sup/api/endpoints/root_endpoint.rb new file mode 100644 index 0000000..aee9fcc --- /dev/null +++ b/slack-sup/api/endpoints/root_endpoint.rb @@ -0,0 +1,22 @@ +module Api + module Endpoints + class RootEndpoint < Grape::API + include Api::Helpers::ErrorHelpers + + prefix 'api' + + format :json + formatter :json, Grape::Formatter::Roar + get do + present self, with: Api::Presenters::RootPresenter + end + + mount Api::Endpoints::StatusEndpoint + mount Api::Endpoints::TeamsEndpoint + mount Api::Endpoints::SubscriptionsEndpoint + mount Api::Endpoints::CreditCardsEndpoint + + add_swagger_documentation + end + end +end diff --git a/slack-sup/api/endpoints/status_endpoint.rb b/slack-sup/api/endpoints/status_endpoint.rb new file mode 100644 index 0000000..805f1ca --- /dev/null +++ b/slack-sup/api/endpoints/status_endpoint.rb @@ -0,0 +1,14 @@ +module Api + module Endpoints + class StatusEndpoint < Grape::API + format :json + + namespace :status do + desc 'Get system status.' + get do + present self, with: Api::Presenters::StatusPresenter + end + end + end + end +end diff --git a/slack-sup/api/endpoints/subscriptions_endpoint.rb b/slack-sup/api/endpoints/subscriptions_endpoint.rb new file mode 100644 index 0000000..b949f76 --- /dev/null +++ b/slack-sup/api/endpoints/subscriptions_endpoint.rb @@ -0,0 +1,37 @@ +module Api + module Endpoints + class SubscriptionsEndpoint < Grape::API + format :json + + namespace :subscriptions do + desc 'Subscribe to slack-sup.' + params do + requires :stripe_token, type: String + requires :stripe_token_type, type: String + requires :stripe_email, type: String + requires :team_id, type: String + end + post do + team = Team.where(team_id: params[:team_id]).first || error!('Team Not Found', 404) + Api::Middleware.logger.info "Creating a subscription for team #{team}." + error!('Already Subscribed', 400) if team.subscribed? + error!('Customer Already Registered', 400) if team.stripe_customer_id + customer = Stripe::Customer.create( + source: params[:stripe_token], + plan: 'slack-sup-monthly', + email: params[:stripe_email], + metadata: { + id: team._id, + team_id: team.team_id, + name: team.name, + domain: team.domain + } + ) + Api::Middleware.logger.info "Subscription for team #{team} created, stripe_customer_id=#{customer['id']}." + team.update_attributes!(subscribed: true, subscribed_at: Time.now.utc, stripe_customer_id: customer['id']) + present team, with: Api::Presenters::TeamPresenter + end + end + end + end +end diff --git a/slack-sup/api/endpoints/teams_endpoint.rb b/slack-sup/api/endpoints/teams_endpoint.rb new file mode 100644 index 0000000..5dfbc68 --- /dev/null +++ b/slack-sup/api/endpoints/teams_endpoint.rb @@ -0,0 +1,68 @@ +module Api + module Endpoints + class TeamsEndpoint < Grape::API + format :json + helpers Api::Helpers::CursorHelpers + helpers Api::Helpers::SortHelpers + helpers Api::Helpers::PaginationParameters + + namespace :teams do + desc 'Get a team.' + params do + requires :id, type: String, desc: 'Team ID.' + end + get ':id' do + team = Team.where(_id: params[:id], api: true).first || error!('Not Found', 404) + present team, with: Api::Presenters::TeamPresenter + end + + desc 'Get all the teams.' + params do + optional :active, type: Boolean, desc: 'Return active teams only.' + use :pagination + end + sort Team::SORT_ORDERS + get do + teams = Team.api + teams = teams.active if params[:active] + teams = paginate_and_sort_by_cursor(teams, default_sort_order: '-_id') + present teams, with: Api::Presenters::TeamsPresenter + end + + desc 'Create a team using an OAuth token.' + params do + requires :code, type: String + end + post do + client = Slack::Web::Client.new + + raise 'Missing SLACK_CLIENT_ID or SLACK_CLIENT_SECRET.' unless ENV.key?('SLACK_CLIENT_ID') && ENV.key?('SLACK_CLIENT_SECRET') + + rc = client.oauth_access( + client_id: ENV['SLACK_CLIENT_ID'], + client_secret: ENV['SLACK_CLIENT_SECRET'], + code: params[:code] + ) + + token = rc['bot']['bot_access_token'] + team = Team.where(token: token).first + team ||= Team.where(team_id: rc['team_id']).first + if team && !team.active? + team.activate!(token) + elsif team + raise "Team #{team.name} is already registered." + else + team = Team.create!( + token: token, + team_id: rc['team_id'], + name: rc['team_name'] + ) + end + + SlackSup::Service.instance.start!(team) + present team, with: Api::Presenters::TeamPresenter + end + end + end + end +end diff --git a/slack-sup/api/helpers.rb b/slack-sup/api/helpers.rb new file mode 100644 index 0000000..864bfdc --- /dev/null +++ b/slack-sup/api/helpers.rb @@ -0,0 +1,4 @@ +require 'slack-sup/api/helpers/cursor_helpers' +require 'slack-sup/api/helpers/pagination_parameters' +require 'slack-sup/api/helpers/sort_helpers' +require 'slack-sup/api/helpers/error_helpers' diff --git a/slack-sup/api/helpers/cursor_helpers.rb b/slack-sup/api/helpers/cursor_helpers.rb new file mode 100644 index 0000000..a427152 --- /dev/null +++ b/slack-sup/api/helpers/cursor_helpers.rb @@ -0,0 +1,35 @@ +module Api + module Helpers + module CursorHelpers + extend ActiveSupport::Concern + + # apply cursor-based pagination to a collection + # returns a hash: + # results: (paginated collection subset) + # next: (cursor to the next page) + def paginate_by_cursor(coll, &_block) + raise 'Both cursor and offset parameters are present, these are mutually exclusive.' if params.key?(:offset) && params.key?(:cursor) + results = { results: [], next: nil } + size = (params[:size] || 10).to_i + if params.key?(:offset) + skip = params[:offset].to_i + coll = coll.skip(skip) + end + # some items may be skipped with a block + query = block_given? ? coll : coll.limit(size) + query.scroll(params[:cursor]) do |record, next_cursor| + record = yield(record) if block_given? + results[:results] << record if record + results[:next] = next_cursor.to_s + break if results[:results].count >= size + end + results[:total_count] = coll.count if params[:total_count] && coll.respond_to?(:count) + results + end + + def paginate_and_sort_by_cursor(coll, options = {}, &block) + Hashie::Mash.new(paginate_by_cursor(sort(coll, options), &block)) + end + end + end +end diff --git a/slack-sup/api/helpers/error_helpers.rb b/slack-sup/api/helpers/error_helpers.rb new file mode 100644 index 0000000..048d1a1 --- /dev/null +++ b/slack-sup/api/helpers/error_helpers.rb @@ -0,0 +1,50 @@ +module Api + module Helpers + module ErrorHelpers + extend ActiveSupport::Concern + + included do + rescue_from :all, backtrace: true do |e| + backtrace = e.backtrace[0..5].join("\n ") + Api::Middleware.logger.error "#{e.class.name}: #{e.message}\n #{backtrace}" + error = { type: 'other_error', message: e.message } + error[:backtrace] = backtrace + rack_response(error.to_json, 400) + end + # rescue document validation errors into detail json + rescue_from Mongoid::Errors::Validations do |e| + backtrace = e.backtrace[0..5].join("\n ") + Api::Middleware.logger.warn "#{e.class.name}: #{e.message}\n #{backtrace}" + rack_response({ + type: 'param_error', + message: e.document.errors.full_messages.uniq.join(', ') + '.', + detail: e.document.errors.messages.each_with_object({}) do |(k, v), h| + h[k] = v.uniq + end + }.to_json, 400) + end + rescue_from Grape::Exceptions::Validation do |e| + backtrace = e.backtrace[0..5].join("\n ") + Api::Middleware.logger.warn "#{e.class.name}: #{e.message}\n #{backtrace}" + rack_response({ + type: 'param_error', + message: 'Invalid parameters.', + detail: { e.params.join(', ') => [e.message] } + }.to_json, 400) + end + rescue_from Grape::Exceptions::ValidationErrors do |e| + backtrace = e.backtrace[0..5].join("\n ") + Api::Middleware.logger.warn "#{e.class.name}: #{e.message}\n #{backtrace}" + rack_response({ + type: 'param_error', + message: 'Invalid parameters.', + detail: e.errors.each_with_object({}) do |(k, v), h| + # JSON does not permit having a key of type Array + h[k.count == 1 ? k.first : k.join(', ')] = v + end + }.to_json, 400) + end + end + end + end +end diff --git a/slack-sup/api/helpers/pagination_parameters.rb b/slack-sup/api/helpers/pagination_parameters.rb new file mode 100644 index 0000000..d991855 --- /dev/null +++ b/slack-sup/api/helpers/pagination_parameters.rb @@ -0,0 +1,17 @@ +module Api + module Helpers + module PaginationParameters + extend Grape::API::Helpers + + params :pagination do + optional :offset, type: Integer, desc: 'Offset from which to retrieve.' + optional :size, type: Integer, desc: 'Number of items to retrieve for this page or from the current offset.' + optional :cursor, type: String, desc: 'Cursor for pagination.' + optional :total_count, desc: 'Include total count in the response.' + mutually_exclusive :offset, :cursor + end + + ALL = %w[cursor size sort offset total_count].freeze + end + end +end diff --git a/slack-sup/api/helpers/sort_helpers.rb b/slack-sup/api/helpers/sort_helpers.rb new file mode 100644 index 0000000..4191c1b --- /dev/null +++ b/slack-sup/api/helpers/sort_helpers.rb @@ -0,0 +1,51 @@ +module Api + module Helpers + module SortHelpers + extend ActiveSupport::Concern + + def sort_order(options = {}) + params[:sort] = options[:default_sort_order] unless params[:sort] + return [] unless params[:sort] + sort_order = params[:sort].to_s + unless options[:default_sort_order] == sort_order + supported_sort_orders = route_sort + error!("This API doesn't support sorting", 400) if supported_sort_orders.blank? + unless supported_sort_orders.include?(sort_order) + error!("Invalid sort order: #{sort_order}, must be#{supported_sort_orders.count == 1 ? '' : ' one of'} '#{supported_sort_orders.join('\', \'')}'", 400) + end + end + sort_order = sort_order.split(',').map do |sort_entry| + sort_order = {} + if sort_entry[0] == '-' + sort_order[:direction] = :desc + sort_order[:column] = sort_entry[1..-1] + else + sort_order[:direction] = :asc + sort_order[:column] = sort_entry + end + error!("Invalid sort: #{sort_entry}", 400) if sort_order[:column].blank? + sort_order + end + sort_order + end + + def route_sort + (env['api.endpoint'].route_setting(:sort) || {})[:sort] + end + + def sort(coll, options = {}) + sort_order = sort_order(options) + unless sort_order.empty? + if coll.respond_to?(:asc) && coll.respond_to?(:desc) + sort_order.each do |s| + coll = coll.send(s[:direction], s[:column]) + end + else + error!("Cannot sort #{coll.class.name}", 500) + end + end + coll = coll.is_a?(Module) && coll.respond_to?(:all) ? coll.all : coll + end + end + end +end diff --git a/slack-sup/api/middleware.rb b/slack-sup/api/middleware.rb new file mode 100644 index 0000000..91f72aa --- /dev/null +++ b/slack-sup/api/middleware.rb @@ -0,0 +1,36 @@ +module Api + class Middleware + def self.logger + @logger ||= begin + $stdout.sync = true + Logger.new(STDOUT) + end + end + + def self.instance + @instance ||= Rack::Builder.new do + use Rack::Cors do + allow do + origins '*' + resource '*', headers: :any, methods: %i[get post] + end + end + + # rewrite HAL links to make them clickable in a browser + use Rack::Rewrite do + r302 %r{(\/[\w\/]*\/)(%7B|\{)?(.*)(%7D|\})}, '$1' + end + + use Rack::Robotz, 'User-Agent' => '*', 'Disallow' => '/api' + + use Rack::ServerPages + + run Api::Middleware.new + end.to_app + end + + def call(env) + Api::Endpoints::RootEndpoint.call(env) + end + end +end diff --git a/slack-sup/api/presenters.rb b/slack-sup/api/presenters.rb new file mode 100644 index 0000000..c604156 --- /dev/null +++ b/slack-sup/api/presenters.rb @@ -0,0 +1,9 @@ +require 'roar/representer' +require 'roar/json' +require 'roar/json/hal' + +require 'slack-sup/api/presenters/paginated_presenter' +require 'slack-sup/api/presenters/status_presenter' +require 'slack-sup/api/presenters/team_presenter' +require 'slack-sup/api/presenters/teams_presenter' +require 'slack-sup/api/presenters/root_presenter' diff --git a/slack-sup/api/presenters/paginated_presenter.rb b/slack-sup/api/presenters/paginated_presenter.rb new file mode 100644 index 0000000..c62fe7c --- /dev/null +++ b/slack-sup/api/presenters/paginated_presenter.rb @@ -0,0 +1,36 @@ +module Api + module Presenters + module PaginatedPresenter + include Roar::JSON::HAL + include Roar::Hypermedia + include Grape::Roar::Representer + + property :total_count + + link :self do |opts| + "#{request_url(opts)}#{query_string_for_cursor(nil, opts)}" + end + + link :next do |opts| + "#{request_url(opts)}#{query_string_for_cursor(represented.next, opts)}" if represented.next + end + + private + + def request_url(opts) + request = Grape::Request.new(opts[:env]) + "#{request.base_url}#{opts[:env]['PATH_INFO']}" + end + + # replace the page and offset parameters in the query string + def query_string_for_cursor(cursor, opts) + qs = Hashie::Mash.new(Rack::Utils.parse_nested_query(opts[:env]['QUERY_STRING'])) + if cursor + qs[:cursor] = cursor + qs.delete(:offset) + end + "?#{qs.to_query}" unless qs.empty? + end + end + end +end diff --git a/slack-sup/api/presenters/root_presenter.rb b/slack-sup/api/presenters/root_presenter.rb new file mode 100644 index 0000000..3e5bc45 --- /dev/null +++ b/slack-sup/api/presenters/root_presenter.rb @@ -0,0 +1,50 @@ +module Api + module Presenters + module RootPresenter + include Roar::JSON::HAL + include Roar::Hypermedia + include Grape::Roar::Representer + + link :self do |opts| + "#{base_url(opts)}/api" + end + + link :status do |opts| + "#{base_url(opts)}/api/status" + end + + link :subscriptions do |opts| + "#{base_url(opts)}/api/subscriptions" + end + + link :credit_cards do |opts| + "#{base_url(opts)}/api/credit_cards" + end + + link :teams do |opts| + { + href: "#{base_url(opts)}/api/teams/#{link_params(Api::Helpers::PaginationParameters::ALL, :active)}", + templated: true + } + end + + link :team do |opts| + { + href: "#{base_url(opts)}/api/teams/{id}", + templated: true + } + end + + private + + def base_url(opts) + request = Grape::Request.new(opts[:env]) + request.base_url + end + + def link_params(*args) + "{?#{args.join(',')}}" + end + end + end +end diff --git a/slack-sup/api/presenters/status_presenter.rb b/slack-sup/api/presenters/status_presenter.rb new file mode 100644 index 0000000..3b6c946 --- /dev/null +++ b/slack-sup/api/presenters/status_presenter.rb @@ -0,0 +1,36 @@ +module Api + module Presenters + module StatusPresenter + include Roar::JSON::HAL + include Roar::Hypermedia + include Grape::Roar::Representer + + link :self do |opts| + "#{base_url(opts)}/status" + end + + property :teams_count + property :active_teams_count + property :ping + + def ping + team = Team.asc(:_id).first + return unless team + team.ping! + end + + def teams_count + Team.count + end + + def active_teams_count + Team.active.count + end + + def base_url(opts) + request = Grape::Request.new(opts[:env]) + request.base_url + end + end + end +end diff --git a/slack-sup/api/presenters/team_presenter.rb b/slack-sup/api/presenters/team_presenter.rb new file mode 100644 index 0000000..4eced14 --- /dev/null +++ b/slack-sup/api/presenters/team_presenter.rb @@ -0,0 +1,24 @@ +module Api + module Presenters + module TeamPresenter + include Roar::JSON::HAL + include Roar::Hypermedia + include Grape::Roar::Representer + + property :id, type: String, desc: 'Team ID.' + property :team_id, type: String, desc: 'Slack team ID.' + property :name, type: String, desc: 'Team name.' + property :domain, type: String, desc: 'Team domain.' + property :active, type: Boolean, desc: 'Team is active.' + property :subscribed, type: Boolean, desc: 'Team is a paid subscriber.' + property :subscribed_at, type: DateTime, desc: 'Date/time when a subscription was purchased.' + property :created_at, type: DateTime, desc: 'Date/time when the team was created.' + property :updated_at, type: DateTime, desc: 'Date/time when the team was accepted, declined or canceled.' + + link :self do |opts| + request = Grape::Request.new(opts[:env]) + "#{request.base_url}/api/teams/#{id}" + end + end + end +end diff --git a/slack-sup/api/presenters/teams_presenter.rb b/slack-sup/api/presenters/teams_presenter.rb new file mode 100644 index 0000000..dc35850 --- /dev/null +++ b/slack-sup/api/presenters/teams_presenter.rb @@ -0,0 +1,12 @@ +module Api + module Presenters + module TeamsPresenter + include Roar::JSON::HAL + include Roar::Hypermedia + include Grape::Roar::Representer + include Api::Presenters::PaginatedPresenter + + collection :results, extend: TeamPresenter, as: :teams, embedded: true + end + end +end diff --git a/slack-sup/app.rb b/slack-sup/app.rb new file mode 100644 index 0000000..bc6bfef --- /dev/null +++ b/slack-sup/app.rb @@ -0,0 +1,45 @@ +module SlackSup + class App < SlackRubyBotServer::App + def prepare! + super + deactivate_asleep_teams! + end + + def after_start! + check_subscribed_teams! + end + + private + + def deactivate_asleep_teams! + Team.active.each do |team| + next unless team.asleep? + begin + team.deactivate! + team.inform! "This integration hasn't been used for 2 weeks, deactivating. Reactivate at #{SlackSup::Service.url}. Your data will be purged in another 2 weeks." + rescue StandardError => e + logger.warn "Error informing team #{team}, #{e.message}." + end + end + end + + def check_subscribed_teams! + Team.where(subscribed: true, :stripe_customer_id.ne => nil).each do |team| + customer = Stripe::Customer.retrieve(team.stripe_customer_id) + customer.subscriptions.each do |subscription| + subscription_name = "#{subscription.plan.name} (#{ActiveSupport::NumberHelper.number_to_currency(subscription.plan.amount.to_f / 100)})" + logger.info "Checking #{team} subscription to #{subscription_name}, #{subscription.status}." + case subscription.status + when 'past_due' + logger.warn "Subscription for #{team} is #{subscription.status}, notifying." + team.inform! "Your subscription to #{subscription_name} is past due. #{team.update_cc_text}" + when 'canceled', 'unpaid' + logger.warn "Subscription for #{team} is #{subscription.status}, downgrading." + team.inform! "Your subscription to #{subscription.plan.name} (#{ActiveSupport::NumberHelper.number_to_currency(subscription.plan.amount.to_f / 100)}) was canceled and your team has been downgraded. Thank you for being a customer!" + team.update_attributes!(subscribed: false) + end + end + end + end + end +end diff --git a/slack-sup/commands.rb b/slack-sup/commands.rb new file mode 100644 index 0000000..e5d10d3 --- /dev/null +++ b/slack-sup/commands.rb @@ -0,0 +1,3 @@ +require 'slack-sup/commands/mixins' +require 'slack-sup/commands/help' +require 'slack-sup/commands/subscription' diff --git a/slack-sup/commands/help.rb b/slack-sup/commands/help.rb new file mode 100644 index 0000000..9f068de --- /dev/null +++ b/slack-sup/commands/help.rb @@ -0,0 +1,26 @@ +module SlackSup + module Commands + class Help < SlackRubyBot::Commands::Base + HELP = <<~EOS.freeze + ``` + I am your friendly Team Sup bot. + + General + ------- + + help - get this helpful message + subscription - show subscription info + ``` +EOS + def self.call(client, data, _match) + client.say(channel: data.channel, text: [ + HELP, + SlackSup::INFO, + client.owner.reload.subscribed? ? nil : client.owner.subscribe_text + ].compact.join("\n")) + client.say(channel: data.channel) + logger.info "HELP: #{client.owner}, user=#{data.user}" + end + end + end +end diff --git a/slack-sup/commands/mixins.rb b/slack-sup/commands/mixins.rb new file mode 100644 index 0000000..eb4fb49 --- /dev/null +++ b/slack-sup/commands/mixins.rb @@ -0,0 +1 @@ +require 'slack-sup/commands/mixins/subscribe' diff --git a/slack-sup/commands/mixins/subscribe.rb b/slack-sup/commands/mixins/subscribe.rb new file mode 100644 index 0000000..9fa20f2 --- /dev/null +++ b/slack-sup/commands/mixins/subscribe.rb @@ -0,0 +1,22 @@ +module SlackSup + module Commands + module Mixins + module Subscribe + extend ActiveSupport::Concern + + module ClassMethods + def subscribe_command(*values, &_block) + command(*values) do |client, data, match| + if Stripe.api_key && client.owner.reload.subscription_expired? + client.say channel: data.channel, text: client.owner.subscribe_text + logger.info "#{client.owner}, user=#{data.user}, text=#{data.text}, subscription expired" + else + yield client, data, match + end + end + end + end + end + end + end +end diff --git a/slack-sup/commands/subscription.rb b/slack-sup/commands/subscription.rb new file mode 100644 index 0000000..5192b8e --- /dev/null +++ b/slack-sup/commands/subscription.rb @@ -0,0 +1,29 @@ +module SlackSup + module Commands + class Subscription < SlackRubyBot::Commands::Base + include SlackSup::Commands::Mixins::Subscribe + + subscribe_command 'subscription' do |client, data, _match| + user = ::User.find_create_or_update_by_slack_id!(client, data.user) + if client.owner.stripe_customer_id + customer = Stripe::Customer.retrieve(client.owner.stripe_customer_id) + customer_info = "Customer since #{Time.at(customer.created).strftime('%B %d, %Y')}." + customer.subscriptions.each do |subscription| + customer_info += "\nSubscribed to #{subscription.plan.name} (#{ActiveSupport::NumberHelper.number_to_currency(subscription.plan.amount.to_f / 100)})" + end + customer.invoices.each do |invoice| + customer_info += "\nInvoice for #{ActiveSupport::NumberHelper.number_to_currency(invoice.amount_due.to_f / 100)} on #{Time.at(invoice.date).strftime('%B %d, %Y')}, #{invoice.paid ? 'paid' : 'unpaid'}." + end + customer.sources.each do |source| + customer_info += "\nOn file #{source.brand} #{source.object}, #{source.name} ending with #{source.last4}, expires #{source.exp_month}/#{source.exp_year}." + end + customer_info += "\n#{client.owner.update_cc_text}" + client.say(channel: data.channel, text: customer_info) + else + client.say(channel: data.channel, text: "Not a subscriber. #{client.owner.subscribe_text}") + end + logger.info "SUBSCRIPTION: #{client.owner} - #{data.user}" + end + end + end +end diff --git a/slack-sup/info.rb b/slack-sup/info.rb new file mode 100644 index 0000000..261317c --- /dev/null +++ b/slack-sup/info.rb @@ -0,0 +1,11 @@ +module SlackSup + INFO = <<~EOS.freeze + Slack Sup' #{SlackSup::VERSION} + + © 2017 Daniel Doubrovkine & Contributors, MIT License + https://twitter.com/dblockdotorg + + Service at #{SlackSup::Service.url} + Open-Source at https://github.com/dblock/slack-sup + EOS +end diff --git a/slack-sup/models.rb b/slack-sup/models.rb new file mode 100644 index 0000000..a7273fe --- /dev/null +++ b/slack-sup/models.rb @@ -0,0 +1,3 @@ +require 'slack-sup/models/error' +require 'slack-sup/models/team' +require 'slack-sup/models/user' diff --git a/slack-sup/models/error.rb b/slack-sup/models/error.rb new file mode 100644 index 0000000..244bb80 --- /dev/null +++ b/slack-sup/models/error.rb @@ -0,0 +1,4 @@ +module SlackSup + class Error < StandardError + end +end diff --git a/slack-sup/models/team.rb b/slack-sup/models/team.rb new file mode 100644 index 0000000..d1981a2 --- /dev/null +++ b/slack-sup/models/team.rb @@ -0,0 +1,60 @@ +class Team + field :api, type: Boolean, default: false + + field :stripe_customer_id, type: String + field :subscribed, type: Boolean, default: false + field :subscribed_at, type: DateTime + + scope :api, -> { where(api: true) } + + after_update :inform_subscribed_changed! + + def asleep?(dt = 2.weeks) + return false unless subscription_expired? + time_limit = Time.now - dt + created_at <= time_limit + end + + def inform!(message) + client = Slack::Web::Client.new(token: token) + channels = client.channels_list['channels'].select { |channel| channel['is_member'] } + return unless channels.any? + channel = channels.first + logger.info "Sending '#{message}' to #{self} on ##{channel['name']}." + client.chat_postMessage(text: message, channel: channel['id'], as_user: true) + end + + def subscription_expired? + return false if subscribed? + (created_at + 1.week) < Time.now + end + + def subscribe_text + [trial_expired_text, subscribe_team_text].compact.join(' ') + end + + def update_cc_text + "Update your credit card info at #{SlackSup::Service.url}/update_cc?team_id=#{team_id}." + end + + private + + def trial_expired_text + return unless subscription_expired? + 'Your trial subscription has expired.' + end + + def subscribe_team_text + "Subscribe your team for $39.99 a year at #{SlackSup::Service.url}/subscribe?team_id=#{team_id}." + end + + SUBSCRIBED_TEXT = <<~EOS.freeze + Your team has been subscribed. Thanks for being a customer! + Follow https://twitter.com/playplayio for news and updates. +EOS + + def inform_subscribed_changed! + return unless subscribed? && subscribed_changed? + inform! SUBSCRIBED_TEXT + end +end diff --git a/slack-sup/models/user.rb b/slack-sup/models/user.rb new file mode 100644 index 0000000..bd8b557 --- /dev/null +++ b/slack-sup/models/user.rb @@ -0,0 +1,38 @@ +class User + include Mongoid::Document + include Mongoid::Timestamps + + field :user_id, type: String + field :user_name, type: String + + belongs_to :team, index: true + validates_presence_of :team + + index({ user_id: 1, team_id: 1 }, unique: true) + index(user_name: 1, team_id: 1) + + def slack_mention + "<@#{user_id}>" + end + + def self.find_by_slack_mention!(team, user_name) + user_match = user_name.match(/^<@(.*)>$/) + query = user_match ? { user_id: user_match[1] } : { user_name: ::Regexp.new("^#{user_name}$", 'i') } + user = User.where(query.merge(team: team)).first + raise SlackSup::Error, "I don't know who #{user_name} is!" unless user + user + end + + # Find an existing record, update the username if necessary, otherwise create a user record. + def self.find_create_or_update_by_slack_id!(client, slack_id) + instance = User.where(team: client.owner, user_id: slack_id).first + instance_info = Hashie::Mash.new(client.web_client.users_info(user: slack_id)).user + instance.update_attributes!(user_name: instance_info.name) if instance && instance.user_name != instance_info.name + instance ||= User.create!(team: client.owner, user_id: slack_id, user_name: instance_info.name) + instance + end + + def to_s + user_name + end +end diff --git a/slack-sup/server.rb b/slack-sup/server.rb new file mode 100644 index 0000000..7cdacff --- /dev/null +++ b/slack-sup/server.rb @@ -0,0 +1,13 @@ +module SlackSup + class Server < SlackRubyBotServer::Server + CHANNEL_JOINED_MESSAGE = <<~EOS.freeze + Thanks for installing Slack Sup'! + Type `@sup help` for instructions. + EOS + + on :channel_joined do |client, data| + logger.info "#{client.owner.name}: joined ##{data.channel['name']}." + client.say(channel: data.channel['id'], text: CHANNEL_JOINED_MESSAGE) + end + end +end diff --git a/slack-sup/service.rb b/slack-sup/service.rb new file mode 100644 index 0000000..43e99bc --- /dev/null +++ b/slack-sup/service.rb @@ -0,0 +1,7 @@ +module SlackSup + class Service < SlackRubyBotServer::Service + def self.url + ENV['URL'] || (ENV['RACK_ENV'] == 'development' ? 'http://localhost:5000' : 'https://sup.playplay.io') + end + end +end diff --git a/slack-sup/version.rb b/slack-sup/version.rb new file mode 100644 index 0000000..33871b6 --- /dev/null +++ b/slack-sup/version.rb @@ -0,0 +1,3 @@ +module SlackSup + VERSION = '0.2.0'.freeze +end diff --git a/spec/api/404_spec.rb b/spec/api/404_spec.rb new file mode 100644 index 0000000..0be75d3 --- /dev/null +++ b/spec/api/404_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Api do + include Api::Test::EndpointTest + + context '404' do + it 'returns a plain 404' do + get '/foobar' + expect(last_response.status).to eq 404 + expect(last_response.body).to eq '404 Not Found' + end + end +end diff --git a/spec/api/cors_spec.rb b/spec/api/cors_spec.rb new file mode 100644 index 0000000..c28b888 --- /dev/null +++ b/spec/api/cors_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Api do + include Api::Test::EndpointTest + + context 'CORS' do + it 'supports options' do + options '/api', {}, + 'HTTP_ORIGIN' => '*', + 'HTTP_ACCESS_CONTROL_REQUEST_HEADERS' => 'Origin, Accept, Content-Type', + 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'GET' + + expect(last_response.status).to eq 200 + expect(last_response.headers['Access-Control-Allow-Origin']).to eq '*' + expect(last_response.headers['Access-Control-Expose-Headers']).to eq '' + end + it 'includes Access-Control-Allow-Origin in the response' do + get '/api', {}, 'HTTP_ORIGIN' => '*' + expect(last_response.status).to eq 200 + expect(last_response.headers['Access-Control-Allow-Origin']).to eq '*' + end + it 'includes Access-Control-Allow-Origin in errors' do + get '/api/invalid', {}, 'HTTP_ORIGIN' => '*' + expect(last_response.status).to eq 404 + expect(last_response.headers['Access-Control-Allow-Origin']).to eq '*' + end + end +end diff --git a/spec/api/documentation_spec.rb b/spec/api/documentation_spec.rb new file mode 100644 index 0000000..6334e80 --- /dev/null +++ b/spec/api/documentation_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe Api do + include Api::Test::EndpointTest + + context 'swagger root' do + subject do + get '/api/swagger_doc' + JSON.parse(last_response.body) + end + it 'documents root level apis' do + expect(subject['paths'].keys).to eq ['/api/status', '/api/teams/{id}', '/api/teams', '/api/subscriptions', '/api/credit_cards'] + end + end + + context 'teams' do + subject do + get '/api/swagger_doc/teams' + JSON.parse(last_response.body) + end + it 'documents teams apis' do + expect(subject['paths'].keys).to eq ['/api/teams/{id}', '/api/teams'] + end + end +end diff --git a/spec/api/endpoints/credit_cards_endpoint_spec.rb b/spec/api/endpoints/credit_cards_endpoint_spec.rb new file mode 100644 index 0000000..d6152e4 --- /dev/null +++ b/spec/api/endpoints/credit_cards_endpoint_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Api::Endpoints::CreditCardsEndpoint do + include Api::Test::EndpointTest + + context 'credit cards' do + it 'requires stripe parameters' do + expect { client.credit_cards._post }.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['message']).to eq 'Invalid parameters.' + expect(json['type']).to eq 'param_error' + end + end + context 'premium team without a stripe customer id' do + let!(:team) { Fabricate(:team, subscribed: true, stripe_customer_id: nil) } + it 'fails to update credit_card' do + expect do + client.credit_cards._post( + team_id: team._id, + stripe_token: 'token' + ) + end.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['error']).to eq 'Not a Subscriber' + end + end + end + context 'existing premium team' do + include_context :stripe_mock + let!(:team) { Fabricate(:team) } + before do + stripe_helper.create_plan(id: 'slack-sup-yearly', amount: 3999) + customer = Stripe::Customer.create( + source: stripe_helper.generate_card_token, + plan: 'slack-sup-yearly', + email: 'foo@bar.com' + ) + expect_any_instance_of(Team).to receive(:inform!).once + team.update_attributes!(subscribed: true, stripe_customer_id: customer['id']) + end + it 'updates a credit card' do + new_source = stripe_helper.generate_card_token + client.credit_cards._post( + team_id: team._id, + stripe_token: new_source, + stripe_token_type: 'card' + ) + team.reload + customer = Stripe::Customer.retrieve(team.stripe_customer_id) + expect(customer.source).to eq new_source + end + end + end +end diff --git a/spec/api/endpoints/root_endpoint_spec.rb b/spec/api/endpoints/root_endpoint_spec.rb new file mode 100644 index 0000000..3c52bfc --- /dev/null +++ b/spec/api/endpoints/root_endpoint_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Api::Endpoints::RootEndpoint do + include Api::Test::EndpointTest + + it 'hypermedia root' do + get '/api' + expect(last_response.status).to eq 200 + links = JSON.parse(last_response.body)['_links'] + expect(links.keys.sort).to eq(%w[self status subscriptions credit_cards team teams].sort) + end + it 'follows all links' do + get '/api' + expect(last_response.status).to eq 200 + links = JSON.parse(last_response.body)['_links'] + links.each_pair do |_key, h| + href = h['href'] + next if href.include?('{') # templated link + next if href == 'http://example.org/api/subscriptions' + next if href == 'http://example.org/api/credit_cards' + get href.gsub('http://example.org', '') + expect(last_response.status).to eq 200 + expect(JSON.parse(last_response.body)).to_not eq({}) + end + end + it 'rewrites encoded HAL links to make them clickable' do + get '/api/teams/%7B?cursor,size%7D' + expect(last_response.status).to eq 302 + expect(last_response.headers['Location']).to eq '/api/teams/' + end +end diff --git a/spec/api/endpoints/status_endpoint_spec.rb b/spec/api/endpoints/status_endpoint_spec.rb new file mode 100644 index 0000000..aa08919 --- /dev/null +++ b/spec/api/endpoints/status_endpoint_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Api::Endpoints::StatusEndpoint do + include Api::Test::EndpointTest + + before do + allow_any_instance_of(Team).to receive(:ping!).and_return(ok: 1) + end + + context 'status' do + it 'returns a status' do + status = client.status + expect(status.teams_count).to eq 0 + end + + context 'with a team' do + let!(:team) { Fabricate(:team, active: false) } + it 'returns a status with ping' do + status = client.status + expect(status.teams_count).to eq 1 + ping = status.ping + expect(ping['ok']).to eq 1 + end + end + end +end diff --git a/spec/api/endpoints/subscriptions_endpoint_spec.rb b/spec/api/endpoints/subscriptions_endpoint_spec.rb new file mode 100644 index 0000000..b93c7b7 --- /dev/null +++ b/spec/api/endpoints/subscriptions_endpoint_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe Api::Endpoints::SubscriptionsEndpoint do + include Api::Test::EndpointTest + + context 'subcriptions' do + it 'requires stripe parameters' do + expect { client.subscriptions._post }.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['message']).to eq 'Invalid parameters.' + expect(json['type']).to eq 'param_error' + end + end + context 'subscribed team' do + let!(:team) { Fabricate(:team, subscribed: true, stripe_customer_id: 'customer_id') } + it 'fails to create a subscription' do + expect do + client.subscriptions._post( + team_id: team.team_id, + stripe_token: 'token', + stripe_token_type: 'card', + stripe_email: 'foo@bar.com' + ) + end.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['error']).to eq 'Already Subscribed' + end + end + end + context 'non-subscribed team with a customer_id' do + let!(:team) { Fabricate(:team, stripe_customer_id: 'customer_id') } + it 'fails to create a subscription' do + expect do + client.subscriptions._post( + team_id: team.team_id, + stripe_token: 'token', + stripe_token_type: 'card', + stripe_email: 'foo@bar.com' + ) + end.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['error']).to eq 'Customer Already Registered' + end + end + end + context 'existing team' do + let!(:team) { Fabricate(:team) } + it 'creates a subscription' do + expect(Stripe::Customer).to receive(:create).with( + source: 'token', + plan: 'slack-sup-monthly', + email: 'foo@bar.com', + metadata: { + id: team._id, + team_id: team.team_id, + name: team.name, + domain: team.domain + } + ).and_return('id' => 'customer_id') + expect_any_instance_of(Team).to receive(:inform!).once + client.subscriptions._post( + team_id: team.team_id, + stripe_token: 'token', + stripe_token_type: 'card', + stripe_email: 'foo@bar.com' + ) + team.reload + expect(team.subscribed).to be true + expect(team.subscribed_at).to_not be nil + expect(team.stripe_customer_id).to eq 'customer_id' + end + end + end +end diff --git a/spec/api/endpoints/teams_endpoint_spec.rb b/spec/api/endpoints/teams_endpoint_spec.rb new file mode 100644 index 0000000..c6becf9 --- /dev/null +++ b/spec/api/endpoints/teams_endpoint_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' + +describe Api::Endpoints::TeamsEndpoint do + include Api::Test::EndpointTest + + context 'team' do + it 'requires code' do + expect { client.teams._post }.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['message']).to eq 'Invalid parameters.' + expect(json['type']).to eq 'param_error' + end + end + + context 'register' do + before do + oauth_access = { 'bot' => { 'bot_access_token' => 'token' }, 'team_id' => 'team_id', 'team_name' => 'team_name' } + ENV['SLACK_CLIENT_ID'] = 'client_id' + ENV['SLACK_CLIENT_SECRET'] = 'client_secret' + allow_any_instance_of(Slack::Web::Client).to receive(:oauth_access).with( + hash_including( + code: 'code', + client_id: 'client_id', + client_secret: 'client_secret' + ) + ).and_return(oauth_access) + end + after do + ENV.delete('SLACK_CLIENT_ID') + ENV.delete('SLACK_CLIENT_SECRET') + end + it 'creates a team' do + expect(SlackSup::Service.instance).to receive(:start!) + expect do + team = client.teams._post(code: 'code') + expect(team.team_id).to eq 'team_id' + expect(team.name).to eq 'team_name' + team = Team.find(team.id) + expect(team.token).to eq 'token' + end.to change(Team, :count).by(1) + end + it 'reactivates a deactivated team' do + expect(SlackSup::Service.instance).to receive(:start!) + existing_team = Fabricate(:team, token: 'token', active: false) + expect do + team = client.teams._post(code: 'code') + expect(team.team_id).to eq existing_team.team_id + expect(team.name).to eq existing_team.name + expect(team.active).to be true + team = Team.find(team.id) + expect(team.token).to eq 'token' + expect(team.active).to be true + end.to_not change(Team, :count) + end + it 'returns a useful error when team already exists' do + existing_team = Fabricate(:team, token: 'token') + expect { client.teams._post(code: 'code') }.to raise_error Faraday::ClientError do |e| + json = JSON.parse(e.response[:body]) + expect(json['message']).to eq "Team #{existing_team.name} is already registered." + end + end + it 'reactivates a deactivated team with a different code' do + expect(SlackSup::Service.instance).to receive(:start!) + existing_team = Fabricate(:team, api: true, token: 'old', team_id: 'team_id', active: false) + expect do + team = client.teams._post(code: 'code') + expect(team.team_id).to eq existing_team.team_id + expect(team.name).to eq existing_team.name + expect(team.active).to be true + team = Team.find(team.id) + expect(team.token).to eq 'token' + expect(team.active).to be true + end.to_not change(Team, :count) + end + end + end +end diff --git a/spec/api/robots_spec.rb b/spec/api/robots_spec.rb new file mode 100644 index 0000000..4bad7b0 --- /dev/null +++ b/spec/api/robots_spec.rb @@ -0,0 +1,12 @@ +require 'spec_helper' + +describe Api do + include Api::Test::EndpointTest + + it 'returns a robots.txt that disallows indexing' do + get '/robots.txt' + expect(last_response.status).to eq 200 + expect(last_response.headers['Content-Type']).to eq 'text/plain' + expect(last_response.body).to eq "User-Agent: *\nDisallow: /api" + end +end diff --git a/spec/fabricators/team_fabricator.rb b/spec/fabricators/team_fabricator.rb new file mode 100644 index 0000000..c7a948d --- /dev/null +++ b/spec/fabricators/team_fabricator.rb @@ -0,0 +1,7 @@ +Fabricator(:team) do + token { Fabricate.sequence(:team_token) { |i| "abc-#{i}" } } + team_id { Fabricate.sequence(:team_id) { |i| "T#{i}" } } + name { Faker::Lorem.word } + api { true } + created_at { Time.now - 2.weeks } +end diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb new file mode 100644 index 0000000..1864193 --- /dev/null +++ b/spec/fabricators/user_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:user) do + user_id { Fabricate.sequence(:user_id) { |i| "U#{i}" } } + user_name { Faker::Internet.user_name } + team { Team.first || Fabricate(:team) } +end diff --git a/spec/fixtures/slack/auth_test.yml b/spec/fixtures/slack/auth_test.yml new file mode 100644 index 0000000..1ce7104 --- /dev/null +++ b/spec/fixtures/slack/auth_test.yml @@ -0,0 +1,57 @@ +--- +http_interactions: +- request: + method: post + uri: https://slack.com/api/auth.test + body: + encoding: UTF-8 + string: token=token + headers: + Accept: + - application/json; charset=utf-8 + User-Agent: + - Slack Ruby Gem 1.1.1 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - private, no-cache, no-store, must-revalidate + Content-Type: + - application/json; charset=utf-8 + Date: + - Tue, 28 Apr 2015 12:55:23 GMT + Expires: + - Mon, 26 Jul 1997 05:00:00 GMT + Pragma: + - no-cache + Server: + - Apache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Vary: + - Accept-Encoding + X-Accepted-Oauth-Scopes: + - identify + X-Content-Type-Options: + - nosniff + X-Oauth-Scopes: + - identify,read,post,client + X-Xss-Protection: + - '0' + Content-Length: + - '128' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"ok":true,"url":"https:\/\/rubybot.slack.com\/","team":"team_name","user":"user_name","team_id":"TDEADBEEF","user_id":"UBAADFOOD"}' + http_version: + recorded_at: Tue, 28 Apr 2015 12:55:22 GMT diff --git a/spec/fixtures/slack/team_info.yml b/spec/fixtures/slack/team_info.yml new file mode 100644 index 0000000..2d7f3b3 --- /dev/null +++ b/spec/fixtures/slack/team_info.yml @@ -0,0 +1,60 @@ +--- +http_interactions: +- request: + method: post + uri: https://slack.com/api/team.info + body: + encoding: UTF-8 + string: token=token + headers: + Accept: + - application/json; charset=utf-8 + User-Agent: + - Slack Ruby Client/0.5.0 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Origin: + - "*" + Cache-Control: + - private, no-cache, no-store, must-revalidate + Content-Security-Policy: + - referrer no-referrer; + Content-Type: + - application/json; charset=utf-8 + Date: + - Thu, 17 Dec 2015 21:34:12 GMT + Expires: + - Mon, 26 Jul 1997 05:00:00 GMT + Pragma: + - no-cache + Server: + - Apache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Vary: + - Accept-Encoding + X-Accepted-Oauth-Scopes: + - team:read,read + X-Content-Type-Options: + - nosniff + X-Oauth-Scopes: + - identify,read,post,client + X-Xss-Protection: + - '0' + Content-Length: + - '249' + Connection: + - keep-alive + body: + encoding: ASCII-8BIT + string: '{"ok":true,"team":{"id":"T04KB5WQH","name":"dblock","domain":"dblockdotorg","email_domain":"dblock.org","icon":{"image_34":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_34.jpg","image_44":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_44.jpg","image_68":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_68.jpg","image_88":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_88.jpg","image_102":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_102.jpg","image_132":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_132.jpg","image_original":"https:\/\/s3-us-west-2.amazonaws.com\/slack-files2\/avatars\/2015-04-28\/4657218807_d480d2ee610d2e8aacfe_original.jpg"}}}' + http_version: + recorded_at: Thu, 17 Dec 2015 21:34:12 GMT +recorded_with: VCR 3.0.0 diff --git a/spec/fixtures/slack/user_info.yml b/spec/fixtures/slack/user_info.yml new file mode 100644 index 0000000..9751a21 --- /dev/null +++ b/spec/fixtures/slack/user_info.yml @@ -0,0 +1,59 @@ +--- +http_interactions: +- request: + method: post + uri: https://slack.com/api/users.info + body: + encoding: UTF-8 + string: token=token&user=user + headers: + Accept: + - application/json; charset=utf-8 + User-Agent: + - Slack Ruby Gem 1.1.1 + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Access-Control-Allow-Origin: + - '*' + Cache-Control: + - private, no-cache, no-store, must-revalidate + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 29 Apr 2015 16:10:34 GMT + Expires: + - Mon, 26 Jul 1997 05:00:00 GMT + Pragma: + - no-cache + Server: + - Apache + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Vary: + - Accept-Encoding + X-Accepted-Oauth-Scopes: + - read + X-Content-Type-Options: + - nosniff + X-Oauth-Scopes: + - identify,read,post,client + X-Xss-Protection: + - '0' + Content-Length: + - '448' + Connection: + - keep-alive + body: + encoding: UTF-8 + string: '{"ok":true,"user":{"id":"U007","name":"username","deleted":false,"status":null,"color":"9f69e7","real_name":"","tz":"America\/Indiana\/Indianapolis","tz_label":"Eastern + Daylight Time","tz_offset":-14400,"profile":{"real_name":"","real_name_normalized":"","email":"dblock@dblock.org","image_24":"https:\/\/secure.gravatar.com\/avatar\/3d925b45ac07ec0ae5bd04888f6c5b61.jpg?s=24&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F3654%2Fimg%2Favatars%2Fava_0015-24.png","image_32":"https:\/\/secure.gravatar.com\/avatar\/3d925b45ac07ec0ae5bd04888f6c5b61.jpg?s=32&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F3654%2Fimg%2Favatars%2Fava_0015-32.png","image_48":"https:\/\/secure.gravatar.com\/avatar\/3d925b45ac07ec0ae5bd04888f6c5b61.jpg?s=48&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F3654%2Fimg%2Favatars%2Fava_0015-48.png","image_72":"https:\/\/secure.gravatar.com\/avatar\/3d925b45ac07ec0ae5bd04888f6c5b61.jpg?s=72&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F3654%2Fimg%2Favatars%2Fava_0015-72.png","image_192":"https:\/\/secure.gravatar.com\/avatar\/3d925b45ac07ec0ae5bd04888f6c5b61.jpg?s=192&d=https%3A%2F%2Fslack.global.ssl.fastly.net%2F272a%2Fimg%2Favatars%2Fava_0015.png"},"is_admin":true,"is_owner":true,"is_primary_owner":true,"is_restricted":false,"is_ultra_restricted":false,"is_bot":false,"has_files":false}}' + http_version: + recorded_at: Wed, 29 Apr 2015 16:10:34 GMT + diff --git a/spec/integration/subscribe_spec.rb b/spec/integration/subscribe_spec.rb new file mode 100644 index 0000000..f68ae8a --- /dev/null +++ b/spec/integration/subscribe_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe 'Subscribe', js: true, type: :feature do + context 'without team_id' do + before do + visit '/subscribe' + end + it 'requires a team' do + expect(find('#messages')).to have_text('Missing or invalid team ID.') + find('#subscribe', visible: false) + end + end + context 'for a subscribed team' do + let!(:team) { Fabricate(:team, subscribed: true) } + before do + visit "/subscribe?team_id=#{team.team_id}" + end + it 'displays an error' do + expect(find('#messages')).to have_text("Team #{team.name} is already subscribed, thank you for your support.") + find('#subscribe', visible: false) + end + end + context 'for a team' do + let!(:team) { Fabricate(:team) } + before do + ENV['STRIPE_API_PUBLISHABLE_KEY'] = 'pk_test_804U1vUeVeTxBl8znwriXskf' + end + after do + ENV.delete 'STRIPE_API_PUBLISHABLE_KEY' + end + it 'subscribes team' do + visit "/subscribe?team_id=#{team.team_id}" + expect(find('#messages')).to have_text("Subscribe team #{team.name} for $39.99 a year.") + + expect_any_instance_of(Team).to receive(:inform!).with(Team::SUBSCRIBED_TEXT) + + find('#subscribe', visible: true) + + expect(Stripe::Customer).to receive(:create).and_return('id' => 'customer_id') + + find('#subscribeButton').click + stripe_iframe = all('iframe[name=stripe_checkout_app]').last + Capybara.within_frame stripe_iframe do + page.find_field('Email').set 'foo@bar.com' + page.find_field('Card number').set '4242 4242 4242 4242' + page.find_field('MM / YY').set '12/42' + page.find_field('CVC').set '123' + find('button[type="submit"]').click + end + + sleep 5 + + find('#subscribe', visible: false) + expect(find('#messages')).to have_text("Team #{team.name} successfully subscribed. Thank you for your support!") + + team.reload + expect(team.subscribed).to be true + expect(team.stripe_customer_id).to eq 'customer_id' + end + end +end diff --git a/spec/integration/teams_spec.rb b/spec/integration/teams_spec.rb new file mode 100644 index 0000000..5024801 --- /dev/null +++ b/spec/integration/teams_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe 'Teams', js: true, type: :feature do + before do + ENV['SLACK_CLIENT_ID'] = 'client_id' + ENV['SLACK_CLIENT_SECRET'] = 'client_secret' + end + after do + ENV.delete 'SLACK_CLIENT_ID' + ENV.delete 'SLACK_CLIENT_SECRET' + end + context 'oauth', vcr: { cassette_name: 'auth_test' } do + it 'registers a team' do + allow_any_instance_of(Team).to receive(:ping!).and_return(ok: true) + expect(SlackSup::Service.instance).to receive(:start!) + oauth_access = { 'bot' => { 'bot_access_token' => 'token' }, 'team_id' => 'team_id', 'team_name' => 'team_name' } + allow_any_instance_of(Slack::Web::Client).to receive(:oauth_access).with(hash_including(code: 'code')).and_return(oauth_access) + expect do + visit '/?code=code' + expect(page.find('#messages')).to have_content 'Team successfully registered!' + end.to change(Team, :count).by(1) + end + end + context 'homepage' do + before do + visit '/' + end + it 'displays index.html page' do + expect(title).to eq('Sup for Slack') + end + it 'includes a link to add to slack with the client id' do + expect(find("a[href='https://slack.com/oauth/authorize?scope=bot&client_id=#{ENV['SLACK_CLIENT_ID']}']")) + end + end +end diff --git a/spec/integration/update_cc_spec.rb b/spec/integration/update_cc_spec.rb new file mode 100644 index 0000000..d9192a1 --- /dev/null +++ b/spec/integration/update_cc_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe 'Update cc', js: true, type: :feature do + context 'with a stripe key' do + before do + ENV['STRIPE_API_PUBLISHABLE_KEY'] = 'pk_test_804U1vUeVeTxBl8znwriXskf' + end + after do + ENV.delete 'STRIPE_API_PUBLISHABLE_KEY' + end + context 'a team with a stripe customer ID' do + let!(:team) { Fabricate(:team, stripe_customer_id: 'stripe_customer_id') } + it 'updates cc' do + visit "/update_cc?team_id=#{team.team_id}" + expect(find('h3')).to have_text('Sup for Slack: Update Credit Card Info') + customer = double + expect(Stripe::Customer).to receive(:retrieve).and_return(customer) + expect(customer).to receive(:source=) + expect(customer).to receive(:save) + click_button 'Update Credit Card' + sleep 1 + stripe_iframe = all('iframe[name=stripe_checkout_app]').last + Capybara.within_frame stripe_iframe do + page.find_field('Email').set 'foo@bar.com' + page.find_field('Card number').set '4012 8888 8888 1881' + page.find_field('MM / YY').set '12/42' + page.find_field('CVC').set '345' + find('button[type="submit"]').click + end + sleep 5 + expect(find('#messages')).to have_text("Successfully updated team #{team.name} credit card. Thank you for your support!") + end + end + context 'a team without a stripe customer ID' do + let!(:team) { Fabricate(:team, stripe_customer_id: nil) } + it 'displays error' do + visit "/update_cc?team_id=#{team.team_id}" + expect(find('h3')).to have_text('Sup for Slack: Update Credit Card Info') + click_button 'Update Credit Card' + sleep 1 + stripe_iframe = all('iframe[name=stripe_checkout_app]').last + Capybara.within_frame stripe_iframe do + page.find_field('Email').set 'foo@bar.com' + page.find_field('Card number').set '4012 8888 8888 1881' + page.find_field('MM / YY').set '12/42' + page.find_field('CVC').set '345' + find('button[type="submit"]').click + end + sleep 5 + expect(find('#messages')).to have_text('Not a Subscriber') + end + end + end +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb new file mode 100644 index 0000000..3f81f7c --- /dev/null +++ b/spec/models/team_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe Team do + context '#find_or_create_from_env!' do + before do + ENV['SLACK_API_TOKEN'] = 'token' + end + context 'team', vcr: { cassette_name: 'team_info' } do + it 'creates a team' do + expect { Team.find_or_create_from_env! }.to change(Team, :count).by(1) + team = Team.first + expect(team.team_id).to eq 'T04KB5WQH' + expect(team.name).to eq 'dblock' + expect(team.domain).to eq 'dblockdotorg' + expect(team.token).to eq 'token' + end + end + after do + ENV.delete 'SLACK_API_TOKEN' + end + end + context '#purge!' do + let!(:active_team) { Fabricate(:team) } + let!(:inactive_team) { Fabricate(:team, active: false) } + let!(:inactive_team_a_week_ago) { Fabricate(:team, updated_at: 1.week.ago, active: false) } + let!(:inactive_team_two_weeks_ago) { Fabricate(:team, updated_at: 2.weeks.ago, active: false) } + let!(:inactive_team_a_month_ago) { Fabricate(:team, updated_at: 1.month.ago, active: false) } + it 'destroys teams inactive for two weeks' do + expect do + Team.purge! + end.to change(Team, :count).by(-2) + expect(Team.find(active_team.id)).to eq active_team + expect(Team.find(inactive_team.id)).to eq inactive_team + expect(Team.find(inactive_team_a_week_ago.id)).to eq inactive_team_a_week_ago + expect(Team.find(inactive_team_two_weeks_ago.id)).to be nil + expect(Team.find(inactive_team_a_month_ago.id)).to be nil + end + end + context '#asleep?' do + context 'default' do + let(:team) { Fabricate(:team, created_at: Time.now.utc) } + it 'false' do + expect(team.asleep?).to be false + end + end + context 'team created two weeks ago' do + let(:team) { Fabricate(:team, created_at: 2.weeks.ago) } + it 'is asleep' do + expect(team.asleep?).to be true + end + end + context 'team created two weeks ago and subscribed' do + let(:team) { Fabricate(:team, created_at: 2.weeks.ago, subscribed: true) } + before do + allow(team).to receive(:inform_subscribed_changed!) + team.update_attributes!(subscribed: true) + end + it 'is not asleep' do + expect(team.asleep?).to be false + end + end + context 'team created over two weeks ago' do + let(:team) { Fabricate(:team, created_at: 2.weeks.ago - 1.day) } + it 'is asleep' do + expect(team.asleep?).to be true + end + end + context 'team created over two weeks ago and subscribed' do + let(:team) { Fabricate(:team, created_at: 2.weeks.ago - 1.day, subscribed: true) } + it 'is not asleep' do + expect(team.asleep?).to be false + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..67b847b --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe User do + context '#find_by_slack_mention!' do + before do + @user = Fabricate(:user) + end + it 'finds by slack id' do + expect(User.find_by_slack_mention!(@user.team, "<@#{@user.user_id}>")).to eq @user + end + it 'finds by username' do + expect(User.find_by_slack_mention!(@user.team, @user.user_name)).to eq @user + end + it 'finds by username is case-insensitive' do + expect(User.find_by_slack_mention!(@user.team, @user.user_name.capitalize)).to eq @user + end + it 'requires a known user' do + expect do + User.find_by_slack_mention!(@user.team, '<@nobody>') + end.to raise_error SlackSup::Error, "I don't know who <@nobody> is!" + end + end + context '#find_create_or_update_by_slack_id!', vcr: { cassette_name: 'user_info' } do + let!(:team) { Fabricate(:team) } + let(:client) { SlackRubyBot::Client.new } + before do + client.owner = team + end + context 'without a user' do + it 'creates a user' do + expect do + user = User.find_create_or_update_by_slack_id!(client, 'U42') + expect(user).to_not be_nil + expect(user.user_id).to eq 'U42' + expect(user.user_name).to eq 'username' + end.to change(User, :count).by(1) + end + end + context 'with a user' do + before do + @user = Fabricate(:user, team: team) + end + it 'creates another user' do + expect do + User.find_create_or_update_by_slack_id!(client, 'U42') + end.to change(User, :count).by(1) + end + it 'updates the username of the existing user' do + expect do + User.find_create_or_update_by_slack_id!(client, @user.user_id) + end.to_not change(User, :count) + expect(@user.reload.user_name).to eq 'username' + end + end + end +end diff --git a/spec/slack-sup/app_spec.rb b/spec/slack-sup/app_spec.rb new file mode 100644 index 0000000..665ae87 --- /dev/null +++ b/spec/slack-sup/app_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe SlackSup::App do + subject do + SlackSup::App.instance + end + context '#instance' do + it 'is an instance of the app' do + expect(subject).to be_a_kind_of(SlackRubyBotServer::App) + expect(subject).to be_an_instance_of(SlackSup::App) + end + end + context '#purge_inactive_teams!' do + it 'purges teams' do + expect(Team).to receive(:purge!) + subject.send(:purge_inactive_teams!) + end + end + context '#deactivate_asleep_teams!' do + let!(:active_team) { Fabricate(:team, created_at: Time.now.utc) } + let!(:active_team_one_week_ago) { Fabricate(:team, created_at: 1.week.ago) } + let!(:active_team_two_weeks_ago) { Fabricate(:team, created_at: 2.weeks.ago) } + let!(:subscribed_team_a_month_ago) { Fabricate(:team, created_at: 1.month.ago, subscribed: true) } + it 'destroys teams inactive for two weeks' do + expect_any_instance_of(Team).to receive(:inform!).with( + "This integration hasn't been used for 2 weeks, deactivating. Reactivate at #{SlackSup::Service.url}. Your data will be purged in another 2 weeks." + ).once + subject.send(:deactivate_asleep_teams!) + expect(active_team.reload.active).to be true + expect(active_team_one_week_ago.reload.active).to be true + expect(active_team_two_weeks_ago.reload.active).to be false + expect(subscribed_team_a_month_ago.reload.active).to be true + end + end + context 'subscribed' do + include_context :stripe_mock + let(:plan) { stripe_helper.create_plan(id: 'slack-sup-yearly', amount: 3999) } + let(:customer) { Stripe::Customer.create(source: stripe_helper.generate_card_token, plan: plan.id, email: 'foo@bar.com') } + let!(:team) { Fabricate(:team, subscribed: true, stripe_customer_id: customer.id) } + context '#check_subscribed_teams!' do + it 'ignores active subscriptions' do + expect_any_instance_of(Team).to_not receive(:inform!) + subject.send(:check_subscribed_teams!) + end + it 'notifies past due subscription' do + customer.subscriptions.data.first['status'] = 'past_due' + expect(Stripe::Customer).to receive(:retrieve).and_return(customer) + expect_any_instance_of(Team).to receive(:inform!).with("Your subscription to StripeMock Default Plan ID ($39.99) is past due. #{team.update_cc_text}") + subject.send(:check_subscribed_teams!) + end + it 'notifies past due subscription' do + customer.subscriptions.data.first['status'] = 'canceled' + expect(Stripe::Customer).to receive(:retrieve).and_return(customer) + expect_any_instance_of(Team).to receive(:inform!).with('Your subscription to StripeMock Default Plan ID ($39.99) was canceled and your team has been downgraded. Thank you for being a customer!') + subject.send(:check_subscribed_teams!) + expect(team.reload.subscribed?).to be false + end + end + end +end diff --git a/spec/slack-sup/commands/help_spec.rb b/spec/slack-sup/commands/help_spec.rb new file mode 100644 index 0000000..5ae0c12 --- /dev/null +++ b/spec/slack-sup/commands/help_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe SlackSup::Commands::Help do + let(:app) { SlackSup::Server.new(team: team) } + let(:client) { app.send(:client) } + let(:message_hook) { SlackRubyBot::Hooks::Message.new } + context 'subscribed team' do + let!(:team) { Fabricate(:team, subscribed: true) } + it 'help' do + expect(client).to receive(:say).with(channel: 'channel', text: [SlackSup::Commands::Help::HELP, SlackSup::INFO].join("\n")) + expect(client).to receive(:say).with(channel: 'channel') + message_hook.call(client, Hashie::Mash.new(channel: 'channel', text: "#{SlackRubyBot.config.user} help")) + end + end + context 'non-subscribed team after trial' do + let!(:team) { Fabricate(:team, created_at: 2.weeks.ago) } + it 'help' do + expect(client).to receive(:say).with(channel: 'channel', text: [ + SlackSup::Commands::Help::HELP, + SlackSup::INFO, + [team.send(:trial_expired_text), team.send(:subscribe_team_text)].join(' ') + ].join("\n")) + expect(client).to receive(:say).with(channel: 'channel') + message_hook.call(client, Hashie::Mash.new(channel: 'channel', text: "#{SlackRubyBot.config.user} help")) + end + end + context 'non-subscribed team during trial' do + let!(:team) { Fabricate(:team, created_at: 1.day.ago) } + it 'help' do + expect(client).to receive(:say).with(channel: 'channel', text: [ + SlackSup::Commands::Help::HELP, + SlackSup::INFO, + team.send(:subscribe_team_text) + ].join("\n")) + expect(client).to receive(:say).with(channel: 'channel') + message_hook.call(client, Hashie::Mash.new(channel: 'channel', text: "#{SlackRubyBot.config.user} help")) + end + end +end diff --git a/spec/slack-sup/commands/subscription_spec.rb b/spec/slack-sup/commands/subscription_spec.rb new file mode 100644 index 0000000..fa5b97e --- /dev/null +++ b/spec/slack-sup/commands/subscription_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe SlackSup::Commands::Subscription, vcr: { cassette_name: 'user_info' } do + let(:app) { SlackSup::Server.new(team: team) } + let(:client) { app.send(:client) } + context 'team' do + let!(:team) { Fabricate(:team) } + it 'is a subscription feature' do + expect(message: "#{SlackRubyBot.config.user} subscription", user: 'user').to respond_with_slack_message( + "Your trial subscription has expired. Subscribe your team for $39.99 a year at #{SlackSup::Service.url}/subscribe?team_id=#{team.team_id}." + ) + end + end + context 'team without a customer ID' do + let!(:team) { Fabricate(:team, subscribed: true, stripe_customer_id: nil) } + it 'errors' do + expect(message: "#{SlackRubyBot.config.user} subscription", user: 'user').to respond_with_slack_message( + "Not a subscriber. Subscribe your team for $39.99 a year at #{SlackSup::Service.url}/subscribe?team_id=#{team.team_id}." + ) + end + end + shared_examples_for 'subscription' do + include_context :stripe_mock + context 'with a plan' do + before do + stripe_helper.create_plan(id: 'slack-sup-yearly', amount: 3999) + end + context 'a customer' do + let!(:customer) do + Stripe::Customer.create( + source: stripe_helper.generate_card_token, + plan: 'slack-sup-yearly', + email: 'foo@bar.com' + ) + end + before do + team.update_attributes!(subscribed: true, stripe_customer_id: customer['id']) + end + it 'displays subscription info' do + customer_info = "Customer since #{Time.at(customer.created).strftime('%B %d, %Y')}." + customer_info += "\nSubscribed to StripeMock Default Plan ID ($39.99)" + card = customer.sources.first + customer_info += "\nOn file Visa card, #{card.name} ending with #{card.last4}, expires #{card.exp_month}/#{card.exp_year}." + customer_info += "\n#{team.update_cc_text}" + expect(message: "#{SlackRubyBot.config.user} subscription").to respond_with_slack_message customer_info + end + end + end + end + context 'subscription team' do + let!(:team) { Fabricate(:team, subscribed: true) } + it_behaves_like 'subscription' + context 'with another team' do + let!(:team2) { Fabricate(:team) } + it_behaves_like 'subscription' + end + end +end diff --git a/spec/slack-sup/server_spec.rb b/spec/slack-sup/server_spec.rb new file mode 100644 index 0000000..edacb95 --- /dev/null +++ b/spec/slack-sup/server_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe SlackSup::Server do + let(:team) { Fabricate(:team) } + let(:client) { subject.send(:client) } + subject do + SlackSup::Server.new(team: team) + end + context '#channel_joined' do + it 'sends a welcome message' do + expect(client).to receive(:say).with(channel: 'C12345', text: SlackSup::Server::CHANNEL_JOINED_MESSAGE) + client.send(:callback, Hashie::Mash.new('channel' => { 'id' => 'C12345' }), :channel_joined) + end + end +end diff --git a/spec/slack-sup/service_spec.rb b/spec/slack-sup/service_spec.rb new file mode 100644 index 0000000..b5701ab --- /dev/null +++ b/spec/slack-sup/service_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe SlackSup::Service do + context '#url' do + before do + @rack_env = ENV['RACK_ENV'] + end + after do + ENV['RACK_ENV'] = @rack_env + end + it 'defaults to playplay.io in production' do + expect(SlackSup::Service.url).to eq 'https://sup.playplay.io' + end + context 'in development' do + before do + ENV['RACK_ENV'] = 'development' + end + it 'defaults to localhost' do + expect(SlackSup::Service.url).to eq 'http://localhost:5000' + end + end + context 'when set' do + before do + ENV['URL'] = 'updated' + end + after do + ENV.delete('URL') + end + it 'defaults to ENV' do + expect(SlackSup::Service.url).to eq 'updated' + end + end + end +end diff --git a/spec/slack-sup/version_spec.rb b/spec/slack-sup/version_spec.rb new file mode 100644 index 0000000..48c7248 --- /dev/null +++ b/spec/slack-sup/version_spec.rb @@ -0,0 +1,7 @@ +require 'spec_helper' + +describe SlackSup do + it 'has a version' do + expect(SlackSup::VERSION).to_not be nil + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..358f714 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,15 @@ +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..')) + +require 'fabrication' +require 'faker' +require 'hyperclient' +require 'webmock/rspec' + +ENV['RACK_ENV'] = 'test' + +require 'slack-ruby-bot/rspec' +require 'slack-sup' + +Dir[File.join(File.dirname(__FILE__), 'support', '**/*.rb')].each do |file| + require file +end diff --git a/spec/support/api/endpoints/endpoint_test.rb b/spec/support/api/endpoints/endpoint_test.rb new file mode 100644 index 0000000..e549436 --- /dev/null +++ b/spec/support/api/endpoints/endpoint_test.rb @@ -0,0 +1,30 @@ +module Api + module Test + module EndpointTest + extend ActiveSupport::Concern + include Rack::Test::Methods + + included do + let(:client) do + Hyperclient.new('http://example.org/api') do |client| + client.headers = { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json,application/hal+json' + } + client.connection(default: false) do |conn| + conn.request :json + conn.response :json + conn.use Faraday::Response::RaiseError + conn.use FaradayMiddleware::FollowRedirects + conn.use Faraday::Adapter::Rack, app + end + end + end + end + + def app + Api::Middleware.instance + end + end + end +end diff --git a/spec/support/api/endpoints/it_behaves_like_a_cursor_api.rb b/spec/support/api/endpoints/it_behaves_like_a_cursor_api.rb new file mode 100644 index 0000000..67d69a4 --- /dev/null +++ b/spec/support/api/endpoints/it_behaves_like_a_cursor_api.rb @@ -0,0 +1,66 @@ +shared_examples_for 'a cursor api' do |model| + let(:model_s) { model.name.underscore.to_sym } + let(:model_ps) { model.name.underscore.pluralize.to_sym } + context model.name do + let(:cursor_params) { @cursor_params || {} } + + before do + 12.times { Fabricate(model_s) } + end + + it 'returns 10 items by default' do + expect(client.send(model_ps, cursor_params).count).to eq 10 + end + + it 'returns 2 items' do + expect(client.send(model_ps, cursor_params.merge(size: 2)).count).to eq 2 + end + + it 'returns a first page with a cursor' do + response = client.send(model_ps, cursor_params.merge(size: 2)) + expect(response._links.self._url).to eq "http://example.org/api/#{model_ps}?#{cursor_params.merge(size: 2).to_query}" + expect(response._links.next._url).to start_with "http://example.org/api/#{model_ps}?" + expect(response._links.next._url).to match(/cursor\=.*%3A\h*/) + end + + it 'paginates over the entire collection' do + models_ids = [] + next_cursor = { size: 3 } + loop do + response = client.send(model_ps, next_cursor.merge(cursor_params)) + models_ids.concat(response.map { |instance| instance._links.self._url.gsub("http://example.org/api/#{model_ps}/", '') }) + break unless response._links[:next] + next_cursor = Hash[CGI.parse(URI.parse(response._links.next._url).query).map { |a| [a[0], a[1][0]] }] + end + expect(models_ids.uniq.count).to eq model.all.count + end + + it 'allows skipping of results via an offset query param' do + models_ids = [] + next_cursor = { size: 3, offset: 3 } + loop do + response = client.send(model_ps, next_cursor.merge(cursor_params)) + models_ids.concat(response.map { |instance| instance._links.self._url.gsub("http://example.org/api/#{model_ps}/", '') }) + break unless response._links[:next] + next_cursor = Hash[CGI.parse(URI.parse(response._links.next._url).query).map { |a| [a[0], a[1][0]] }] + end + expect(models_ids.uniq.count).to eq model.all.count - 3 + end + + context 'total count' do + it "doesn't return total_count" do + response = client.send(model_ps, cursor_params) + expect(response).to_not respond_to(:total_count) + end + it 'returns total_count when total_count query string is specified' do + response = client.send(model_ps, cursor_params.merge(total_count: true)) + expect(response.total_count).to eq model.all.count + end + end + + it 'returns all unique ids' do + instances = client.send(model_ps, cursor_params) + expect(instances.map(&:id).uniq.count).to eq 10 + end + end +end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb new file mode 100644 index 0000000..ad2ca80 --- /dev/null +++ b/spec/support/capybara.rb @@ -0,0 +1,5 @@ +require 'capybara/rspec' +Capybara.configure do |config| + config.app = Api::Middleware.instance + config.server_port = 9293 +end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb new file mode 100644 index 0000000..348130b --- /dev/null +++ b/spec/support/database_cleaner.rb @@ -0,0 +1,18 @@ +require 'database_cleaner' + +RSpec.configure do |config| + config.before :suite do + DatabaseCleaner.strategy = :truncation + DatabaseCleaner.clean_with :truncation + end + + config.after :suite do + Mongoid.purge! + end + + config.around :each do |example| + DatabaseCleaner.cleaning do + example.run + end + end +end diff --git a/spec/support/mongoid.rb b/spec/support/mongoid.rb new file mode 100644 index 0000000..4d4662b --- /dev/null +++ b/spec/support/mongoid.rb @@ -0,0 +1,8 @@ +RSpec.configure do |config| + config.before :suite do + Mongoid.logger.level = Logger::INFO + Mongo::Logger.logger.level = Logger::INFO + + Mongoid::Tasks::Database.create_indexes + end +end diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb new file mode 100644 index 0000000..0b6cc57 --- /dev/null +++ b/spec/support/rspec.rb @@ -0,0 +1,5 @@ +RSpec.configure do |config| + config.mock_with :rspec + config.expect_with :rspec + config.raise_errors_for_deprecations! +end diff --git a/spec/support/stripe.rb b/spec/support/stripe.rb new file mode 100644 index 0000000..f267e4f --- /dev/null +++ b/spec/support/stripe.rb @@ -0,0 +1,15 @@ +RSpec.shared_context :stripe_mock do + let(:stripe_helper) { StripeMock.create_test_helper } + before do + StripeMock.start + end + after do + StripeMock.stop + end +end + +RSpec.configure do |config| + config.before do + allow(Stripe).to receive(:api_key).and_return('key') + end +end diff --git a/spec/support/vcr.rb b/spec/support/vcr.rb new file mode 100644 index 0000000..6cb0a29 --- /dev/null +++ b/spec/support/vcr.rb @@ -0,0 +1,9 @@ +require 'vcr' + +VCR.configure do |config| + config.cassette_library_dir = 'spec/fixtures/slack' + config.hook_into :webmock + # config.default_cassette_options = { record: :new_episodes } + config.configure_rspec_metadata! + config.ignore_localhost = true +end diff --git a/tasks/development.rake b/tasks/development.rake new file mode 100644 index 0000000..5f9de38 --- /dev/null +++ b/tasks/development.rake @@ -0,0 +1,4 @@ +import 'tasks/rubocop.rake' +import 'tasks/rspec.rake' + +task default: %i[rubocop spec] diff --git a/tasks/production.rake b/tasks/production.rake new file mode 100644 index 0000000..fdffa2a --- /dev/null +++ b/tasks/production.rake @@ -0,0 +1 @@ +# placeholder diff --git a/tasks/rspec.rake b/tasks/rspec.rake new file mode 100644 index 0000000..71d0583 --- /dev/null +++ b/tasks/rspec.rake @@ -0,0 +1,6 @@ +require 'rspec/core' +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new(:spec) do |spec| + spec.pattern = FileList['spec/**/*_spec.rb'] +end diff --git a/tasks/rubocop.rake b/tasks/rubocop.rake new file mode 100644 index 0000000..bb71577 --- /dev/null +++ b/tasks/rubocop.rake @@ -0,0 +1,3 @@ +require 'rubocop/rake_task' + +RuboCop::RakeTask.new