| @@ -70,6 +70,20 @@ EMAIL_FROM_ADDRESS=from_address@gmail.com | ||
| 70 | 70 | # Number of lines of log messages to keep per Agent | 
| 71 | 71 | AGENT_LOG_LENGTH=200 | 
| 72 | 72 |  | 
| 73 | +######################################################################################################## | |
| 74 | +# OAuth Configuration # | |
| 75 | +# More information at the wiki: https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications # | |
| 76 | +######################################################################################################## | |
| 77 | + | |
| 78 | +TWITTER_OAUTH_KEY= | |
| 79 | +TWITTER_OAUTH_SECRET= | |
| 80 | + | |
| 81 | +THIRTY_SEVEN_SIGNALS_OAUTH_KEY= | |
| 82 | +THIRTY_SEVEN_SIGNALS_OAUTH_SECRET= | |
| 83 | + | |
| 84 | +GITHUB_OAUTH_KEY= | |
| 85 | +GITHUB_OAUTH_SECRET= | |
| 86 | + | |
| 73 | 87 | ############################# | 
| 74 | 88 | # AWS and Mechanical Turk # | 
| 75 | 89 | ############################# | 
| @@ -8,7 +8,7 @@ rvm: | ||
| 8 | 8 | - 2.1.1 | 
| 9 | 9 | - 1.9.3 | 
| 10 | 10 | before_install: | 
| 11 | - - travis_retry gem install bundler | |
| 11 | + - travis_retry gem install bundler | |
| 12 | 12 | before_script: | 
| 13 | 13 | - mysql -e 'create database huginn_test;' | 
| 14 | 14 | - bundle exec rake db:migrate db:test:prepare | 
| @@ -56,6 +56,8 @@ gem 'uglifier', '>= 1.3.0' | ||
| 56 | 56 | gem 'select2-rails', '~> 3.5.4' | 
| 57 | 57 | gem 'jquery-rails', '~> 3.1.0' | 
| 58 | 58 | gem 'ace-rails-ap', '~> 2.0.1' | 
| 59 | +gem 'spectrum-rails' | |
| 60 | + | |
| 59 | 61 |  | 
| 60 | 62 | # geokit-rails doesn't work with geokit 1.8.X but it specifies ~> 1.5 | 
| 61 | 63 | # in its own Gemfile. | 
| @@ -86,6 +88,11 @@ gem 'slack-notifier', '~> 0.5.0' | ||
| 86 | 88 | gem 'therubyracer', '~> 0.12.1' | 
| 87 | 89 | gem 'mqtt' | 
| 88 | 90 |  | 
| 91 | +gem 'omniauth' | |
| 92 | +gem 'omniauth-twitter' | |
| 93 | +gem 'omniauth-37signals' | |
| 94 | +gem 'omniauth-github' | |
| 95 | + | |
| 89 | 96 | group :development do | 
| 90 | 97 | gem 'binding_of_caller' | 
| 91 | 98 | gem 'better_errors' | 
| @@ -191,12 +191,33 @@ GEM | ||
| 191 | 191 | net-ftp-list (3.2.8) | 
| 192 | 192 | nokogiri (1.6.3.1) | 
| 193 | 193 | mini_portile (= 0.6.0) | 
| 194 | + oauth (0.4.7) | |
| 194 | 195 | oauth2 (0.9.4) | 
| 195 | 196 | faraday (>= 0.8, < 0.10) | 
| 196 | 197 | jwt (~> 1.0) | 
| 197 | 198 | multi_json (~> 1.3) | 
| 198 | 199 | multi_xml (~> 0.5) | 
| 199 | 200 | rack (~> 1.2) | 
| 201 | + omniauth (1.2.2) | |
| 202 | + hashie (>= 1.2, < 4) | |
| 203 | + rack (~> 1.0) | |
| 204 | + omniauth-37signals (1.0.5) | |
| 205 | + omniauth (~> 1.0) | |
| 206 | + omniauth-oauth2 (~> 1.0) | |
| 207 | + omniauth-github (1.1.2) | |
| 208 | + omniauth (~> 1.0) | |
| 209 | + omniauth-oauth2 (~> 1.1) | |
| 210 | + omniauth-oauth (1.0.1) | |
| 211 | + oauth | |
| 212 | + omniauth (~> 1.0) | |
| 213 | + omniauth-oauth2 (1.1.2) | |
| 214 | + faraday (>= 0.8, < 0.10) | |
| 215 | + multi_json (~> 1.3) | |
| 216 | + oauth2 (~> 0.9.3) | |
| 217 | + omniauth (~> 1.2) | |
| 218 | + omniauth-twitter (1.0.1) | |
| 219 | + multi_json (~> 1.3) | |
| 220 | + omniauth-oauth (~> 1.0) | |
| 200 | 221 | orm_adapter (0.5.0) | 
| 201 | 222 | pg (0.17.1) | 
| 202 | 223 | polyglot (0.3.5) | 
| @@ -293,6 +314,8 @@ GEM | ||
| 293 | 314 | simplecov-html (0.8.0) | 
| 294 | 315 | slack-notifier (0.5.0) | 
| 295 | 316 | slop (3.6.0) | 
| 317 | + spectrum-rails (1.3.4) | |
| 318 | + railties (>= 3.1) | |
| 296 | 319 | sprockets (2.11.0) | 
| 297 | 320 | hike (~> 1.2) | 
| 298 | 321 | multi_json (~> 1.0) | 
| @@ -400,6 +423,10 @@ DEPENDENCIES | ||
| 400 | 423 | mysql2 (~> 0.3.16) | 
| 401 | 424 | net-ftp-list (~> 3.2.8) | 
| 402 | 425 | nokogiri (~> 1.6.1) | 
| 426 | + omniauth | |
| 427 | + omniauth-37signals | |
| 428 | + omniauth-github | |
| 429 | + omniauth-twitter | |
| 403 | 430 | pg | 
| 404 | 431 | protected_attributes (~> 1.0.8) | 
| 405 | 432 | pry | 
| @@ -418,6 +445,7 @@ DEPENDENCIES | ||
| 418 | 445 | select2-rails (~> 3.5.4) | 
| 419 | 446 | shoulda-matchers | 
| 420 | 447 | slack-notifier (~> 0.5.0) | 
| 448 | + spectrum-rails | |
| 421 | 449 | therubyracer (~> 0.12.1) | 
| 422 | 450 | twilio-ruby (~> 3.11.5) | 
| 423 | 451 | twitter (~> 5.8.0) | 
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | #= require json2 | 
| 7 | 7 | #= require jquery.json-editor | 
| 8 | 8 | #= require latlon_and_geo | 
| 9 | +#= require spectrum | |
| 9 | 10 | #= require ./worker-checker | 
| 10 | 11 | #= require_self | 
| 11 | 12 |  | 
| @@ -182,6 +183,8 @@ $(document).ready -> | ||
| 182 | 183 |  | 
| 183 | 184 |          $(".description").html(json.description_html) if json.description_html? | 
| 184 | 185 |  | 
| 186 | +        $('.oauthable-form').html(json.form) if json.form? | |
| 187 | + | |
| 185 | 188 |          if $("#agent_options").hasClass("showing-default") || $("#agent_options").val().match(/\A\s*(\{\s*\}|)\s*\Z/g) | 
| 186 | 189 | window.jsonEditor.json = json.options | 
| 187 | 190 | window.jsonEditor.rebuild() | 
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | *= require select2-bootstrap | 
| 13 | 13 | *= require jquery.json-editor | 
| 14 | 14 | *= require rickshaw | 
| 15 | + *= require spectrum | |
| 15 | 16 | *= require_tree . | 
| 16 | 17 | *= require_self | 
| 17 | 18 | */ | 
| @@ -186,3 +187,17 @@ h2 .scenario, a span.label.scenario { | ||
| 186 | 187 |  .color-success { | 
| 187 | 188 | color: #5cb85c; | 
| 188 | 189 | } | 
| 190 | + | |
| 191 | +.form-group { | |
| 192 | +  .sp-replacer { | |
| 193 | + @extend .form-control; | |
| 194 | + } | |
| 195 | + | |
| 196 | +  .sp-preview { | |
| 197 | + width: 100%; | |
| 198 | + } | |
| 199 | + | |
| 200 | +  .sp-dd { | |
| 201 | + display: none; | |
| 202 | + } | |
| 203 | +} | 
| @@ -1,9 +1,50 @@ | ||
| 1 | 1 | module LiquidDroppable | 
| 2 | 2 | extend ActiveSupport::Concern | 
| 3 | 3 |  | 
| 4 | + # In subclasses of this base class, "locals" take precedence over | |
| 5 | + # methods. | |
| 4 | 6 | class Drop < Liquid::Drop | 
| 5 | - def initialize(object) | |
| 7 | + class << self | |
| 8 | + def inherited(subclass) | |
| 9 | + class << subclass | |
| 10 | + attr_reader :drop_methods | |
| 11 | + | |
| 12 | + # Make all public methods private so that #before_method | |
| 13 | + # catches everything. | |
| 14 | + def drop_methods! | |
| 15 | + return if @drop_methods | |
| 16 | + | |
| 17 | + @drop_methods = Set.new | |
| 18 | + | |
| 19 | +            (public_instance_methods - Drop.public_instance_methods).each { |name| | |
| 20 | + @drop_methods << name.to_s | |
| 21 | + private name | |
| 22 | + } | |
| 23 | + end | |
| 24 | + end | |
| 25 | + end | |
| 26 | + end | |
| 27 | + | |
| 28 | + def initialize(object, locals = nil) | |
| 29 | + self.class.drop_methods! | |
| 30 | + | |
| 6 | 31 | @object = object | 
| 32 | +      @locals = locals || {} | |
| 33 | + end | |
| 34 | + | |
| 35 | + def before_method(name) | |
| 36 | + if @locals.include?(name) | |
| 37 | + @locals[name] | |
| 38 | + elsif self.class.drop_methods.include?(name) | |
| 39 | + __send__(name) | |
| 40 | + end | |
| 41 | + end | |
| 42 | + | |
| 43 | + def each | |
| 44 | + return to_enum(__method__) unless block_given? | |
| 45 | +      self.class.drop_methods.each { |name| | |
| 46 | + yield [name, __send__(name)] | |
| 47 | + } | |
| 7 | 48 | end | 
| 8 | 49 | end | 
| 9 | 50 |  | 
| @@ -1,6 +1,23 @@ | ||
| 1 | 1 | module LiquidInterpolatable | 
| 2 | 2 | extend ActiveSupport::Concern | 
| 3 | 3 |  | 
| 4 | + included do | |
| 5 | + validate :validate_interpolation | |
| 6 | + end | |
| 7 | + | |
| 8 | + def valid?(context = nil) | |
| 9 | + super | |
| 10 | + rescue Liquid::Error | |
| 11 | + errors.empty? | |
| 12 | + end | |
| 13 | + | |
| 14 | + def validate_interpolation | |
| 15 | + interpolated | |
| 16 | + rescue Liquid::Error => e | |
| 17 | +    errors.add(:options, "has an error with Liquid templating: #{e.message}") | |
| 18 | + false | |
| 19 | + end | |
| 20 | + | |
| 4 | 21 |    def interpolate_options(options, event = {}) | 
| 5 | 22 | case options | 
| 6 | 23 | when String | 
| @@ -0,0 +1,32 @@ | ||
| 1 | +module Oauthable | |
| 2 | + extend ActiveSupport::Concern | |
| 3 | + | |
| 4 | + included do |base| | |
| 5 | + @valid_oauth_providers = :all | |
| 6 | + attr_accessible :service_id | |
| 7 | + validates_presence_of :service_id | |
| 8 | + end | |
| 9 | + | |
| 10 | + def oauthable? | |
| 11 | + true | |
| 12 | + end | |
| 13 | + | |
| 14 | + def valid_services_for(user) | |
| 15 | + if valid_oauth_providers == :all | |
| 16 | + user.available_services | |
| 17 | + else | |
| 18 | + user.available_services.where(provider: valid_oauth_providers) | |
| 19 | + end | |
| 20 | + end | |
| 21 | + | |
| 22 | + def valid_oauth_providers | |
| 23 | + self.class.valid_oauth_providers | |
| 24 | + end | |
| 25 | + | |
| 26 | + module ClassMethods | |
| 27 | + def valid_oauth_providers(*providers) | |
| 28 | + return @valid_oauth_providers if providers == [] | |
| 29 | + @valid_oauth_providers = providers | |
| 30 | + end | |
| 31 | + end | |
| 32 | +end | 
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | module TwitterConcern | 
| 2 | 2 | extend ActiveSupport::Concern | 
| 3 | + include Oauthable | |
| 3 | 4 |  | 
| 4 | 5 | included do | 
| 5 | 6 | validate :validate_twitter_options | 
| 7 | + valid_oauth_providers :twitter | |
| 6 | 8 | end | 
| 7 | 9 |  | 
| 8 | 10 | def validate_twitter_options | 
| @@ -15,19 +17,19 @@ module TwitterConcern | ||
| 15 | 17 | end | 
| 16 | 18 |  | 
| 17 | 19 | def twitter_consumer_key | 
| 18 | -    options['consumer_key'].presence || credential('twitter_consumer_key') | |
| 20 | + ENV['TWITTER_OAUTH_KEY'] | |
| 19 | 21 | end | 
| 20 | 22 |  | 
| 21 | 23 | def twitter_consumer_secret | 
| 22 | -    options['consumer_secret'].presence || credential('twitter_consumer_secret') | |
| 24 | + ENV['TWITTER_OAUTH_SECRET'] | |
| 23 | 25 | end | 
| 24 | 26 |  | 
| 25 | 27 | def twitter_oauth_token | 
| 26 | -    options['oauth_token'].presence || options['access_key'].presence || credential('twitter_oauth_token') | |
| 28 | + service.token | |
| 27 | 29 | end | 
| 28 | 30 |  | 
| 29 | 31 | def twitter_oauth_token_secret | 
| 30 | -    options['oauth_token_secret'].presence || options['access_secret'].presence || credential('twitter_oauth_token_secret') | |
| 32 | + service.secret | |
| 31 | 33 | end | 
| 32 | 34 |  | 
| 33 | 35 | def twitter | 
| @@ -31,14 +31,15 @@ class AgentsController < ApplicationController | ||
| 31 | 31 | end | 
| 32 | 32 |  | 
| 33 | 33 | def type_details | 
| 34 | -    agent = Agent.build_for_type(params[:type], current_user, {}) | |
| 34 | +    @agent = Agent.build_for_type(params[:type], current_user, {}) | |
| 35 | 35 |      render :json => { | 
| 36 | - :can_be_scheduled => agent.can_be_scheduled?, | |
| 37 | - :default_schedule => agent.default_schedule, | |
| 38 | - :can_receive_events => agent.can_receive_events?, | |
| 39 | - :can_create_events => agent.can_create_events?, | |
| 40 | - :options => agent.default_options, | |
| 41 | - :description_html => agent.html_description | |
| 36 | + :can_be_scheduled => @agent.can_be_scheduled?, | |
| 37 | + :default_schedule => @agent.default_schedule, | |
| 38 | + :can_receive_events => @agent.can_receive_events?, | |
| 39 | + :can_create_events => @agent.can_create_events?, | |
| 40 | + :options => @agent.default_options, | |
| 41 | + :description_html => @agent.html_description, | |
| 42 | + :form => render_to_string(partial: 'oauth_dropdown') | |
| 42 | 43 | } | 
| 43 | 44 | end | 
| 44 | 45 |  | 
| @@ -13,4 +13,27 @@ class ApplicationController < ActionController::Base | ||
| 13 | 13 |      devise_parameter_sanitizer.for(:sign_in) { |u| u.permit(:login, :username, :email, :password, :remember_me) } | 
| 14 | 14 |      devise_parameter_sanitizer.for(:account_update) { |u| u.permit(:username, :email, :password, :password_confirmation, :current_password) } | 
| 15 | 15 | end | 
| 16 | + | |
| 17 | + def upgrade_warning | |
| 18 | + return unless current_user | |
| 19 | + twitter_oauth_check | |
| 20 | + basecamp_auth_check | |
| 21 | + end | |
| 22 | + | |
| 23 | + private | |
| 24 | + def twitter_oauth_check | |
| 25 | + if ENV['TWITTER_OAUTH_KEY'].blank? || ENV['TWITTER_OAUTH_SECRET'].blank? | |
| 26 | +      if @twitter_agent = current_user.agents.where("type like 'Agents::Twitter%'").first | |
| 27 | +        @twitter_oauth_key    = @twitter_agent.options['consumer_key'].presence || @twitter_agent.credential('twitter_consumer_key') | |
| 28 | +        @twitter_oauth_secret = @twitter_agent.options['consumer_secret'].presence || @twitter_agent.credential('twitter_consumer_secret') | |
| 29 | + end | |
| 30 | + end | |
| 31 | + end | |
| 32 | + | |
| 33 | + def basecamp_auth_check | |
| 34 | + if ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'].blank? || ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'].blank? | |
| 35 | + @basecamp_agent = current_user.agents.where(type: 'Agents::BasecampAgent').first | |
| 36 | + end | |
| 37 | + end | |
| 38 | + | |
| 16 | 39 | end | 
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | class HomeController < ApplicationController | 
| 2 | 2 | skip_before_filter :authenticate_user! | 
| 3 | 3 |  | 
| 4 | + before_filter :upgrade_warning, only: :index | |
| 5 | + | |
| 4 | 6 | def index | 
| 5 | 7 | end | 
| 6 | 8 |  | 
| @@ -45,6 +45,8 @@ class ScenariosController < ApplicationController | ||
| 45 | 45 | @exporter = AgentsExporter.new(:name => @scenario.name, | 
| 46 | 46 | :description => @scenario.description, | 
| 47 | 47 | :guid => @scenario.guid, | 
| 48 | + :tag_fg_color => @scenario.tag_fg_color, | |
| 49 | + :tag_bg_color => @scenario.tag_bg_color, | |
| 48 | 50 | :source_url => @scenario.public? && export_scenario_url(@scenario), | 
| 49 | 51 | :agents => @scenario.agents) | 
| 50 | 52 | response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"' | 
| @@ -0,0 +1,41 @@ | ||
| 1 | +class ServicesController < ApplicationController | |
| 2 | + before_filter :upgrade_warning, only: :index | |
| 3 | + | |
| 4 | + def index | |
| 5 | + @services = current_user.services.page(params[:page]) | |
| 6 | + | |
| 7 | + respond_to do |format| | |
| 8 | + format.html | |
| 9 | +      format.json { render json: @services } | |
| 10 | + end | |
| 11 | + end | |
| 12 | + | |
| 13 | + def destroy | |
| 14 | + @services = current_user.services.find(params[:id]) | |
| 15 | + @services.destroy | |
| 16 | + | |
| 17 | + respond_to do |format| | |
| 18 | +      format.html { redirect_to services_path } | |
| 19 | +      format.json { head :no_content } | |
| 20 | + end | |
| 21 | + end | |
| 22 | + | |
| 23 | + def toggle_availability | |
| 24 | + @service = current_user.services.find(params[:id]) | |
| 25 | + @service.toggle_availability! | |
| 26 | + | |
| 27 | + respond_to do |format| | |
| 28 | +      format.html { redirect_to services_path } | |
| 29 | +      format.json { render json: @service } | |
| 30 | + end | |
| 31 | + end | |
| 32 | + | |
| 33 | + def callback | |
| 34 | + @service = current_user.services.initialize_or_update_via_omniauth(request.env['omniauth.auth']) | |
| 35 | + if @service && @service.save | |
| 36 | + redirect_to services_path, notice: "The service was successfully created." | |
| 37 | + else | |
| 38 | + redirect_to services_path, error: "Error creating the service." | |
| 39 | + end | |
| 40 | + end | |
| 41 | +end | 
| @@ -8,7 +8,7 @@ module AgentHelper | ||
| 8 | 8 |  | 
| 9 | 9 | def scenario_links(agent) | 
| 10 | 10 |      agent.scenarios.map { |scenario| | 
| 11 | - link_to(scenario.name, scenario, class: "label label-info") | |
| 11 | + link_to(scenario.name, scenario, class: "label", style: style_colors(scenario)) | |
| 12 | 12 |      }.join(" ").html_safe | 
| 13 | 13 | end | 
| 14 | 14 |  | 
| @@ -0,0 +1,23 @@ | ||
| 1 | +module ScenarioHelper | |
| 2 | + | |
| 3 | + def style_colors(scenario) | |
| 4 | +    colors = { | |
| 5 | + color: scenario.tag_fg_color || default_scenario_fg_color, | |
| 6 | + background_color: scenario.tag_bg_color || default_scenario_bg_color | |
| 7 | +    }.map { |key, value| "#{key.to_s.dasherize}:#{value}" }.join(';') | |
| 8 | + end | |
| 9 | + | |
| 10 | + def scenario_label(scenario, text = nil) | |
| 11 | + text ||= scenario.name | |
| 12 | + content_tag :span, text, class: 'label scenario', style: style_colors(scenario) | |
| 13 | + end | |
| 14 | + | |
| 15 | + def default_scenario_bg_color | |
| 16 | + '#5BC0DE' | |
| 17 | + end | |
| 18 | + | |
| 19 | + def default_scenario_fg_color | |
| 20 | + '#FFFFFF' | |
| 21 | + end | |
| 22 | + | |
| 23 | +end | 
| @@ -0,0 +1,5 @@ | ||
| 1 | +module ServiceHelper | |
| 2 | + def has_oauth_configuration_for(provider) | |
| 3 | +    ENV["#{provider.upcase}_OAUTH_KEY"].present? && ENV["#{provider.upcase}_OAUTH_SECRET"].present? | |
| 4 | + end | |
| 5 | +end | 
| @@ -46,6 +46,7 @@ class Agent < ActiveRecord::Base | ||
| 46 | 46 | after_save :possibly_update_event_expirations | 
| 47 | 47 |  | 
| 48 | 48 | belongs_to :user, :inverse_of => :agents | 
| 49 | + belongs_to :service, :inverse_of => :agents | |
| 49 | 50 |    has_many :events, -> { order("events.id desc") }, :dependent => :delete_all, :inverse_of => :agent | 
| 50 | 51 | has_one :most_recent_event, :inverse_of => :agent, :class_name => "Event", :order => "events.id desc" | 
| 51 | 52 |    has_many :logs,  -> { order("agent_logs.id desc") }, :dependent => :delete_all, :inverse_of => :agent, :class_name => "AgentLog" | 
| @@ -413,7 +414,7 @@ class AgentDrop | ||
| 413 | 414 | @object.short_type | 
| 414 | 415 | end | 
| 415 | 416 |  | 
| 416 | - METHODS = [ | |
| 417 | + [ | |
| 417 | 418 | :name, | 
| 418 | 419 | :type, | 
| 419 | 420 | :options, | 
| @@ -426,19 +427,9 @@ class AgentDrop | ||
| 426 | 427 | :disabled, | 
| 427 | 428 | :keep_events_for, | 
| 428 | 429 | :propagate_immediately, | 
| 429 | - ] | |
| 430 | - | |
| 431 | -  METHODS.each { |attr| | |
| 430 | +  ].each { |attr| | |
| 432 | 431 |      define_method(attr) { | 
| 433 | 432 | @object.__send__(attr) | 
| 434 | 433 | } unless method_defined?(attr) | 
| 435 | 434 | } | 
| 436 | - | |
| 437 | - def each(&block) | |
| 438 | - return to_enum(__method__) unless block | |
| 439 | - | |
| 440 | -    METHODS.each { |attr| | |
| 441 | - yield [attr, __sent__(attr)] | |
| 442 | - } | |
| 443 | - end | |
| 444 | 435 | end | 
| @@ -2,17 +2,18 @@ module Agents | ||
| 2 | 2 | class BasecampAgent < Agent | 
| 3 | 3 | cannot_receive_events! | 
| 4 | 4 |  | 
| 5 | + include Oauthable | |
| 6 | + valid_oauth_providers '37signals' | |
| 7 | + | |
| 5 | 8 | description <<-MD | 
| 6 | 9 | The BasecampAgent checks a Basecamp project for new Events | 
| 7 | 10 |  | 
| 8 | - It is required that you enter your Basecamp credentials (`username` and `password`). | |
| 11 | + To be able to use this Agent you need to authenticate with 37signals in the [Services](/services) section first. | |
| 9 | 12 |  | 
| 10 | - You also need to provide your Basecamp `user_id` and the `project_id` of the project you want to monitor. | |
| 13 | + You need to provide the `project_id` of the project you want to monitor. | |
| 11 | 14 | If you have your Basecamp project opened in your browser you can find the user_id and project_id as follows: | 
| 12 | 15 |  | 
| 13 | - `https://basecamp.com/` | |
| 14 | - user_id | |
| 15 | - `/projects/` | |
| 16 | + `https://basecamp.com/123456/projects/` | |
| 16 | 17 | project_id | 
| 17 | 18 | `-explore-basecamp` | 
| 18 | 19 | MD | 
| @@ -20,42 +21,36 @@ module Agents | ||
| 20 | 21 | event_description <<-MD | 
| 21 | 22 | Events are the raw JSON provided by the Basecamp API. Should look something like: | 
| 22 | 23 |  | 
| 23 | -        { | |
| 24 | -          "creator": { | |
| 25 | - "fullsize_avatar_url": "https://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/original.gif?r=3", | |
| 26 | - "avatar_url": "http://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/avatar.gif?r=3", | |
| 27 | - "name": "Dominik Sander", | |
| 28 | - "id": 123456 | |
| 29 | - }, | |
| 30 | - "attachments": [], | |
| 31 | - "raw_excerpt": "test test", | |
| 32 | - "excerpt": "test test", | |
| 33 | - "id": 6454342343, | |
| 34 | - "created_at": "2014-04-17T10:25:31.000+02:00", | |
| 35 | - "updated_at": "2014-04-17T10:25:31.000+02:00", | |
| 36 | - "summary": "commented on whaat", | |
| 37 | - "action": "commented on", | |
| 38 | - "target": "whaat", | |
| 39 | - "url": "https://basecamp.com/12456/api/v1/projects/76454545-explore-basecamp/messages/76454545-whaat.json", | |
| 40 | - "html_url": "https://basecamp.com/12456/projects/76454545-explore-basecamp/messages/76454545-whaat#comment_76454545" | |
| 41 | - } | |
| 24 | +          { | |
| 25 | +            "creator": { | |
| 26 | + "fullsize_avatar_url": "https://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/original.gif?r=3", | |
| 27 | + "avatar_url": "http://dge9rmgqjs8m1.cloudfront.net/global/dfsdfsdfdsf/avatar.gif?r=3", | |
| 28 | + "name": "Dominik Sander", | |
| 29 | + "id": 123456 | |
| 30 | + }, | |
| 31 | + "attachments": [], | |
| 32 | + "raw_excerpt": "test test", | |
| 33 | + "excerpt": "test test", | |
| 34 | + "id": 6454342343, | |
| 35 | + "created_at": "2014-04-17T10:25:31.000+02:00", | |
| 36 | + "updated_at": "2014-04-17T10:25:31.000+02:00", | |
| 37 | + "summary": "commented on whaat", | |
| 38 | + "action": "commented on", | |
| 39 | + "target": "whaat", | |
| 40 | + "url": "https://basecamp.com/12456/api/v1/projects/76454545-explore-basecamp/messages/76454545-whaat.json", | |
| 41 | + "html_url": "https://basecamp.com/12456/projects/76454545-explore-basecamp/messages/76454545-whaat#comment_76454545" | |
| 42 | + } | |
| 42 | 43 | MD | 
| 43 | 44 |  | 
| 44 | 45 | default_schedule "every_10m" | 
| 45 | 46 |  | 
| 46 | 47 | def default_options | 
| 47 | 48 |        { | 
| 48 | - 'username' => '', | |
| 49 | - 'password' => '', | |
| 50 | - 'user_id' => '', | |
| 51 | 49 | 'project_id' => '', | 
| 52 | 50 | } | 
| 53 | 51 | end | 
| 54 | 52 |  | 
| 55 | 53 | def validate_options | 
| 56 | - errors.add(:base, "you need to specify your basecamp username") unless options['username'].present? | |
| 57 | - errors.add(:base, "you need to specify your basecamp password") unless options['password'].present? | |
| 58 | - errors.add(:base, "you need to specify your basecamp user id") unless options['user_id'].present? | |
| 59 | 54 | errors.add(:base, "you need to specify the basecamp project id of which you want to receive events") unless options['project_id'].present? | 
| 60 | 55 | end | 
| 61 | 56 |  | 
| @@ -64,27 +59,29 @@ module Agents | ||
| 64 | 59 | end | 
| 65 | 60 |  | 
| 66 | 61 | def check | 
| 62 | + service.prepare_request | |
| 67 | 63 | reponse = HTTParty.get request_url, request_options.merge(query_parameters) | 
| 68 | - memory[:last_run] = Time.now.utc.iso8601 | |
| 69 | - if last_check_at != nil | |
| 70 | - JSON.parse(reponse.body).each do |event| | |
| 64 | + events = JSON.parse(reponse.body) | |
| 65 | + if !memory[:last_event].nil? | |
| 66 | + events.each do |event| | |
| 71 | 67 | create_event :payload => event | 
| 72 | 68 | end | 
| 73 | 69 | end | 
| 70 | + memory[:last_event] = events.first['created_at'] if events.length > 0 | |
| 74 | 71 | save! | 
| 75 | 72 | end | 
| 76 | 73 |  | 
| 77 | 74 | private | 
| 78 | 75 | def request_url | 
| 79 | -      "https://basecamp.com/#{URI.encode(interpolated[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json" | |
| 76 | +      "https://basecamp.com/#{URI.encode(service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json" | |
| 80 | 77 | end | 
| 81 | 78 |  | 
| 82 | 79 | def request_options | 
| 83 | -      {:basic_auth => {:username => interpolated[:username], :password => interpolated[:password]}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}} | |
| 80 | +      {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => "Bearer \"#{service.token}\""}} | |
| 84 | 81 | end | 
| 85 | 82 |  | 
| 86 | 83 | def query_parameters | 
| 87 | -      memory[:last_run].present? ? { :query => {:since => memory[:last_run]} } : {} | |
| 84 | +      memory[:last_event].present? ? { :query => {:since => memory[:last_event]} } : {} | |
| 88 | 85 | end | 
| 89 | 86 | end | 
| 90 | 87 | end | 
| @@ -51,7 +51,7 @@ module Agents | ||
| 51 | 51 |                { | 
| 52 | 52 |                  "path": "{{date.pretty}}", | 
| 53 | 53 | "regexp": "\\A(?<time>\\d\\d:\\d\\d [AP]M [A-Z]+)", | 
| 54 | - "to": "pretty_date", | |
| 54 | + "to": "pretty_date" | |
| 55 | 55 | } | 
| 56 | 56 | ] | 
| 57 | 57 | } | 
| @@ -61,7 +61,7 @@ module Agents | ||
| 61 | 61 |            "pretty_date": { | 
| 62 | 62 | "time": "10:00 PM EST", | 
| 63 | 63 | "0": "10:00 PM EST on January 11, 2013" | 
| 64 | - "1": "10:00 PM EST", | |
| 64 | + "1": "10:00 PM EST" | |
| 65 | 65 | } | 
| 66 | 66 |  | 
| 67 | 67 | So you can use it in `instructions` like this: | 
| @@ -80,7 +80,19 @@ module Agents | ||
| 80 | 80 | } | 
| 81 | 81 | MD | 
| 82 | 82 |  | 
| 83 | - event_description "User defined" | |
| 83 | + event_description do | |
| 84 | + "Events will have the following fields%s:\n\n %s" % [ | |
| 85 | + case options['mode'].to_s | |
| 86 | + when 'merged' | |
| 87 | + ', merged with the original contents' | |
| 88 | +        when /\{/ | |
| 89 | + ', conditionally merged with the original contents' | |
| 90 | + end, | |
| 91 | +        Utils.pretty_print(Hash[options['instructions'].keys.map { |key| | |
| 92 | + [key, "..."] | |
| 93 | + }]) | |
| 94 | + ] | |
| 95 | + end | |
| 84 | 96 |  | 
| 85 | 97 | after_save :clear_matchers | 
| 86 | 98 |  | 
| @@ -62,7 +62,7 @@ module Agents | ||
| 62 | 62 | .... | 
| 63 | 63 | }, | 
| 64 | 64 | 'agent_id' => 1234, | 
| 65 | - 'event_id' => 3432, | |
| 65 | + 'event_id' => 3432 | |
| 66 | 66 | } | 
| 67 | 67 | MD | 
| 68 | 68 |  | 
| @@ -9,11 +9,7 @@ module Agents | ||
| 9 | 9 | description <<-MD | 
| 10 | 10 | The TwitterPublishAgent publishes tweets from the events it receives. | 
| 11 | 11 |  | 
| 12 | - Twitter credentials must be supplied as either [credentials](/user_credentials) called | |
| 13 | - `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`, | |
| 14 | - or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`. | |
| 15 | - | |
| 16 | - To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token). | |
| 12 | + To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first. | |
| 17 | 13 |  | 
| 18 | 14 | You must also specify a `message` parameter, you can use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the message. | 
| 19 | 15 |  | 
| @@ -10,11 +10,7 @@ module Agents | ||
| 10 | 10 | To follow the Twitter stream, provide an array of `filters`. Multiple words in a filter must all show up in a tweet, but are independent of order. | 
| 11 | 11 | If you provide an array instead of a filter, the first entry will be considered primary and any additional values will be treated as aliases. | 
| 12 | 12 |  | 
| 13 | - Twitter credentials must be supplied as either [credentials](/user_credentials) called | |
| 14 | - `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`, | |
| 15 | - or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`. | |
| 16 | - | |
| 17 | - To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token). | |
| 13 | + To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first. | |
| 18 | 14 |  | 
| 19 | 15 | Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent. | 
| 20 | 16 |  | 
| @@ -9,11 +9,7 @@ module Agents | ||
| 9 | 9 | description <<-MD | 
| 10 | 10 | The TwitterUserAgent follows the timeline of a specified Twitter user. | 
| 11 | 11 |  | 
| 12 | - Twitter credentials must be supplied as either [credentials](/user_credentials) called | |
| 13 | - `twitter_consumer_key`, `twitter_consumer_secret`, `twitter_oauth_token`, and `twitter_oauth_token_secret`, | |
| 14 | - or as options to this Agent called `consumer_key`, `consumer_secret`, `oauth_token`, and `oauth_token_secret`. | |
| 15 | - | |
| 16 | - To get oAuth credentials for Twitter, [follow these instructions](https://github.com/cantino/huginn/wiki/Getting-a-twitter-oauth-token). | |
| 12 | + To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first. | |
| 17 | 13 |  | 
| 18 | 14 | You must also provide the `username` of the Twitter user to monitor. | 
| 19 | 15 |  | 
| @@ -42,20 +42,20 @@ module Agents | ||
| 42 | 42 |  | 
| 43 | 43 |            "extract": { | 
| 44 | 44 |              "word": { "regexp": "^(.+?): (.+)$", index: 1 }, | 
| 45 | -            "definition": { "regexp": "^(.+?): (.+)$", index: 2 }, | |
| 45 | +            "definition": { "regexp": "^(.+?): (.+)$", index: 2 } | |
| 46 | 46 | } | 
| 47 | 47 |  | 
| 48 | 48 | Or if you prefer names to numbers for index: | 
| 49 | 49 |  | 
| 50 | 50 |            "extract": { | 
| 51 | 51 |              "word": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'word' }, | 
| 52 | -            "definition": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'definition' }, | |
| 52 | +            "definition": { "regexp": "^(?<word>.+?): (?<definition>.+)$", index: 'definition' } | |
| 53 | 53 | } | 
| 54 | 54 |  | 
| 55 | 55 | To extract the whole content as one event: | 
| 56 | 56 |  | 
| 57 | 57 |            "extract": { | 
| 58 | -            "content": { "regexp": "\A(?m:.)*\z", index: 0 }, | |
| 58 | +            "content": { "regexp": "\A(?m:.)*\z", index: 0 } | |
| 59 | 59 | } | 
| 60 | 60 |  | 
| 61 | 61 |        Beware that `.` does not match the newline character (LF) unless the `m` flag is in effect, and `^`/`$` basically match every line beginning/end.  See [this document](http://ruby-doc.org/core-#{RUBY_VERSION}/doc/regexp_rdoc.html) to learn the regular expression variant used in this service. | 
| @@ -78,7 +78,11 @@ module Agents | ||
| 78 | 78 | MD | 
| 79 | 79 |  | 
| 80 | 80 | event_description do | 
| 81 | -      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print interpolated['extract']}" | |
| 81 | + "Events will have the following fields:\n\n %s" % [ | |
| 82 | +        Utils.pretty_print(Hash[options['extract'].keys.map { |key| | |
| 83 | + [key, "..."] | |
| 84 | + }]) | |
| 85 | + ] | |
| 82 | 86 | end | 
| 83 | 87 |  | 
| 84 | 88 | def working? | 
| @@ -157,85 +161,60 @@ module Agents | ||
| 157 | 161 |                log "Storing new result for '#{name}': #{doc.inspect}" | 
| 158 | 162 | create_event :payload => doc | 
| 159 | 163 | end | 
| 160 | - else | |
| 161 | -            output = {} | |
| 162 | - interpolated['extract'].each do |name, extraction_details| | |
| 163 | - case extraction_type | |
| 164 | - when "text" | |
| 165 | - regexp = Regexp.new(extraction_details['regexp']) | |
| 166 | - result = [] | |
| 167 | -                doc.scan(regexp) { | |
| 168 | - result << Regexp.last_match[extraction_details['index']] | |
| 169 | - } | |
| 170 | -                log "Extracting #{extraction_type} at #{regexp}: #{result}" | |
| 171 | - when "json" | |
| 172 | - result = Utils.values_at(doc, extraction_details['path']) | |
| 173 | -                log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}" | |
| 174 | - else | |
| 175 | - case | |
| 176 | - when css = extraction_details['css'] | |
| 177 | - nodes = doc.css(css) | |
| 178 | - when xpath = extraction_details['xpath'] | |
| 179 | - doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds | |
| 180 | - nodes = doc.xpath(xpath) | |
| 181 | - else | |
| 182 | - error '"css" or "xpath" is required for HTML or XML extraction' | |
| 183 | - return | |
| 184 | - end | |
| 185 | - case nodes | |
| 186 | - when Nokogiri::XML::NodeSet | |
| 187 | -                  result = nodes.map { |node| | |
| 188 | - case value = node.xpath(extraction_details['value']) | |
| 189 | - when Float | |
| 190 | - # Node#xpath() returns any numeric value as float; | |
| 191 | - # convert it to integer as appropriate. | |
| 192 | - value = value.to_i if value.to_i == value | |
| 193 | - end | |
| 194 | - value.to_s | |
| 195 | - } | |
| 196 | - else | |
| 197 | - error "The result of HTML/XML extraction was not a NodeSet" | |
| 198 | - return | |
| 199 | - end | |
| 200 | -                log "Extracting #{extraction_type} at #{xpath || css}: #{result}" | |
| 201 | - end | |
| 202 | - output[name] = result | |
| 164 | + next | |
| 165 | + end | |
| 166 | + | |
| 167 | + output = | |
| 168 | + case extraction_type | |
| 169 | + when 'json' | |
| 170 | + extract_json(doc) | |
| 171 | + when 'text' | |
| 172 | + extract_text(doc) | |
| 173 | + else | |
| 174 | + extract_xml(doc) | |
| 203 | 175 | end | 
| 204 | 176 |  | 
| 205 | -            num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq | |
| 177 | +          num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq | |
| 206 | 178 |  | 
| 207 | - if num_unique_lengths.length != 1 | |
| 208 | -              error "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}" | |
| 209 | - return | |
| 210 | - end | |
| 179 | + if num_unique_lengths.length != 1 | |
| 180 | +            raise "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}" | |
| 181 | + end | |
| 211 | 182 |  | 
| 212 | - old_events = previous_payloads num_unique_lengths.first | |
| 213 | - num_unique_lengths.first.times do |index| | |
| 214 | -              result = {} | |
| 215 | - interpolated['extract'].keys.each do |name| | |
| 216 | - result[name] = output[name][index] | |
| 217 | - if name.to_s == 'url' | |
| 218 | - result[name] = (response.env[:url] + result[name]).to_s | |
| 219 | - end | |
| 183 | + old_events = previous_payloads num_unique_lengths.first | |
| 184 | + num_unique_lengths.first.times do |index| | |
| 185 | +            result = {} | |
| 186 | + interpolated['extract'].keys.each do |name| | |
| 187 | + result[name] = output[name][index] | |
| 188 | + if name.to_s == 'url' | |
| 189 | + result[name] = (response.env[:url] + result[name]).to_s | |
| 220 | 190 | end | 
| 191 | + end | |
| 221 | 192 |  | 
| 222 | - if store_payload!(old_events, result) | |
| 223 | -                log "Storing new parsed result for '#{name}': #{result.inspect}" | |
| 224 | - create_event :payload => result | |
| 225 | - end | |
| 193 | + if store_payload!(old_events, result) | |
| 194 | +              log "Storing new parsed result for '#{name}': #{result.inspect}" | |
| 195 | + create_event :payload => result | |
| 226 | 196 | end | 
| 227 | 197 | end | 
| 228 | 198 | else | 
| 229 | -          error "Failed: #{response.inspect}" | |
| 199 | +          raise "Failed: #{response.inspect}" | |
| 230 | 200 | end | 
| 231 | 201 | end | 
| 202 | + rescue => e | |
| 203 | + error e.message | |
| 232 | 204 | end | 
| 233 | 205 |  | 
| 234 | 206 | def receive(incoming_events) | 
| 235 | 207 | incoming_events.each do |event| | 
| 208 | + Thread.current[:current_event] = event | |
| 236 | 209 | url_to_scrape = event.payload['url'] | 
| 237 | 210 | check_url(url_to_scrape) if url_to_scrape =~ /^https?:\/\//i | 
| 238 | 211 | end | 
| 212 | + ensure | |
| 213 | + Thread.current[:current_event] = nil | |
| 214 | + end | |
| 215 | + | |
| 216 | + def interpolated(event = Thread.current[:current_event]) | |
| 217 | + super | |
| 239 | 218 | end | 
| 240 | 219 |  | 
| 241 | 220 | private | 
| @@ -244,22 +223,22 @@ module Agents | ||
| 244 | 223 | # If mode is set to 'on_change', this method may return false and update an existing | 
| 245 | 224 | # event to expire further in the future. | 
| 246 | 225 | def store_payload!(old_events, result) | 
| 247 | - if !interpolated['mode'].present? | |
| 248 | - return true | |
| 249 | - elsif interpolated['mode'].to_s == "all" | |
| 250 | - return true | |
| 251 | - elsif interpolated['mode'].to_s == "on_change" | |
| 226 | + case interpolated['mode'].presence | |
| 227 | + when 'on_change' | |
| 252 | 228 | result_json = result.to_json | 
| 253 | 229 | old_events.each do |old_event| | 
| 254 | 230 | if old_event.payload.to_json == result_json | 
| 255 | 231 | old_event.expires_at = new_event_expiration_date | 
| 256 | 232 | old_event.save! | 
| 257 | 233 | return false | 
| 258 | - end | |
| 234 | + end | |
| 259 | 235 | end | 
| 260 | - return true | |
| 236 | + true | |
| 237 | + when 'all', '' | |
| 238 | + true | |
| 239 | + else | |
| 240 | +        raise "Illegal options[mode]: #{interpolated['mode']}" | |
| 261 | 241 | end | 
| 262 | - raise "Illegal options[mode]: " + interpolated['mode'].to_s | |
| 263 | 242 | end | 
| 264 | 243 |  | 
| 265 | 244 | def previous_payloads(num_events) | 
| @@ -272,7 +251,7 @@ module Agents | ||
| 272 | 251 | look_back = UNIQUENESS_LOOK_BACK | 
| 273 | 252 | end | 
| 274 | 253 | end | 
| 275 | -      events.order("id desc").limit(look_back) if interpolated['mode'].present? && interpolated['mode'].to_s == "on_change" | |
| 254 | +      events.order("id desc").limit(look_back) if interpolated['mode'] == "on_change" | |
| 276 | 255 | end | 
| 277 | 256 |  | 
| 278 | 257 | def extract_full_json? | 
| @@ -294,27 +273,81 @@ module Agents | ||
| 294 | 273 | end).to_s | 
| 295 | 274 | end | 
| 296 | 275 |  | 
| 276 | + def extract_each(doc, &block) | |
| 277 | +      interpolated['extract'].each_with_object({}) { |(name, extraction_details), output| | |
| 278 | + output[name] = block.call(extraction_details) | |
| 279 | + } | |
| 280 | + end | |
| 281 | + | |
| 282 | + def extract_json(doc) | |
| 283 | +      extract_each(doc) { |extraction_details| | |
| 284 | + result = Utils.values_at(doc, extraction_details['path']) | |
| 285 | +        log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}" | |
| 286 | + result | |
| 287 | + } | |
| 288 | + end | |
| 289 | + | |
| 290 | + def extract_text(doc) | |
| 291 | +      extract_each(doc) { |extraction_details| | |
| 292 | + regexp = Regexp.new(extraction_details['regexp']) | |
| 293 | + result = [] | |
| 294 | +        doc.scan(regexp) { | |
| 295 | + result << Regexp.last_match[extraction_details['index']] | |
| 296 | + } | |
| 297 | +        log "Extracting #{extraction_type} at #{regexp}: #{result}" | |
| 298 | + result | |
| 299 | + } | |
| 300 | + end | |
| 301 | + | |
| 302 | + def extract_xml(doc) | |
| 303 | +      extract_each(doc) { |extraction_details| | |
| 304 | + case | |
| 305 | + when css = extraction_details['css'] | |
| 306 | + nodes = doc.css(css) | |
| 307 | + when xpath = extraction_details['xpath'] | |
| 308 | + doc.remove_namespaces! # ignore xmlns, useful when parsing atom feeds | |
| 309 | + nodes = doc.xpath(xpath) | |
| 310 | + else | |
| 311 | + raise '"css" or "xpath" is required for HTML or XML extraction' | |
| 312 | + end | |
| 313 | + case nodes | |
| 314 | + when Nokogiri::XML::NodeSet | |
| 315 | +          result = nodes.map { |node| | |
| 316 | + case value = node.xpath(extraction_details['value']) | |
| 317 | + when Float | |
| 318 | + # Node#xpath() returns any numeric value as float; | |
| 319 | + # convert it to integer as appropriate. | |
| 320 | + value = value.to_i if value.to_i == value | |
| 321 | + end | |
| 322 | + value.to_s | |
| 323 | + } | |
| 324 | + else | |
| 325 | + raise "The result of HTML/XML extraction was not a NodeSet" | |
| 326 | + end | |
| 327 | +        log "Extracting #{extraction_type} at #{xpath || css}: #{result}" | |
| 328 | + result | |
| 329 | + } | |
| 330 | + end | |
| 331 | + | |
| 297 | 332 | def parse(data) | 
| 298 | 333 | case extraction_type | 
| 299 | - when "xml" | |
| 300 | - Nokogiri::XML(data) | |
| 301 | - when "json" | |
| 302 | - JSON.parse(data) | |
| 303 | - when "html" | |
| 304 | - Nokogiri::HTML(data) | |
| 305 | - when "text" | |
| 306 | - data | |
| 307 | - else | |
| 308 | -          raise "Unknown extraction type #{extraction_type}" | |
| 334 | + when "xml" | |
| 335 | + Nokogiri::XML(data) | |
| 336 | + when "json" | |
| 337 | + JSON.parse(data) | |
| 338 | + when "html" | |
| 339 | + Nokogiri::HTML(data) | |
| 340 | + when "text" | |
| 341 | + data | |
| 342 | + else | |
| 343 | +        raise "Unknown extraction type #{extraction_type}" | |
| 309 | 344 | end | 
| 310 | 345 | end | 
| 311 | 346 |  | 
| 312 | 347 | def is_positive_integer?(value) | 
| 313 | - begin | |
| 314 | - Integer(value) >= 0 | |
| 315 | - rescue | |
| 316 | - false | |
| 317 | - end | |
| 348 | + Integer(value) >= 0 | |
| 349 | + rescue | |
| 350 | + false | |
| 318 | 351 | end | 
| 319 | 352 | end | 
| 320 | 353 | end | 
| @@ -44,26 +44,21 @@ class Event < ActiveRecord::Base | ||
| 44 | 44 | end | 
| 45 | 45 |  | 
| 46 | 46 | class EventDrop | 
| 47 | - def initialize(event, payload = event.payload) | |
| 48 | - super(event) | |
| 49 | - @payload = payload | |
| 50 | - end | |
| 51 | - | |
| 52 | - def before_method(key) | |
| 53 | - if @payload.key?(key) | |
| 54 | - @payload[key] | |
| 55 | - else | |
| 56 | - case key | |
| 57 | - when 'agent' | |
| 58 | - @object.agent | |
| 59 | - when 'created_at' | |
| 60 | - @object.created_at | |
| 61 | - end | |
| 62 | - end | |
| 47 | + def initialize(object, locals = nil) | |
| 48 | +    locals = object.payload.merge(locals || {}) | |
| 49 | + super | |
| 63 | 50 | end | 
| 64 | 51 |  | 
| 65 | 52 | def each(&block) | 
| 66 | 53 | return to_enum(__method__) unless block | 
| 67 | - @payload.each(&block) | |
| 54 | + @locals.each(&block) | |
| 55 | + end | |
| 56 | + | |
| 57 | + def agent | |
| 58 | + @object.agent | |
| 59 | + end | |
| 60 | + | |
| 61 | + def created_at | |
| 62 | + @object.created_at | |
| 68 | 63 | end | 
| 69 | 64 | end | 
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | class Scenario < ActiveRecord::Base | 
| 2 | 2 | include HasGuid | 
| 3 | 3 |  | 
| 4 | - attr_accessible :name, :agent_ids, :description, :public, :source_url | |
| 4 | + attr_accessible :name, :agent_ids, :description, :public, :source_url, :tag_fg_color, :tag_bg_color | |
| 5 | 5 |  | 
| 6 | 6 | belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios | 
| 7 | 7 | has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario | 
| @@ -9,6 +9,11 @@ class Scenario < ActiveRecord::Base | ||
| 9 | 9 |  | 
| 10 | 10 | validates_presence_of :name, :user | 
| 11 | 11 |  | 
| 12 | + validates_format_of :tag_fg_color, :tag_bg_color, | |
| 13 | + # Regex adapted from: http://stackoverflow.com/a/1636354/3130625 | |
| 14 | +    :with => /\A#(?:[0-9a-fA-F]{3}){1,2}\z/, :allow_nil => true, | |
| 15 | + :message => "must be a valid hex color." | |
| 16 | + | |
| 12 | 17 | validate :agents_are_owned | 
| 13 | 18 |  | 
| 14 | 19 | protected | 
| @@ -60,10 +60,14 @@ class ScenarioImport | ||
| 60 | 60 | description = parsed_data['description'] | 
| 61 | 61 | name = parsed_data['name'] | 
| 62 | 62 | links = parsed_data['links'] | 
| 63 | + tag_fg_color = parsed_data['tag_fg_color'] | |
| 64 | + tag_bg_color = parsed_data['tag_bg_color'] | |
| 63 | 65 | source_url = parsed_data['source_url'].presence || nil | 
| 64 | 66 | @scenario = user.scenarios.where(:guid => guid).first_or_initialize | 
| 65 | 67 | @scenario.update_attributes!(:name => name, :description => description, | 
| 66 | - :source_url => source_url, :public => false) | |
| 68 | + :source_url => source_url, :public => false, | |
| 69 | + :tag_fg_color => tag_fg_color, | |
| 70 | + :tag_bg_color => tag_bg_color) | |
| 67 | 71 |  | 
| 68 | 72 | unless options[:skip_agents] | 
| 69 | 73 | created_agents = agent_diffs.map do |agent_diff| | 
| @@ -76,17 +80,19 @@ class ScenarioImport | ||
| 76 | 80 | agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present? | 
| 77 | 81 | agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present? | 
| 78 | 82 | agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true" | 
| 83 | + agent.service_id = agent_diff.service_id.updated if agent_diff.service_id.present? | |
| 79 | 84 | unless agent.save | 
| 80 | 85 | success = false | 
| 81 | 86 |            errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}") | 
| 82 | 87 | end | 
| 83 | 88 | agent | 
| 84 | 89 | end | 
| 85 | - | |
| 86 | - links.each do |link| | |
| 87 | - receiver = created_agents[link['receiver']] | |
| 88 | - source = created_agents[link['source']] | |
| 89 | - receiver.sources << source unless receiver.sources.include?(source) | |
| 90 | + if success | |
| 91 | + links.each do |link| | |
| 92 | + receiver = created_agents[link['receiver']] | |
| 93 | + source = created_agents[link['source']] | |
| 94 | + receiver.sources << source unless receiver.sources.include?(source) | |
| 95 | + end | |
| 90 | 96 | end | 
| 91 | 97 | end | 
| 92 | 98 |  | 
| @@ -149,6 +155,9 @@ class ScenarioImport | ||
| 149 | 155 |            errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.") | 
| 150 | 156 | end | 
| 151 | 157 | end | 
| 158 | + if agent_diff.requires_service? && merges.present? && merges[index.to_s].present? && merges[index.to_s]['service_id'].present? | |
| 159 | + agent_diff.service_id = AgentDiff::FieldDiff.new(merges[index.to_s]['service_id'].to_i) | |
| 160 | + end | |
| 152 | 161 | agent_diff | 
| 153 | 162 | end | 
| 154 | 163 | end | 
| @@ -192,6 +201,10 @@ class ScenarioImport | ||
| 192 | 201 | @requires_merge | 
| 193 | 202 | end | 
| 194 | 203 |  | 
| 204 | + def requires_service? | |
| 205 | + !!agent_instance.try(:oauthable?) | |
| 206 | + end | |
| 207 | + | |
| 195 | 208 | def store!(agent_data) | 
| 196 | 209 |        self.type = FieldDiff.new(agent_data["type"].split("::").pop) | 
| 197 | 210 |        self.options = FieldDiff.new(agent_data['options'] || {}) | 
| @@ -252,5 +265,9 @@ class ScenarioImport | ||
| 252 | 265 | key.gsub(/[^a-zA-Z0-9_-]/, '') | 
| 253 | 266 | end | 
| 254 | 267 | end | 
| 268 | + | |
| 269 | + def agent_instance | |
| 270 | +      "Agents::#{self.type.updated}".constantize.new | |
| 271 | + end | |
| 255 | 272 | end | 
| 256 | 273 | end | 
| @@ -0,0 +1,89 @@ | ||
| 1 | +class Service < ActiveRecord::Base | |
| 2 | +  PROVIDER_TO_ENV_MAP = {'37signals' => 'THIRTY_SEVEN_SIGNALS'} | |
| 3 | + | |
| 4 | + attr_accessible :provider, :name, :token, :secret, :refresh_token, :expires_at, :global, :options, :uid | |
| 5 | + | |
| 6 | + serialize :options, Hash | |
| 7 | + | |
| 8 | + belongs_to :user, :inverse_of => :services | |
| 9 | + has_many :agents, :inverse_of => :service | |
| 10 | + | |
| 11 | + validates_presence_of :user_id, :provider, :name, :token | |
| 12 | + | |
| 13 | + before_destroy :disable_agents | |
| 14 | + | |
| 15 | +  scope :available_to_user, lambda { |user| where("services.user_id = ? or services.global = true", user.id) } | |
| 16 | +  scope :by_name, lambda { |dir = 'desc'| order("services.name #{dir}") } | |
| 17 | + | |
| 18 | +  def disable_agents(conditions = {}) | |
| 19 | +    agents.where.not(conditions[:where_not] || {}).each do |agent| | |
| 20 | + agent.service_id = nil | |
| 21 | + agent.disabled = true | |
| 22 | + agent.save!(validate: false) | |
| 23 | + end | |
| 24 | + end | |
| 25 | + | |
| 26 | + def toggle_availability! | |
| 27 | +    disable_agents(where_not: {user_id: self.user_id}) if global | |
| 28 | + self.global = !self.global | |
| 29 | + self.save! | |
| 30 | + end | |
| 31 | + | |
| 32 | + def prepare_request | |
| 33 | + if expires_at && Time.now > expires_at | |
| 34 | + refresh_token! | |
| 35 | + end | |
| 36 | + end | |
| 37 | + | |
| 38 | + def refresh_token! | |
| 39 | +    response = HTTParty.post(endpoint, query: { | |
| 40 | + type: 'refresh', | |
| 41 | + client_id: oauth_key, | |
| 42 | + client_secret: oauth_secret, | |
| 43 | + refresh_token: refresh_token | |
| 44 | + }) | |
| 45 | + data = JSON.parse(response.body) | |
| 46 | + update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || refresh_token) | |
| 47 | + end | |
| 48 | + | |
| 49 | + def endpoint | |
| 50 | +    client_options = "OmniAuth::Strategies::#{OmniAuth::Utils.camelize(self.provider)}".constantize.default_options['client_options'] | |
| 51 | + URI.join(client_options['site'], client_options['token_url']) | |
| 52 | + end | |
| 53 | + | |
| 54 | + def provider_to_env | |
| 55 | + PROVIDER_TO_ENV_MAP[provider].presence || provider.upcase | |
| 56 | + end | |
| 57 | + | |
| 58 | + def oauth_key | |
| 59 | +    ENV["#{provider_to_env}_OAUTH_KEY"] | |
| 60 | + end | |
| 61 | + | |
| 62 | + def oauth_secret | |
| 63 | +    ENV["#{provider_to_env}_OAUTH_SECRET"] | |
| 64 | + end | |
| 65 | + | |
| 66 | + def self.provider_specific_options(omniauth) | |
| 67 | + case omniauth['provider'] | |
| 68 | + when 'twitter', 'github' | |
| 69 | +        { name: omniauth['info']['nickname'] } | |
| 70 | + when '37signals' | |
| 71 | +        { user_id: omniauth['extra']['accounts'][0]['id'], name: omniauth['info']['name'] } | |
| 72 | + else | |
| 73 | +        { name: omniauth['info']['nickname'] } | |
| 74 | + end | |
| 75 | + end | |
| 76 | + | |
| 77 | + def self.initialize_or_update_via_omniauth(omniauth) | |
| 78 | + options = provider_specific_options(omniauth) | |
| 79 | + | |
| 80 | + find_or_initialize_by(provider: omniauth['provider'], uid: omniauth['uid'].to_s).tap do |service| | |
| 81 | + service.assign_attributes token: omniauth['credentials']['token'], | |
| 82 | + secret: omniauth['credentials']['secret'], | |
| 83 | + name: options[:name], | |
| 84 | + refresh_token: omniauth['credentials']['refresh_token'], | |
| 85 | + expires_at: omniauth['credentials']['expires_at'] && Time.at(omniauth['credentials']['expires_at']), | |
| 86 | + options: options | |
| 87 | + end | |
| 88 | + end | |
| 89 | +end | 
| @@ -27,6 +27,11 @@ class User < ActiveRecord::Base | ||
| 27 | 27 |    has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user | 
| 28 | 28 | has_many :logs, :through => :agents, :class_name => "AgentLog" | 
| 29 | 29 | has_many :scenarios, :inverse_of => :user, :dependent => :destroy | 
| 30 | +  has_many :services, -> { by_name('asc') }, :dependent => :destroy | |
| 31 | + | |
| 32 | + def available_services | |
| 33 | + Service.available_to_user(self).by_name | |
| 34 | + end | |
| 30 | 35 |  | 
| 31 | 36 | # Allow users to login via either email or username. | 
| 32 | 37 | def self.find_first_by_auth_conditions(warden_conditions) | 
| @@ -32,7 +32,7 @@ | ||
| 32 | 32 |  | 
| 33 | 33 | <% agent.scenarios.each do |scenario| %> | 
| 34 | 34 | <li> | 
| 35 | -        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from <span class='scenario label label-info'>#{h scenario.name}</span>".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %> | |
| 35 | +        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from #{scenario_label(scenario)}".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %> | |
| 36 | 36 | </li> | 
| 37 | 37 | <% end %> | 
| 38 | 38 | <% end %> | 
| @@ -25,11 +25,15 @@ | ||
| 25 | 25 | </div> | 
| 26 | 26 | <% end %> | 
| 27 | 27 |  | 
| 28 | - <div class="form-group"> | |
| 28 | + <div class="form-group type-select"> | |
| 29 | 29 | <%= f.label :name %> | 
| 30 | 30 | <%= f.text_field :name, :class => 'form-control' %> | 
| 31 | 31 | </div> | 
| 32 | 32 |  | 
| 33 | + <div class='oauthable-form'> | |
| 34 | + <%= render partial: 'oauth_dropdown' %> | |
| 35 | + </div> | |
| 36 | + | |
| 33 | 37 | <div class="form-group"> | 
| 34 | 38 | <%= f.label :schedule, :class => 'control-label' %> | 
| 35 | 39 | <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>"> | 
| @@ -0,0 +1,6 @@ | ||
| 1 | +<% if @agent.try(:oauthable?) %> | |
| 2 | + <div class="form-group type-select"> | |
| 3 | + <%= label_tag :service %> | |
| 4 | +    <%= select_tag 'agent[service_id]', options_for_select(@agent.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, @agent.service_id), class: 'form-control' %> | |
| 5 | + </div> | |
| 6 | +<% end %> | 
| @@ -0,0 +1,26 @@ | ||
| 1 | +<% if @twitter_agent || @basecamp_agent %> | |
| 2 | + <div class="alert alert-danger" role="alert"> | |
| 3 | + <p> | |
| 4 | + <b>Warning!</b> You need to update your Huginn configuration, so your agents continue to work with the new OAuth services. | |
| 5 | + </p> | |
| 6 | + <br/> | |
| 7 | + <% if @twitter_agent %> | |
| 8 | + <p> | |
| 9 | + To complete the migration of your <b>Twitter</b> agents you need to update your .env file and add the following two lines: | |
| 10 | + | |
| 11 | + <pre> | |
| 12 | +TWITTER_OAUTH_KEY=<%= @twitter_oauth_key %> | |
| 13 | +TWITTER_OAUTH_SECRET=<%= @twitter_oauth_secret %> | |
| 14 | + </pre> | |
| 15 | + To authenticate new accounts with your twitter OAuth application you need to log in the to <a href="https://apps.twitter.com/" target="_blank">twitter application management page</a> and set the callback URL of your application to "http<%= ENV['FORCE_SSL'] == 'true' ? 's' : '' %>://<%= ENV['DOMAIN'] %>/auth/twitter/callback". | |
| 16 | + | |
| 17 | + </p> | |
| 18 | + <% end %> | |
| 19 | + <% if @basecamp_agent %> | |
| 20 | + <p> | |
| 21 | + Your <b>Basecamp</b> agents could not be migrated automatically. You need to manually register an application with 37signals and authenticate Huginn to use it.<br/> | |
| 22 | + Have a look at the <%= link_to 'Wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: '_blank' %> if you need help. | |
| 23 | + </p> | |
| 24 | + <% end %> | |
| 25 | + </div> | |
| 26 | +<% end -%> | 
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | <%= nav_link "Scenarios", scenarios_path %> | 
| 23 | 23 | <%= nav_link "Events", events_path %> | 
| 24 | 24 | <%= nav_link "Credentials", user_credentials_path %> | 
| 25 | + <%= nav_link "Services", services_path %> | |
| 25 | 26 | </ul> | 
| 26 | 27 | <% end %> | 
| 27 | 28 |  | 
| @@ -24,7 +24,10 @@ | ||
| 24 | 24 | <%= render 'layouts/messages' %> | 
| 25 | 25 | </div> | 
| 26 | 26 | </div> | 
| 27 | - | |
| 27 | + <% if user_signed_in? %> | |
| 28 | + <%= render "upgrade_warning" %> | |
| 29 | + <% end %> | |
| 30 | + | |
| 28 | 31 | <%= yield %> | 
| 29 | 32 |  | 
| 30 | 33 | </div> | 
| @@ -13,9 +13,8 @@ | ||
| 13 | 13 | <div class="alert alert-warning"> | 
| 14 | 14 | <span class='glyphicon glyphicon-warning-sign'></span> | 
| 15 | 15 | This Scenario already exists in your system. The import will update your existing | 
| 16 | - <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario's title | |
| 17 | - and | |
| 18 | - description. Below you can customize how the individual agents get updated. | |
| 16 | + <%= scenario_label(@scenario_import.existing_scenario) %> Scenario's title, | |
| 17 | + description and tag colors. Below you can customize how the individual agents get updated. | |
| 19 | 18 | </div> | 
| 20 | 19 | <% end %> | 
| 21 | 20 |  | 
| @@ -120,6 +119,17 @@ | ||
| 120 | 119 | </div> | 
| 121 | 120 | <% end %> | 
| 122 | 121 | </div> | 
| 122 | + | |
| 123 | + <% if agent_diff.requires_service? %> | |
| 124 | + <div class='row'> | |
| 125 | + <div class='col-md-4'> | |
| 126 | + <div class="form-group type-select"> | |
| 127 | +              <%= label_tag "scenario_import[merges][#{index}][service_id]", 'Service' %> | |
| 128 | +              <%= select_tag "scenario_import[merges][#{index}][service_id]", options_for_select(agent_diff.agent_instance.valid_services_for(current_user).collect { |s| ["(#{s.provider}) #{s.name}", s.id]}, agent_diff.agent.try(:service_id)), class: 'form-control' %> | |
| 129 | + </div> | |
| 130 | + </div> | |
| 131 | + </div> | |
| 132 | + <% end %> | |
| 123 | 133 | </div> | 
| 124 | 134 | <% end %> | 
| 125 | 135 | </div> | 
| @@ -15,6 +15,18 @@ | ||
| 15 | 15 | <%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %> | 
| 16 | 16 | </div> | 
| 17 | 17 | </div> | 
| 18 | + <div class="col-md-2"> | |
| 19 | + <div class="form-group"> | |
| 20 | + <%= f.label :tag_bg_color, "Tag Background Color" %> | |
| 21 | + <%= f.color_field :tag_bg_color, :class => 'form-control', :value => @scenario.tag_bg_color || default_scenario_bg_color %> | |
| 22 | + </div> | |
| 23 | + </div> | |
| 24 | + <div class="col-md-2"> | |
| 25 | + <div class="form-group"> | |
| 26 | + <%= f.label :tag_fg_color, "Tag Foreground Color" %> | |
| 27 | + <%= f.color_field :tag_fg_color, :class => 'form-control', :value => @scenario.tag_fg_color || default_scenario_fg_color %> | |
| 28 | + </div> | |
| 29 | + </div> | |
| 18 | 30 | </div> | 
| 19 | 31 |  | 
| 20 | 32 | <div class="row"> | 
| @@ -54,4 +66,4 @@ | ||
| 54 | 66 | </div> | 
| 55 | 67 | </div> | 
| 56 | 68 | </div> | 
| 57 | -<% end %> | |
| 69 | +<% end %> | 
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | <% @scenarios.each do |scenario| %> | 
| 22 | 22 | <tr> | 
| 23 | 23 | <td> | 
| 24 | + <%= scenario_label(scenario, content_tag(:i, '', class: 'glyphicon glyphicon-font')) %> | |
| 24 | 25 | <%= link_to(scenario.name, scenario) %> | 
| 25 | 26 | </td> | 
| 26 | 27 | <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td> | 
| @@ -47,4 +48,4 @@ | ||
| 47 | 48 | </div> | 
| 48 | 49 | </div> | 
| 49 | 50 | </div> | 
| 50 | -</div> | |
| 51 | +</div> | 
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | <div class='row'> | 
| 3 | 3 | <div class='col-md-12'> | 
| 4 | 4 | <div class="page-header"> | 
| 5 | - <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2> | |
| 5 | + <h2>Share <%= scenario_label(@scenario) %> with the world</h2> | |
| 6 | 6 | </div> | 
| 7 | 7 |  | 
| 8 | 8 | <p> | 
| @@ -30,4 +30,4 @@ | ||
| 30 | 30 | </div> | 
| 31 | 31 | </div> | 
| 32 | 32 | </div> | 
| 33 | -</div> | |
| 33 | +</div> | 
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | <div class='row'> | 
| 3 | 3 | <div class='col-md-12'> | 
| 4 | 4 | <div class="page-header"> | 
| 5 | - <h2><span class='label label-info scenario'><%= @scenario.name %></span> <%= "Public" if @scenario.public? %> Scenario</h2> | |
| 5 | + <h2><%= scenario_label(@scenario) %> <%= "Public" if @scenario.public? %> Scenario</h2> | |
| 6 | + | |
| 6 | 7 | </div> | 
| 7 | 8 |  | 
| 8 | 9 | <% if @scenario.description.present? %> | 
| @@ -0,0 +1,58 @@ | ||
| 1 | +<div class='container'> | |
| 2 | + <div class='row'> | |
| 3 | + <div class='col-md-12'> | |
| 4 | + <div class="page-header"> | |
| 5 | + <h2> | |
| 6 | + Your Services | |
| 7 | + </h2> | |
| 8 | + </div> | |
| 9 | + <p> | |
| 10 | + Before you can authenticate with a service, you need to set it up. Have a look at the Huginn | |
| 11 | + <%= link_to 'wiki', 'https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications', target: :_blank %> | |
| 12 | + for guidance. | |
| 13 | + </p> | |
| 14 | +      <% if has_oauth_configuration_for('twitter') %> | |
| 15 | + <p><%= link_to "Authenticate with Twitter", "/auth/twitter" %></p> | |
| 16 | + <% end %> | |
| 17 | +      <% if has_oauth_configuration_for('thirty_seven_signals') %> | |
| 18 | + <p><%= link_to "Authenticate with 37Signals (Basecamp)", "/auth/37signals" %></p> | |
| 19 | + <% end -%> | |
| 20 | +      <% if has_oauth_configuration_for('github') %> | |
| 21 | + <p><%= link_to "Authenticate with Github", "/auth/github" %></p> | |
| 22 | + <% end -%> | |
| 23 | + <hr> | |
| 24 | + | |
| 25 | + <div class='table-responsive'> | |
| 26 | + <table class='table table-striped events'> | |
| 27 | + <tr> | |
| 28 | + <th>Provider</th> | |
| 29 | + <th>Username</th> | |
| 30 | + <th>Global?</th> | |
| 31 | + <th></th> | |
| 32 | + </tr> | |
| 33 | + | |
| 34 | + <% @services.each do |service| %> | |
| 35 | + <tr> | |
| 36 | + <td><%= service.provider %></td> | |
| 37 | + <td><%= service.name %></td> | |
| 38 | + <td><%= service.global ? 'Yes' : 'No' %></td> | |
| 39 | + <td> | |
| 40 | + <div class="btn-group btn-group-xs"> | |
| 41 | + <% if service.global %> | |
| 42 | +                  <%= link_to 'Make private', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to remove access to your data on this service for other users?'}, class: "btn btn-default" %> | |
| 43 | + <% else %> | |
| 44 | +                   <%= link_to 'Make global', toggle_availability_service_path(service), method: :post, data: { confirm: 'Are you sure you want to grant every user on this system access to your data on this service?'}, class: "btn btn-default" %> | |
| 45 | + <% end %> | |
| 46 | +                <%= link_to 'Delete', service_path(service), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %> | |
| 47 | + </div> | |
| 48 | + </td> | |
| 49 | + </tr> | |
| 50 | + <% end %> | |
| 51 | + </table> | |
| 52 | + </div> | |
| 53 | + | |
| 54 | + <%= paginate @services, :theme => 'twitter-bootstrap-3' %> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | +</div> | |
| 58 | + | 
| @@ -5,14 +5,14 @@ | ||
| 5 | 5 | </head> | 
| 6 | 6 | <body> | 
| 7 | 7 | <% if @headline %> | 
| 8 | - <h1><%= @headline %></h1> | |
| 8 | + <h1><%= sanitize @headline %></h1> | |
| 9 | 9 | <% end %> | 
| 10 | 10 | <% @groups.each do |group| %> | 
| 11 | 11 | <div style='margin-bottom: 10px;'> | 
| 12 | - <div><%= group[:title] %></div> | |
| 12 | + <div><%= sanitize group[:title] %></div> | |
| 13 | 13 | <% group[:entries].each do |entry| %> | 
| 14 | 14 | <div style='margin-left: 10px;'> | 
| 15 | - <%= entry %> | |
| 15 | + <%= sanitize entry %> | |
| 16 | 16 | </div> | 
| 17 | 17 | <% end %> | 
| 18 | 18 | </div> | 
| @@ -0,0 +1,5 @@ | ||
| 1 | +Rails.application.config.middleware.use OmniAuth::Builder do | |
| 2 | +  provider :twitter, ENV['TWITTER_OAUTH_KEY'], ENV['TWITTER_OAUTH_SECRET'], authorize_params: {force_login: 'true', use_authorize: 'true'} | |
| 3 | + provider '37signals', ENV['THIRTY_SEVEN_SIGNALS_OAUTH_KEY'], ENV['THIRTY_SEVEN_SIGNALS_OAUTH_SECRET'] | |
| 4 | + provider :github, ENV['GITHUB_OAUTH_KEY'], ENV['GITHUB_OAUTH_SECRET'] | |
| 5 | +end | 
| @@ -45,6 +45,12 @@ Huginn::Application.routes.draw do | ||
| 45 | 45 |  | 
| 46 | 46 | resources :user_credentials, :except => :show | 
| 47 | 47 |  | 
| 48 | + resources :services, :only => [:index, :destroy] do | |
| 49 | + member do | |
| 50 | + post :toggle_availability | |
| 51 | + end | |
| 52 | + end | |
| 53 | + | |
| 48 | 54 | get "/worker_status" => "worker_status#show" | 
| 49 | 55 |  | 
| 50 | 56 | post "/users/:user_id/update_location/:secret" => "user_location_updates#create" | 
| @@ -56,6 +62,7 @@ Huginn::Application.routes.draw do | ||
| 56 | 62 | # get "/delayed_job" => DelayedJobWeb, :anchor => false | 
| 57 | 63 |  | 
| 58 | 64 | devise_for :users, :sign_out_via => [ :post, :delete ] | 
| 65 | + get '/auth/:provider/callback', to: 'services#callback' | |
| 59 | 66 |  | 
| 60 | 67 | get "/about" => "home#about" | 
| 61 | 68 | root :to => "home#index" | 
| @@ -0,0 +1,18 @@ | ||
| 1 | +class CreateServices < ActiveRecord::Migration | |
| 2 | + def change | |
| 3 | + create_table :services do |t| | |
| 4 | + t.integer :user_id, null: false | |
| 5 | + t.string :provider, null: false | |
| 6 | + t.string :name, null: false | |
| 7 | + t.text :token, null: false | |
| 8 | + t.text :secret | |
| 9 | + t.text :refresh_token | |
| 10 | + t.datetime :expires_at | |
| 11 | + t.boolean :global, default: false | |
| 12 | + t.text :options | |
| 13 | + t.timestamps | |
| 14 | + end | |
| 15 | + add_index :services, :user_id | |
| 16 | + add_index :services, [:user_id, :global] | |
| 17 | + end | |
| 18 | +end | 
| @@ -0,0 +1,5 @@ | ||
| 1 | +class AddServiceIdToAgents < ActiveRecord::Migration | |
| 2 | + def change | |
| 3 | + add_column :agents, :service_id, :integer | |
| 4 | + end | |
| 5 | +end | 
| @@ -0,0 +1,61 @@ | ||
| 1 | +class MigrateAgentsToServiceAuthentication < ActiveRecord::Migration | |
| 2 | + def twitter_consumer_key(agent) | |
| 3 | +    agent.options['consumer_key'].presence || agent.credential('twitter_consumer_key') | |
| 4 | + end | |
| 5 | + | |
| 6 | + def twitter_consumer_secret(agent) | |
| 7 | +    agent.options['consumer_secret'].presence || agent.credential('twitter_consumer_secret') | |
| 8 | + end | |
| 9 | + | |
| 10 | + def twitter_oauth_token(agent) | |
| 11 | +    agent.options['oauth_token'].presence || agent.options['access_key'].presence || agent.credential('twitter_oauth_token') | |
| 12 | + end | |
| 13 | + | |
| 14 | + def twitter_oauth_token_secret(agent) | |
| 15 | +    agent.options['oauth_token_secret'].presence || agent.options['access_secret'].presence || agent.credential('twitter_oauth_token_secret') | |
| 16 | + end | |
| 17 | + | |
| 18 | + def up | |
| 19 | + agents = Agent.where(type: ['Agents::TwitterUserAgent', 'Agents::TwitterStreamAgent', 'Agents::TwitterPublishAgent']).each do |agent| | |
| 20 | + service = agent.user.services.create!( | |
| 21 | + provider: 'twitter', | |
| 22 | +        name: "Migrated '#{agent.name}'", | |
| 23 | + token: twitter_oauth_token(agent), | |
| 24 | + secret: twitter_oauth_token_secret(agent) | |
| 25 | + ) | |
| 26 | + agent.service_id = service.id | |
| 27 | + agent.save!(validate: false) | |
| 28 | + end | |
| 29 | + migrated = false | |
| 30 | + if agents.length > 0 | |
| 31 | + puts <<-EOF.strip_heredoc | |
| 32 | + | |
| 33 | + Your Twitter agents were successfully migrated. You need to update your .env file and add the following two lines: | |
| 34 | + | |
| 35 | +        TWITTER_OAUTH_KEY=#{twitter_consumer_key(agents.first)} | |
| 36 | +        TWITTER_OAUTH_SECRET=#{twitter_consumer_secret(agents.first)} | |
| 37 | + | |
| 38 | + To authenticate new accounts with your twitter OAuth application you need to log in the to twitter application management page (https://apps.twitter.com/) | |
| 39 | +        and set the callback URL of your application to "http#{ENV['FORCE_SSL'] == 'true' ? 's' : ''}://#{ENV['DOMAIN']}/auth/twitter/callback" | |
| 40 | + | |
| 41 | + EOF | |
| 42 | + migrated = true | |
| 43 | + end | |
| 44 | + if Agent.where(type: ['Agents::BasecampAgent']).count > 0 | |
| 45 | + puts <<-EOF.strip_heredoc | |
| 46 | + | |
| 47 | + Your Basecamp agents can not be migrated automatically. You need to manually register an application with 37signals and authenticate Huginn to use it. | |
| 48 | + Have a look at the wiki (https://github.com/cantino/huginn/wiki/Configuring-OAuth-applications) if you need help. | |
| 49 | + | |
| 50 | + | |
| 51 | + EOF | |
| 52 | + migrated = true | |
| 53 | + end | |
| 54 | + sleep 20 if migrated | |
| 55 | + end | |
| 56 | + | |
| 57 | + def down | |
| 58 | + raise ActiveRecord::IrreversibleMigration, "Cannot revert migration to OAuth services" | |
| 59 | + end | |
| 60 | +end | |
| 61 | + | 
| @@ -0,0 +1,5 @@ | ||
| 1 | +class RemoveServiceIndexOnUserId < ActiveRecord::Migration | |
| 2 | + def change | |
| 3 | + remove_index :services, :user_id | |
| 4 | + end | |
| 5 | +end | 
| @@ -0,0 +1,7 @@ | ||
| 1 | +class AddUidColumnToServices < ActiveRecord::Migration | |
| 2 | + def change | |
| 3 | + add_column :services, :uid, :string | |
| 4 | + add_index :services, :uid | |
| 5 | + add_index :services, :provider | |
| 6 | + end | |
| 7 | +end | 
| @@ -0,0 +1,6 @@ | ||
| 1 | +class AddTagColorToScenarios < ActiveRecord::Migration | |
| 2 | + def change | |
| 3 | + add_column :scenarios, :tag_bg_color, :string | |
| 4 | + add_column :scenarios, :tag_fg_color, :string | |
| 5 | + end | |
| 6 | +end | 
| @@ -13,19 +13,22 @@ | ||
| 13 | 13 |  | 
| 14 | 14 | ActiveRecord::Schema.define(version: 20140822085519) do | 
| 15 | 15 |  | 
| 16 | + # These are extensions that must be enabled in order to support this database | |
| 17 | + enable_extension "plpgsql" | |
| 18 | + | |
| 16 | 19 | create_table "agent_logs", force: true do |t| | 
| 17 | - t.integer "agent_id", null: false | |
| 18 | - t.text "message", limit: 16777215, null: false | |
| 19 | - t.integer "level", default: 3, null: false | |
| 20 | + t.integer "agent_id", null: false | |
| 21 | + t.text "message", null: false | |
| 22 | + t.integer "level", default: 3, null: false | |
| 20 | 23 | t.integer "inbound_event_id" | 
| 21 | 24 | t.integer "outbound_event_id" | 
| 22 | - t.datetime "created_at", null: false | |
| 23 | - t.datetime "updated_at", null: false | |
| 25 | + t.datetime "created_at" | |
| 26 | + t.datetime "updated_at" | |
| 24 | 27 | end | 
| 25 | 28 |  | 
| 26 | 29 | create_table "agents", force: true do |t| | 
| 27 | 30 | t.integer "user_id" | 
| 28 | - t.text "options", limit: 16777215 | |
| 31 | + t.text "options" | |
| 29 | 32 | t.string "type" | 
| 30 | 33 | t.string "name" | 
| 31 | 34 | t.string "schedule" | 
| @@ -33,16 +36,17 @@ ActiveRecord::Schema.define(version: 20140822085519) do | ||
| 33 | 36 | t.datetime "last_check_at" | 
| 34 | 37 | t.datetime "last_receive_at" | 
| 35 | 38 | t.integer "last_checked_event_id" | 
| 36 | - t.datetime "created_at", null: false | |
| 37 | - t.datetime "updated_at", null: false | |
| 38 | - t.text "memory", limit: 2147483647 | |
| 39 | + t.datetime "created_at" | |
| 40 | + t.datetime "updated_at" | |
| 41 | + t.text "memory" | |
| 39 | 42 | t.datetime "last_web_request_at" | 
| 43 | + t.integer "keep_events_for", default: 0, null: false | |
| 40 | 44 | t.datetime "last_event_at" | 
| 41 | 45 | t.datetime "last_error_log_at" | 
| 42 | - t.integer "keep_events_for", default: 0, null: false | |
| 43 | - t.boolean "propagate_immediately", default: false, null: false | |
| 44 | - t.boolean "disabled", default: false, null: false | |
| 45 | - t.string "guid", null: false | |
| 46 | + t.boolean "propagate_immediately", default: false, null: false | |
| 47 | + t.boolean "disabled", default: false, null: false | |
| 48 | + t.string "guid", null: false | |
| 49 | + t.integer "service_id" | |
| 46 | 50 | end | 
| 47 | 51 |  | 
| 48 | 52 | add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree | 
| @@ -61,17 +65,17 @@ ActiveRecord::Schema.define(version: 20140822085519) do | ||
| 61 | 65 | add_index "chains", ["target_id"], name: "index_chains_on_target_id", using: :btree | 
| 62 | 66 |  | 
| 63 | 67 | create_table "delayed_jobs", force: true do |t| | 
| 64 | - t.integer "priority", default: 0 | |
| 65 | - t.integer "attempts", default: 0 | |
| 66 | - t.text "handler", limit: 16777215 | |
| 67 | - t.text "last_error", limit: 16777215 | |
| 68 | + t.integer "priority", default: 0 | |
| 69 | + t.integer "attempts", default: 0 | |
| 70 | + t.text "handler" | |
| 71 | + t.text "last_error" | |
| 68 | 72 | t.datetime "run_at" | 
| 69 | 73 | t.datetime "locked_at" | 
| 70 | 74 | t.datetime "failed_at" | 
| 71 | 75 | t.string "locked_by" | 
| 72 | 76 | t.string "queue" | 
| 73 | - t.datetime "created_at", null: false | |
| 74 | - t.datetime "updated_at", null: false | |
| 77 | + t.datetime "created_at" | |
| 78 | + t.datetime "updated_at" | |
| 75 | 79 | end | 
| 76 | 80 |  | 
| 77 | 81 | add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree | 
| @@ -79,11 +83,11 @@ ActiveRecord::Schema.define(version: 20140822085519) do | ||
| 79 | 83 | create_table "events", force: true do |t| | 
| 80 | 84 | t.integer "user_id" | 
| 81 | 85 | t.integer "agent_id" | 
| 82 | - t.decimal "lat", precision: 15, scale: 10 | |
| 83 | - t.decimal "lng", precision: 15, scale: 10 | |
| 84 | - t.text "payload", limit: 2147483647 | |
| 85 | - t.datetime "created_at", null: false | |
| 86 | - t.datetime "updated_at", null: false | |
| 86 | + t.decimal "lat", precision: 15, scale: 10 | |
| 87 | + t.decimal "lng", precision: 15, scale: 10 | |
| 88 | + t.text "payload" | |
| 89 | + t.datetime "created_at" | |
| 90 | + t.datetime "updated_at" | |
| 87 | 91 | t.datetime "expires_at" | 
| 88 | 92 | end | 
| 89 | 93 |  | 
| @@ -94,8 +98,8 @@ ActiveRecord::Schema.define(version: 20140822085519) do | ||
| 94 | 98 | create_table "links", force: true do |t| | 
| 95 | 99 | t.integer "source_id" | 
| 96 | 100 | t.integer "receiver_id" | 
| 97 | - t.datetime "created_at", null: false | |
| 98 | - t.datetime "updated_at", null: false | |
| 101 | + t.datetime "created_at" | |
| 102 | + t.datetime "updated_at" | |
| 99 | 103 | t.integer "event_id_at_creation", default: 0, null: false | 
| 100 | 104 | end | 
| 101 | 105 |  | 
| @@ -113,24 +117,45 @@ ActiveRecord::Schema.define(version: 20140822085519) do | ||
| 113 | 117 | add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree | 
| 114 | 118 |  | 
| 115 | 119 | create_table "scenarios", force: true do |t| | 
| 116 | - t.string "name", null: false | |
| 117 | - t.integer "user_id", null: false | |
| 120 | + t.string "name", null: false | |
| 121 | + t.integer "user_id", null: false | |
| 118 | 122 | t.datetime "created_at" | 
| 119 | 123 | t.datetime "updated_at" | 
| 120 | 124 | t.text "description" | 
| 121 | - t.boolean "public", default: false, null: false | |
| 122 | - t.string "guid", null: false | |
| 125 | + t.boolean "public", default: false, null: false | |
| 126 | + t.string "guid", null: false | |
| 123 | 127 | t.string "source_url" | 
| 128 | + t.string "tag_bg_color" | |
| 129 | + t.string "tag_fg_color" | |
| 124 | 130 | end | 
| 125 | 131 |  | 
| 126 | 132 | add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree | 
| 127 | 133 |  | 
| 134 | + create_table "services", force: true do |t| | |
| 135 | + t.integer "user_id", null: false | |
| 136 | + t.string "provider", null: false | |
| 137 | + t.string "name", null: false | |
| 138 | + t.text "token", null: false | |
| 139 | + t.text "secret" | |
| 140 | + t.text "refresh_token" | |
| 141 | + t.datetime "expires_at" | |
| 142 | + t.boolean "global", default: false | |
| 143 | + t.text "options" | |
| 144 | + t.datetime "created_at" | |
| 145 | + t.datetime "updated_at" | |
| 146 | + t.string "uid" | |
| 147 | + end | |
| 148 | + | |
| 149 | + add_index "services", ["provider"], name: "index_services_on_provider", using: :btree | |
| 150 | + add_index "services", ["uid"], name: "index_services_on_uid", using: :btree | |
| 151 | + add_index "services", ["user_id", "global"], name: "index_services_on_user_id_and_global", using: :btree | |
| 152 | + | |
| 128 | 153 | create_table "user_credentials", force: true do |t| | 
| 129 | 154 | t.integer "user_id", null: false | 
| 130 | 155 | t.string "credential_name", null: false | 
| 131 | 156 | t.text "credential_value", null: false | 
| 132 | - t.datetime "created_at", null: false | |
| 133 | - t.datetime "updated_at", null: false | |
| 157 | + t.datetime "created_at" | |
| 158 | + t.datetime "updated_at" | |
| 134 | 159 | t.string "mode", default: "text", null: false | 
| 135 | 160 | end | 
| 136 | 161 |  | 
| @@ -147,8 +172,8 @@ ActiveRecord::Schema.define(version: 20140822085519) do | ||
| 147 | 172 | t.datetime "last_sign_in_at" | 
| 148 | 173 | t.string "current_sign_in_ip" | 
| 149 | 174 | t.string "last_sign_in_ip" | 
| 150 | - t.datetime "created_at", null: false | |
| 151 | - t.datetime "updated_at", null: false | |
| 175 | + t.datetime "created_at" | |
| 176 | + t.datetime "updated_at" | |
| 152 | 177 | t.boolean "admin", default: false, null: false | 
| 153 | 178 | t.integer "failed_attempts", default: 0 | 
| 154 | 179 | t.string "unlock_token" | 
| @@ -16,6 +16,8 @@ class AgentsExporter | ||
| 16 | 16 | :description => options[:description].presence || 'No description provided', | 
| 17 | 17 | :source_url => options[:source_url], | 
| 18 | 18 | :guid => options[:guid], | 
| 19 | + :tag_fg_color => options[:tag_fg_color], | |
| 20 | + :tag_bg_color => options[:tag_bg_color], | |
| 19 | 21 | :exported_at => Time.now.utc.iso8601, | 
| 20 | 22 |        :agents => agents.map { |agent| agent_as_json(agent) }, | 
| 21 | 23 | :links => links | 
| @@ -51,4 +53,4 @@ class AgentsExporter | ||
| 51 | 53 | options[:propagate_immediately] = agent.propagate_immediately if agent.can_receive_events? | 
| 52 | 54 | end | 
| 53 | 55 | end | 
| 54 | -end | |
| 56 | +end | 
| @@ -0,0 +1,35 @@ | ||
| 1 | +require 'spec_helper' | |
| 2 | + | |
| 3 | +describe LiquidInterpolatable::Filters do | |
| 4 | + before do | |
| 5 | + @filter = Class.new do | |
| 6 | + include LiquidInterpolatable::Filters | |
| 7 | + end.new | |
| 8 | + end | |
| 9 | + | |
| 10 | + describe 'uri_escape' do | |
| 11 | + it 'should escape a string for use in URI' do | |
| 12 | +      @filter.uri_escape('abc:/?=').should == 'abc%3A%2F%3F%3D' | |
| 13 | + end | |
| 14 | + end | |
| 15 | + | |
| 16 | + describe 'validations' do | |
| 17 | + class Agents::InterpolatableAgent < Agent | |
| 18 | + include LiquidInterpolatable | |
| 19 | + | |
| 20 | + def check | |
| 21 | +        create_event :payload => {} | |
| 22 | + end | |
| 23 | + | |
| 24 | + def validate_options | |
| 25 | + interpolated['foo'] | |
| 26 | + end | |
| 27 | + end | |
| 28 | + | |
| 29 | + it "should finish without raising an exception" do | |
| 30 | +      agent = Agents::InterpolatableAgent.new(name: "test", options: { 'foo' => '{{bar}' }) | |
| 31 | + agent.valid?.should == false | |
| 32 | + agent.errors[:options].first.should =~ /not properly terminated/ | |
| 33 | + end | |
| 34 | + end | |
| 35 | +end | 
| @@ -50,6 +50,8 @@ describe ScenariosController do | ||
| 50 | 50 | assigns(:exporter).options[:description].should == scenarios(:bob_weather).description | 
| 51 | 51 | assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents | 
| 52 | 52 | assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid | 
| 53 | + assigns(:exporter).options[:tag_fg_color].should == scenarios(:bob_weather).tag_fg_color | |
| 54 | + assigns(:exporter).options[:tag_bg_color].should == scenarios(:bob_weather).tag_bg_color | |
| 53 | 55 | assigns(:exporter).options[:source_url].should be_falsey | 
| 54 | 56 | response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"' | 
| 55 | 57 | response.headers['Content-Type'].should == 'application/json; charset=utf-8' | 
| @@ -0,0 +1,58 @@ | ||
| 1 | +require 'spec_helper' | |
| 2 | + | |
| 3 | +describe ServicesController do | |
| 4 | + before do | |
| 5 | + sign_in users(:bob) | |
| 6 | + OmniAuth.config.test_mode = true | |
| 7 | +    request.env["omniauth.auth"] = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json'))) | |
| 8 | + end | |
| 9 | + | |
| 10 | + describe "GET index" do | |
| 11 | + it "only returns sevices of the current user" do | |
| 12 | + get :index | |
| 13 | +      assigns(:services).all? {|i| i.user.should == users(:bob) }.should == true | |
| 14 | + end | |
| 15 | + end | |
| 16 | + | |
| 17 | + describe "POST toggle_availability" do | |
| 18 | + it "should work for service of the user" do | |
| 19 | + post :toggle_availability, :id => services(:generic).to_param | |
| 20 | + assigns(:service).should eq(services(:generic)) | |
| 21 | + redirect_to(services_path) | |
| 22 | + end | |
| 23 | + | |
| 24 | + it "should not work for a service of another user" do | |
| 25 | +      lambda { | |
| 26 | + post :toggle_availability, :id => services(:global).to_param | |
| 27 | + }.should raise_error(ActiveRecord::RecordNotFound) | |
| 28 | + end | |
| 29 | + end | |
| 30 | + | |
| 31 | + describe "DELETE destroy" do | |
| 32 | + it "destroys only services owned by the current user" do | |
| 33 | +      expect { | |
| 34 | + delete :destroy, :id => services(:generic).to_param | |
| 35 | + }.to change(Service, :count).by(-1) | |
| 36 | + | |
| 37 | +      lambda { | |
| 38 | + delete :destroy, :id => services(:global).to_param | |
| 39 | + }.should raise_error(ActiveRecord::RecordNotFound) | |
| 40 | + end | |
| 41 | + end | |
| 42 | + | |
| 43 | + describe "accepting a callback url" do | |
| 44 | + it "should update the user's credentials" do | |
| 45 | +      expect { | |
| 46 | + get :callback, provider: 'twitter' | |
| 47 | +      }.to change { users(:bob).services.count }.by(1) | |
| 48 | + end | |
| 49 | + | |
| 50 | + it "should work with an unknown provider (for now)" do | |
| 51 | + request.env["omniauth.auth"]['provider'] = 'unknown' | |
| 52 | +      expect { | |
| 53 | + get :callback, provider: 'unknown' | |
| 54 | +      }.to change { users(:bob).services.count }.by(1) | |
| 55 | + users(:bob).services.first.provider.should == 'unknown' | |
| 56 | + end | |
| 57 | + end | |
| 58 | +end | 
| @@ -0,0 +1,43 @@ | ||
| 1 | +{ | |
| 2 | + "provider": "37signals", | |
| 3 | + "uid": 12345, | |
| 4 | +  "info": { | |
| 5 | + "email": "basecamp@none.de", | |
| 6 | + "first_name": "Dominik", | |
| 7 | + "last_name": "Sander", | |
| 8 | + "name": "Dominik Sander" | |
| 9 | + }, | |
| 10 | +  "credentials": { | |
| 11 | + "token": "abcde", | |
| 12 | + "refresh_token": "fghrefresh", | |
| 13 | + "expires_at": 1401554352, | |
| 14 | + "expires": true | |
| 15 | + }, | |
| 16 | +  "extra": { | |
| 17 | + "accounts": [ | |
| 18 | +      { | |
| 19 | + "product": "bcx", | |
| 20 | + "name": "Dominik Sander's Basecamp", | |
| 21 | + "id": 12345, | |
| 22 | + "href": "https://basecamp.com/12345/api/v1" | |
| 23 | + } | |
| 24 | + ], | |
| 25 | +    "raw_info": { | |
| 26 | + "expires_at": "2014-05-31T16:39:12Z", | |
| 27 | +      "identity": { | |
| 28 | + "first_name": "Dominik", | |
| 29 | + "last_name": "Sander", | |
| 30 | + "email_address": "basecamp@none.de", | |
| 31 | + "id": 12345 | |
| 32 | + }, | |
| 33 | + "accounts": [ | |
| 34 | +        { | |
| 35 | + "product": "bcx", | |
| 36 | + "name": "Dominik Sander's Basecamp", | |
| 37 | + "id": 12345, | |
| 38 | + "href": "https://basecamp.com/12345/api/v1" | |
| 39 | + } | |
| 40 | + ] | |
| 41 | + } | |
| 42 | + } | |
| 43 | +} | 
| @@ -0,0 +1,52 @@ | ||
| 1 | +{ | |
| 2 | + "provider": "github", | |
| 3 | + "uid": "12345", | |
| 4 | +  "info": { | |
| 5 | + "nickname": "dsander", | |
| 6 | + "email": null, | |
| 7 | + "name": "Dominik Sander", | |
| 8 | + "image": "https://avatars.githubusercontent.com/u/12345?", | |
| 9 | +    "urls": { | |
| 10 | + "GitHub": "https://github.com/dsander", | |
| 11 | + "Blog": "http://www.dsander.de" | |
| 12 | + } | |
| 13 | + }, | |
| 14 | +  "credentials": { | |
| 15 | + "token": "agithubtoken", | |
| 16 | + "expires": false | |
| 17 | + }, | |
| 18 | +  "extra": { | |
| 19 | +    "raw_info": { | |
| 20 | + "login": "dsander", | |
| 21 | + "id": 12345, | |
| 22 | + "avatar_url": "https://avatars.githubusercontent.com/u/12345?", | |
| 23 | + "gravatar_id": "fsdfsdf", | |
| 24 | + "url": "https://api.github.com/users/dsander", | |
| 25 | + "html_url": "https://github.com/dsander", | |
| 26 | + "followers_url": "https://api.github.com/users/dsander/followers", | |
| 27 | +      "following_url": "https://api.github.com/users/dsander/following{/other_user}", | |
| 28 | +      "gists_url": "https://api.github.com/users/dsander/gists{/gist_id}", | |
| 29 | +      "starred_url": "https://api.github.com/users/dsander/starred{/owner}{/repo}", | |
| 30 | + "subscriptions_url": "https://api.github.com/users/dsander/subscriptions", | |
| 31 | + "organizations_url": "https://api.github.com/users/dsander/orgs", | |
| 32 | + "repos_url": "https://api.github.com/users/dsander/repos", | |
| 33 | +      "events_url": "https://api.github.com/users/dsander/events{/privacy}", | |
| 34 | + "received_events_url": "https://api.github.com/users/dsander/received_events", | |
| 35 | + "type": "User", | |
| 36 | + "site_admin": false, | |
| 37 | + "name": "Dominik Sander", | |
| 38 | + "company": null, | |
| 39 | + "blog": "http://www.url.de", | |
| 40 | + "location": null, | |
| 41 | + "email": null, | |
| 42 | + "hireable": false, | |
| 43 | + "bio": null, | |
| 44 | + "public_repos": 29, | |
| 45 | + "public_gists": 2, | |
| 46 | + "followers": 21, | |
| 47 | + "following": 9, | |
| 48 | + "created_at": "2008-08-17T18:17:50Z", | |
| 49 | + "updated_at": "2014-05-19T09:30:08Z" | |
| 50 | + } | |
| 51 | + } | |
| 52 | +} | 
| @@ -0,0 +1,66 @@ | ||
| 1 | +{ | |
| 2 | + "provider": "twitter", | |
| 3 | + "uid": "123456", | |
| 4 | +  "info": { | |
| 5 | + "nickname": "johnqpublic", | |
| 6 | + "name": "John Q Public", | |
| 7 | + "location": "Anytown, USA", | |
| 8 | + "image": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", | |
| 9 | + "description": "a very normal guy.", | |
| 10 | +    "urls": { | |
| 11 | + "Website": null, | |
| 12 | + "Twitter": "https://twitter.com/johnqpublic" | |
| 13 | + } | |
| 14 | + }, | |
| 15 | +  "credentials": { | |
| 16 | + "token": "a1b2c3d4...", | |
| 17 | + "secret": "abcdef1234" | |
| 18 | + }, | |
| 19 | +  "extra": { | |
| 20 | + "access_token": "", | |
| 21 | +    "raw_info": { | |
| 22 | + "name": "John Q Public", | |
| 23 | + "listed_count": 0, | |
| 24 | + "profile_sidebar_border_color": "181A1E", | |
| 25 | + "url": null, | |
| 26 | + "lang": "en", | |
| 27 | + "statuses_count": 129, | |
| 28 | + "profile_image_url": "http://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", | |
| 29 | + "profile_background_image_url_https": "https://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif", | |
| 30 | + "location": "Anytown, USA", | |
| 31 | + "time_zone": "Chicago", | |
| 32 | + "follow_request_sent": false, | |
| 33 | + "id": 123456, | |
| 34 | + "profile_background_tile": true, | |
| 35 | + "profile_sidebar_fill_color": "666666", | |
| 36 | + "followers_count": 1, | |
| 37 | + "default_profile_image": false, | |
| 38 | + "screen_name": "", | |
| 39 | + "following": false, | |
| 40 | + "utc_offset": -3600, | |
| 41 | + "verified": false, | |
| 42 | + "favourites_count": 0, | |
| 43 | + "profile_background_color": "1A1B1F", | |
| 44 | + "is_translator": false, | |
| 45 | + "friends_count": 1, | |
| 46 | + "notifications": false, | |
| 47 | + "geo_enabled": true, | |
| 48 | + "profile_background_image_url": "http://twimg0-a.akamaihd.net/profile_background_images/229171796/pattern_036.gif", | |
| 49 | + "protected": false, | |
| 50 | + "description": "a very normal guy.", | |
| 51 | + "profile_link_color": "2FC2EF", | |
| 52 | + "created_at": "Thu Jul 4 00:00:00 +0000 2013", | |
| 53 | + "id_str": "123456", | |
| 54 | + "profile_image_url_https": "https://si0.twimg.com/sticky/default_profile_images/default_profile_2_normal.png", | |
| 55 | + "default_profile": false, | |
| 56 | + "profile_use_background_image": false, | |
| 57 | +      "entities": { | |
| 58 | +        "description": { | |
| 59 | + "urls": [] | |
| 60 | + } | |
| 61 | + }, | |
| 62 | + "profile_text_color": "666666", | |
| 63 | + "contributors_enabled": false | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} | 
| @@ -0,0 +1,5 @@ | ||
| 1 | +APP_SECRET_TOKEN=notarealappsecrettoken | |
| 2 | +TWITTER_OAUTH_KEY=twitteroauthkey | |
| 3 | +TWITTER_OAUTH_SECRET=twitteroauthsecret | |
| 4 | +THIRTY_SEVEN_SIGNALS_OAUTH_KEY=TESTKEY | |
| 5 | +THIRTY_SEVEN_SIGNALS_OAUTH_SECRET=TESTSECRET | 
| @@ -109,3 +109,15 @@ bob_manual_event_agent: | ||
| 109 | 109 | user: bob | 
| 110 | 110 | name: "Bob's event testing agent" | 
| 111 | 111 | guid: <%= SecureRandom.hex %> | 
| 112 | + | |
| 113 | +bob_basecamp_agent: | |
| 114 | + type: Agents::BasecampAgent | |
| 115 | + user: bob | |
| 116 | + service: generic | |
| 117 | + guid: <%= SecureRandom.hex %> | |
| 118 | + | |
| 119 | +jane_basecamp_agent: | |
| 120 | + type: Agents::BasecampAgent | |
| 121 | + user: jane | |
| 122 | + service: generic | |
| 123 | + guid: <%= SecureRandom.hex %> | 
| @@ -0,0 +1,17 @@ | ||
| 1 | +generic: | |
| 2 | + token: 1234token | |
| 3 | + secret: 56789secret | |
| 4 | + refresh_token: refresh12345 | |
| 5 | + provider: testprovider | |
| 6 | + name: test | |
| 7 | +  expires_at: <%= Time.parse("2015-01-01 00:00:00") %> | |
| 8 | +  options: <%= { user_id: 12345 }.to_yaml.inspect %> | |
| 9 | + user: bob | |
| 10 | +global: | |
| 11 | + token: 1234token | |
| 12 | + provider: testprovider | |
| 13 | + name: test | |
| 14 | +  expires_at: <%= Time.parse("2015-01-01 00:00:00") %> | |
| 15 | +  options: <%= { user_id: 12345 }.to_yaml.inspect %> | |
| 16 | + user: jane | |
| 17 | + global: true | 
| @@ -0,0 +1,30 @@ | ||
| 1 | +require 'spec_helper' | |
| 2 | + | |
| 3 | +describe ScenarioHelper do | |
| 4 | +  let(:scenario) { users(:bob).scenarios.build(name: 'Scene', tag_fg_color: '#AAAAAA', tag_bg_color: '#000000') } | |
| 5 | + | |
| 6 | + describe '#style_colors' do | |
| 7 | + it 'returns a css style-formated version of the scenario foreground and background colors' do | |
| 8 | + style_colors(scenario).should == "color:#AAAAAA;background-color:#000000" | |
| 9 | + end | |
| 10 | + | |
| 11 | + it 'defauls foreground and background colors' do | |
| 12 | + scenario.tag_fg_color = nil | |
| 13 | + scenario.tag_bg_color = nil | |
| 14 | + style_colors(scenario).should == "color:#FFFFFF;background-color:#5BC0DE" | |
| 15 | + end | |
| 16 | + end | |
| 17 | + | |
| 18 | + describe '#scenario_label' do | |
| 19 | + it 'creates a scenario label with the scenario name' do | |
| 20 | + scenario_label(scenario).should == | |
| 21 | + '<span class="label scenario" style="color:#AAAAAA;background-color:#000000">Scene</span>' | |
| 22 | + end | |
| 23 | + | |
| 24 | + it 'creates a scenario label with the given text' do | |
| 25 | + scenario_label(scenario, 'Other').should == | |
| 26 | + '<span class="label scenario" style="color:#AAAAAA;background-color:#000000">Other</span>' | |
| 27 | + end | |
| 28 | + end | |
| 29 | + | |
| 30 | +end | 
| @@ -7,9 +7,13 @@ describe AgentsExporter do | ||
| 7 | 7 |      let(:name) { "My set of Agents" } | 
| 8 | 8 |      let(:description) { "These Agents work together nicely!" } | 
| 9 | 9 |      let(:guid) { "some-guid" } | 
| 10 | +    let(:tag_fg_color) { "#ffffff" } | |
| 11 | +    let(:tag_bg_color) { "#000000" } | |
| 10 | 12 |      let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" } | 
| 11 | 13 |      let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] } | 
| 12 | -    let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) } | |
| 14 | +    let(:exporter) { AgentsExporter.new( | |
| 15 | + :agents => agent_list, :name => name, :description => description, :source_url => source_url, | |
| 16 | + :guid => guid, :tag_fg_color => tag_fg_color, :tag_bg_color => tag_bg_color) } | |
| 13 | 17 |  | 
| 14 | 18 | it "outputs a structure containing name, description, the date, all agents & their links" do | 
| 15 | 19 | data = exporter.as_json | 
| @@ -17,6 +21,8 @@ describe AgentsExporter do | ||
| 17 | 21 | data[:description].should == description | 
| 18 | 22 | data[:source_url].should == source_url | 
| 19 | 23 | data[:guid].should == guid | 
| 24 | + data[:tag_fg_color].should == tag_fg_color | |
| 25 | + data[:tag_bg_color].should == tag_bg_color | |
| 20 | 26 | Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc) | 
| 21 | 27 |        data[:links].should == [{ :source => 0, :receiver => 1 }] | 
| 22 | 28 |        data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) } | 
| @@ -58,4 +64,4 @@ describe AgentsExporter do | ||
| 58 | 64 | AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json" | 
| 59 | 65 | end | 
| 60 | 66 | end | 
| 61 | -end | |
| 67 | +end | 
| @@ -1,17 +1,16 @@ | ||
| 1 | 1 | require 'spec_helper' | 
| 2 | +require 'models/concerns/oauthable' | |
| 2 | 3 |  | 
| 3 | 4 | describe Agents::BasecampAgent do | 
| 5 | + it_behaves_like Oauthable | |
| 6 | + | |
| 4 | 7 | before(:each) do | 
| 5 | 8 |      stub_request(:get, /json$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"}) | 
| 6 | -    stub_request(:get, /Z$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"}) | |
| 7 | -    @valid_params = { | |
| 8 | - :username => "user", | |
| 9 | - :password => "pass", | |
| 10 | - :user_id => 12345, | |
| 11 | - :project_id => 6789, | |
| 12 | - } | |
| 9 | +    stub_request(:get, /02:00$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"}) | |
| 10 | +    @valid_params = { :project_id => 6789 } | |
| 13 | 11 |  | 
| 14 | 12 | @checker = Agents::BasecampAgent.new(:name => "somename", :options => @valid_params) | 
| 13 | + @checker.service = services(:generic) | |
| 15 | 14 | @checker.user = users(:jane) | 
| 16 | 15 | @checker.save! | 
| 17 | 16 | end | 
| @@ -21,21 +20,6 @@ describe Agents::BasecampAgent do | ||
| 21 | 20 | @checker.should be_valid | 
| 22 | 21 | end | 
| 23 | 22 |  | 
| 24 | - it "should require the basecamp username" do | |
| 25 | - @checker.options['username'] = nil | |
| 26 | - @checker.should_not be_valid | |
| 27 | - end | |
| 28 | - | |
| 29 | - it "should require the basecamp password" do | |
| 30 | - @checker.options['password'] = nil | |
| 31 | - @checker.should_not be_valid | |
| 32 | - end | |
| 33 | - | |
| 34 | - it "should require the basecamp user_id" do | |
| 35 | - @checker.options['user_id'] = nil | |
| 36 | - @checker.should_not be_valid | |
| 37 | - end | |
| 38 | - | |
| 39 | 23 | it "should require the basecamp project_id" do | 
| 40 | 24 | @checker.options['project_id'] = nil | 
| 41 | 25 | @checker.should_not be_valid | 
| @@ -45,7 +29,7 @@ describe Agents::BasecampAgent do | ||
| 45 | 29 |  | 
| 46 | 30 | describe "helpers" do | 
| 47 | 31 | it "should generate a correct request options hash" do | 
| 48 | -      @checker.send(:request_options).should == {:basic_auth=>{:username=>"user", :password=>"pass"}, :headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}} | |
| 32 | +      @checker.send(:request_options).should == {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => 'Bearer "1234token"'}} | |
| 49 | 33 | end | 
| 50 | 34 |  | 
| 51 | 35 | it "should generate the currect request url" do | 
| @@ -59,7 +43,7 @@ describe Agents::BasecampAgent do | ||
| 59 | 43 |  | 
| 60 | 44 | it "should provide the since attribute after the first run" do | 
| 61 | 45 | time = (Time.now-1.minute).iso8601 | 
| 62 | - @checker.memory[:last_run] = time | |
| 46 | + @checker.memory[:last_event] = time | |
| 63 | 47 | @checker.save | 
| 64 | 48 |        @checker.reload.send(:query_parameters).should == {:query => {:since => time}} | 
| 65 | 49 | end | 
| @@ -67,9 +51,10 @@ describe Agents::BasecampAgent do | ||
| 67 | 51 | describe "#check" do | 
| 68 | 52 | it "should not emit events on its first run" do | 
| 69 | 53 |        expect { @checker.check }.to change { Event.count }.by(0) | 
| 54 | + expect(@checker.memory[:last_event]).to eq '2014-04-17T10:25:31.000+02:00' | |
| 70 | 55 | end | 
| 71 | 56 | it "should check that initial run creates an event" do | 
| 72 | - @checker.last_check_at = Time.now - 1.minute | |
| 57 | + @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00' | |
| 73 | 58 |        expect { @checker.check }.to change { Event.count }.by(1) | 
| 74 | 59 | end | 
| 75 | 60 | end | 
| @@ -77,7 +62,7 @@ describe Agents::BasecampAgent do | ||
| 77 | 62 | describe "#working?" do | 
| 78 | 63 | it "it is working when at least one event was emited" do | 
| 79 | 64 | @checker.should_not be_working | 
| 80 | - @checker.last_check_at = Time.now - 1.minute | |
| 65 | + @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00' | |
| 81 | 66 | @checker.check | 
| 82 | 67 | @checker.reload.should be_working | 
| 83 | 68 | end | 
| @@ -13,6 +13,7 @@ describe Agents::TwitterPublishAgent do | ||
| 13 | 13 | } | 
| 14 | 14 |  | 
| 15 | 15 | @checker = Agents::TwitterPublishAgent.new(:name => "HuginnBot", :options => @opts) | 
| 16 | + @checker.service = services(:generic) | |
| 16 | 17 | @checker.user = users(:bob) | 
| 17 | 18 | @checker.save! | 
| 18 | 19 |  | 
| @@ -13,6 +13,7 @@ describe Agents::TwitterStreamAgent do | ||
| 13 | 13 | } | 
| 14 | 14 |  | 
| 15 | 15 | @agent = Agents::TwitterStreamAgent.new(:name => "HuginnBot", :options => @opts) | 
| 16 | + @agent.service = services(:generic) | |
| 16 | 17 | @agent.user = users(:bob) | 
| 17 | 18 | @agent.save! | 
| 18 | 19 | end | 
| @@ -16,6 +16,7 @@ describe Agents::TwitterUserAgent do | ||
| 16 | 16 | } | 
| 17 | 17 |  | 
| 18 | 18 | @checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => @opts) | 
| 19 | + @checker.service = services(:generic) | |
| 19 | 20 | @checker.user = users(:bob) | 
| 20 | 21 | @checker.save! | 
| 21 | 22 | end | 
| @@ -31,6 +32,7 @@ describe Agents::TwitterUserAgent do | ||
| 31 | 32 |        opts = @opts.merge({ :starting_at => "Jan 01 00:00:01 +0000 2999", }) | 
| 32 | 33 |  | 
| 33 | 34 | checker = Agents::TwitterUserAgent.new(:name => "tectonic", :options => opts) | 
| 35 | + checker.service = services(:generic) | |
| 34 | 36 | checker.user = users(:bob) | 
| 35 | 37 | checker.save! | 
| 36 | 38 |  | 
| @@ -453,16 +453,32 @@ fire: hot | ||
| 453 | 453 | end | 
| 454 | 454 |  | 
| 455 | 455 | describe "#receive" do | 
| 456 | - it "should scrape from the url element in incoming event payload" do | |
| 456 | + before do | |
| 457 | 457 | @event = Event.new | 
| 458 | 458 | @event.agent = agents(:bob_rain_notifier_agent) | 
| 459 | 459 |          @event.payload = { 'url' => "http://xkcd.com" } | 
| 460 | + end | |
| 460 | 461 |  | 
| 462 | + it "should scrape from the url element in incoming event payload" do | |
| 461 | 463 |          lambda { | 
| 462 | 464 | @checker.options = @valid_options | 
| 463 | 465 | @checker.receive([@event]) | 
| 464 | 466 |          }.should change { Event.count }.by(1) | 
| 465 | 467 | end | 
| 468 | + | |
| 469 | + it "should interpolate values from incoming event payload" do | |
| 470 | + @event.payload['title'] = 'XKCD' | |
| 471 | + | |
| 472 | +        lambda { | |
| 473 | +          @valid_options['extract']['site_title'] = { | |
| 474 | +            'css' => "#comic img", 'value' => "'{{title}}'" | |
| 475 | + } | |
| 476 | + @checker.options = @valid_options | |
| 477 | + @checker.receive([@event]) | |
| 478 | +        }.should change { Event.count }.by(1) | |
| 479 | + | |
| 480 | + Event.last.payload['site_title'].should == 'XKCD' | |
| 481 | + end | |
| 466 | 482 | end | 
| 467 | 483 | end | 
| 468 | 484 |  | 
| @@ -0,0 +1,29 @@ | ||
| 1 | +require 'spec_helper' | |
| 2 | + | |
| 3 | +module Agents | |
| 4 | + class OauthableTestAgent < Agent | |
| 5 | + include Oauthable | |
| 6 | + end | |
| 7 | +end | |
| 8 | + | |
| 9 | +shared_examples_for Oauthable do | |
| 10 | + before(:each) do | |
| 11 | + @agent = described_class.new(:name => "somename") | |
| 12 | + @agent.user = users(:jane) | |
| 13 | + end | |
| 14 | + | |
| 15 | + it "should be oauthable" do | |
| 16 | + @agent.oauthable?.should == true | |
| 17 | + end | |
| 18 | + | |
| 19 | + describe "valid_services_for" do | |
| 20 | + it "should return all available services without specifying valid_oauth_providers" do | |
| 21 | + @agent = Agents::OauthableTestAgent.new | |
| 22 | + @agent.valid_services_for(users(:bob)).collect(&:id).sort.should == [services(:generic), services(:global)].collect(&:id).sort | |
| 23 | + end | |
| 24 | + | |
| 25 | + it "should filter the services based on the agent defaults" do | |
| 26 | + @agent.valid_services_for(users(:bob)).to_a.should == Service.where(provider: @agent.valid_oauth_providers) | |
| 27 | + end | |
| 28 | + end | |
| 29 | +end | 
| @@ -102,6 +102,15 @@ describe EventDrop do | ||
| 102 | 102 |      interpolate(t, @event).should eq('some title: http://some.site.example.org/') | 
| 103 | 103 | end | 
| 104 | 104 |  | 
| 105 | + it 'should use created_at from the payload if it exists' do | |
| 106 | + created_at = @event.created_at - 86400 | |
| 107 | + # Avoid timezone issue by using %s | |
| 108 | +    @event.payload['created_at'] = created_at.strftime("%s") | |
| 109 | + @event.save! | |
| 110 | +    t = '{{created_at | date:"%s" }}' | |
| 111 | +    interpolate(t, @event).should eq(created_at.strftime("%s")) | |
| 112 | + end | |
| 113 | + | |
| 105 | 114 | it 'should be iteratable' do | 
| 106 | 115 | # to_liquid returns self | 
| 107 | 116 |      t = "{% for pair in to_liquid %}{{pair | join:':' }}\n{% endfor %}" | 
| @@ -3,6 +3,8 @@ require 'spec_helper' | ||
| 3 | 3 | describe ScenarioImport do | 
| 4 | 4 |    let(:user) { users(:bob) } | 
| 5 | 5 |    let(:guid) { "somescenarioguid" } | 
| 6 | +  let(:tag_fg_color) { "#ffffff" } | |
| 7 | +  let(:tag_bg_color) { "#000000" } | |
| 6 | 8 |    let(:description) { "This is a cool Huginn Scenario that does something useful!" } | 
| 7 | 9 |    let(:name) { "A useful Scenario" } | 
| 8 | 10 |    let(:source_url) { "http://example.com/scenarios/2/export.json" } | 
| @@ -45,11 +47,25 @@ describe ScenarioImport do | ||
| 45 | 47 | :options => trigger_agent_options | 
| 46 | 48 | } | 
| 47 | 49 | end | 
| 50 | + let(:valid_parsed_basecamp_agent_data) do | |
| 51 | +    { | |
| 52 | + :type => "Agents::BasecampAgent", | |
| 53 | + :name => "Basecamp test", | |
| 54 | + :schedule => "every_2m", | |
| 55 | + :keep_events_for => 0, | |
| 56 | + :propagate_immediately => true, | |
| 57 | + :disabled => false, | |
| 58 | + :guid => "a-basecamp-agent", | |
| 59 | +      :options => {project_id: 12345} | |
| 60 | + } | |
| 61 | + end | |
| 48 | 62 | let(:valid_parsed_data) do | 
| 49 | -    {  | |
| 63 | +    { | |
| 50 | 64 | :name => name, | 
| 51 | 65 | :description => description, | 
| 52 | 66 | :guid => guid, | 
| 67 | + :tag_fg_color => tag_fg_color, | |
| 68 | + :tag_bg_color => tag_bg_color, | |
| 53 | 69 | :source_url => source_url, | 
| 54 | 70 | :exported_at => 2.days.ago.utc.iso8601, | 
| 55 | 71 | :agents => [ | 
| @@ -142,7 +158,7 @@ describe ScenarioImport do | ||
| 142 | 158 | end | 
| 143 | 159 | end | 
| 144 | 160 | end | 
| 145 | - | |
| 161 | + | |
| 146 | 162 | describe "#dangerous?" do | 
| 147 | 163 | it "returns false on most Agents" do | 
| 148 | 164 | ScenarioImport.new(:data => valid_data).should_not be_dangerous | 
| @@ -171,6 +187,8 @@ describe ScenarioImport do | ||
| 171 | 187 | scenario_import.scenario.name.should == name | 
| 172 | 188 | scenario_import.scenario.description.should == description | 
| 173 | 189 | scenario_import.scenario.guid.should == guid | 
| 190 | + scenario_import.scenario.tag_fg_color.should == tag_fg_color | |
| 191 | + scenario_import.scenario.tag_bg_color.should == tag_bg_color | |
| 174 | 192 | scenario_import.scenario.source_url.should == source_url | 
| 175 | 193 | scenario_import.scenario.public.should be_falsey | 
| 176 | 194 | end | 
| @@ -269,6 +287,8 @@ describe ScenarioImport do | ||
| 269 | 287 |  | 
| 270 | 288 | existing_scenario.reload | 
| 271 | 289 | existing_scenario.guid.should == guid | 
| 290 | + existing_scenario.tag_fg_color.should == tag_fg_color | |
| 291 | + existing_scenario.tag_bg_color.should == tag_bg_color | |
| 272 | 292 | existing_scenario.description.should == description | 
| 273 | 293 | existing_scenario.name.should == name | 
| 274 | 294 | existing_scenario.source_url.should == source_url | 
| @@ -407,5 +427,48 @@ describe ScenarioImport do | ||
| 407 | 427 | end | 
| 408 | 428 | end | 
| 409 | 429 | end | 
| 430 | + | |
| 431 | + context "agents which require a service" do | |
| 432 | + let(:valid_parsed_services) do | |
| 433 | + data = valid_parsed_data | |
| 434 | + data[:agents] = [valid_parsed_basecamp_agent_data, | |
| 435 | + valid_parsed_trigger_agent_data] | |
| 436 | + data | |
| 437 | + end | |
| 438 | + | |
| 439 | +      let(:valid_parsed_services_data) { valid_parsed_services.to_json } | |
| 440 | + | |
| 441 | +      let(:services_scenario_import) { | |
| 442 | + _import = ScenarioImport.new(:data => valid_parsed_services_data) | |
| 443 | + _import.set_user users(:bob) | |
| 444 | + _import | |
| 445 | + } | |
| 446 | + | |
| 447 | + describe "#generate_diff" do | |
| 448 | + it "should check if the agent requires a service" do | |
| 449 | + agent_diffs = services_scenario_import.agent_diffs | |
| 450 | + basecamp_agent_diff = agent_diffs[0] | |
| 451 | + basecamp_agent_diff.requires_service?.should == true | |
| 452 | + end | |
| 453 | + | |
| 454 | + it "should add an error when no service is selected" do | |
| 455 | + services_scenario_import.import.should == false | |
| 456 | + services_scenario_import.errors[:base].length.should == 1 | |
| 457 | + end | |
| 458 | + end | |
| 459 | + | |
| 460 | + describe "#import" do | |
| 461 | + it "should import" do | |
| 462 | +          services_scenario_import.merges = { | |
| 463 | +            "0" => { | |
| 464 | + "service_id" => "0", | |
| 465 | + } | |
| 466 | + } | |
| 467 | +          lambda { | |
| 468 | + services_scenario_import.import.should == true | |
| 469 | +          }.should change { users(:bob).agents.count }.by(2) | |
| 470 | + end | |
| 471 | + end | |
| 472 | + end | |
| 410 | 473 | end | 
| 411 | -end | |
| 474 | +end | 
| @@ -20,6 +20,30 @@ describe Scenario do | ||
| 20 | 20 | new_instance.should_not be_valid | 
| 21 | 21 | end | 
| 22 | 22 |  | 
| 23 | + it "validates tag_fg_color is hex color" do | |
| 24 | + new_instance.tag_fg_color = '#N07H3X' | |
| 25 | + new_instance.should_not be_valid | |
| 26 | + new_instance.tag_fg_color = '#BADA55' | |
| 27 | + new_instance.should be_valid | |
| 28 | + end | |
| 29 | + | |
| 30 | + it "allows nil tag_fg_color" do | |
| 31 | + new_instance.tag_fg_color = nil | |
| 32 | + new_instance.should be_valid | |
| 33 | + end | |
| 34 | + | |
| 35 | + it "validates tag_bg_color is hex color" do | |
| 36 | + new_instance.tag_bg_color = '#N07H3X' | |
| 37 | + new_instance.should_not be_valid | |
| 38 | + new_instance.tag_bg_color = '#BADA55' | |
| 39 | + new_instance.should be_valid | |
| 40 | + end | |
| 41 | + | |
| 42 | + it "allows nil tag_bg_color" do | |
| 43 | + new_instance.tag_bg_color = nil | |
| 44 | + new_instance.should be_valid | |
| 45 | + end | |
| 46 | + | |
| 23 | 47 | it "only allows Agents owned by user" do | 
| 24 | 48 | new_instance.agent_ids = [agents(:bob_website_agent).id] | 
| 25 | 49 | new_instance.should be_valid | 
| @@ -0,0 +1,129 @@ | ||
| 1 | +require 'spec_helper' | |
| 2 | + | |
| 3 | +describe Service do | |
| 4 | + before(:each) do | |
| 5 | + @user = users(:bob) | |
| 6 | + end | |
| 7 | + | |
| 8 | + describe "#toggle_availability!" do | |
| 9 | + it "should toggle the global flag" do | |
| 10 | + @service = services(:generic) | |
| 11 | + @service.global.should == false | |
| 12 | + @service.toggle_availability! | |
| 13 | + @service.global.should == true | |
| 14 | + @service.toggle_availability! | |
| 15 | + @service.global.should == false | |
| 16 | + end | |
| 17 | + | |
| 18 | + it "disconnects agents and disables them if the previously global service is made private again", focus: true do | |
| 19 | + agent = agents(:bob_basecamp_agent) | |
| 20 | + jane_agent = agents(:jane_basecamp_agent) | |
| 21 | + | |
| 22 | + service = agent.service | |
| 23 | + service.toggle_availability! | |
| 24 | + service.agents.length.should == 2 | |
| 25 | + | |
| 26 | + service.toggle_availability! | |
| 27 | + jane_agent.reload | |
| 28 | + jane_agent.service_id.should be_nil | |
| 29 | + jane_agent.disabled.should be true | |
| 30 | + | |
| 31 | + service.reload | |
| 32 | + service.agents.length.should == 1 | |
| 33 | + end | |
| 34 | + end | |
| 35 | + | |
| 36 | + it "disables all agents before beeing destroyed" do | |
| 37 | + agent = agents(:bob_basecamp_agent) | |
| 38 | + service = agent.service | |
| 39 | + service.destroy | |
| 40 | + agent.reload | |
| 41 | + agent.service_id.should be_nil | |
| 42 | + agent.disabled.should be true | |
| 43 | + end | |
| 44 | + | |
| 45 | + describe "preparing for a request" do | |
| 46 | + before(:each) do | |
| 47 | + @service = services(:generic) | |
| 48 | + end | |
| 49 | + | |
| 50 | + it "should not update the token if the token never expires" do | |
| 51 | + @service.expires_at = nil | |
| 52 | + @service.prepare_request.should == nil | |
| 53 | + end | |
| 54 | + | |
| 55 | + it "should not update the token if the token is still valid" do | |
| 56 | + @service.expires_at = Time.now + 1.hour | |
| 57 | + @service.prepare_request.should == nil | |
| 58 | + end | |
| 59 | + | |
| 60 | + it "should call refresh_token! if the token expired" do | |
| 61 | +      stub(@service).refresh_token! { @service } | |
| 62 | + @service.expires_at = Time.now - 1.hour | |
| 63 | + @service.prepare_request.should == @service | |
| 64 | + end | |
| 65 | + end | |
| 66 | + | |
| 67 | + describe "updating the access token" do | |
| 68 | + before(:each) do | |
| 69 | + @service = services(:generic) | |
| 70 | + end | |
| 71 | + | |
| 72 | + it "should return the correct endpoint" do | |
| 73 | + @service.provider = '37signals' | |
| 74 | + @service.send(:endpoint).to_s.should == "https://launchpad.37signals.com/authorization/token" | |
| 75 | + end | |
| 76 | + | |
| 77 | + it "should update the token" do | |
| 78 | + stub_request(:post, "https://launchpad.37signals.com/authorization/token?client_id=TESTKEY&client_secret=TESTSECRET&refresh_token=refreshtokentest&type=refresh"). | |
| 79 | +        to_return(:status => 200, :body => '{"expires_in":1209600,"access_token": "NEWTOKEN"}', :headers => {}) | |
| 80 | + @service.provider = '37signals' | |
| 81 | + @service.refresh_token = 'refreshtokentest' | |
| 82 | + @service.refresh_token! | |
| 83 | + @service.token.should == 'NEWTOKEN' | |
| 84 | + end | |
| 85 | + end | |
| 86 | + | |
| 87 | + describe "creating services via omniauth" do | |
| 88 | + it "should work with twitter services" do | |
| 89 | +      twitter = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/twitter.json'))) | |
| 90 | +      expect { | |
| 91 | + service = @user.services.initialize_or_update_via_omniauth(twitter) | |
| 92 | + service.save! | |
| 93 | +      }.to change { @user.services.count }.by(1) | |
| 94 | + service = @user.services.first | |
| 95 | + service.name.should == 'johnqpublic' | |
| 96 | + service.uid.should == '123456' | |
| 97 | + service.provider.should == 'twitter' | |
| 98 | + service.token.should == 'a1b2c3d4...' | |
| 99 | + service.secret.should == 'abcdef1234' | |
| 100 | + end | |
| 101 | + it "should work with 37signals services" do | |
| 102 | +      signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/37signals.json'))) | |
| 103 | +      expect { | |
| 104 | + service = @user.services.initialize_or_update_via_omniauth(signals) | |
| 105 | + service.save! | |
| 106 | +      }.to change { @user.services.count }.by(1) | |
| 107 | + service = @user.services.first | |
| 108 | + service.provider.should == '37signals' | |
| 109 | + service.name.should == 'Dominik Sander' | |
| 110 | + service.token.should == 'abcde' | |
| 111 | + service.uid.should == '12345' | |
| 112 | + service.refresh_token.should == 'fghrefresh' | |
| 113 | + service.options[:user_id].should == 12345 | |
| 114 | + service.expires_at = Time.at(1401554352) | |
| 115 | + end | |
| 116 | + it "should work with github services" do | |
| 117 | +      signals = JSON.parse(File.read(Rails.root.join('spec/data_fixtures/services/github.json'))) | |
| 118 | +      expect { | |
| 119 | + service = @user.services.initialize_or_update_via_omniauth(signals) | |
| 120 | + service.save! | |
| 121 | +      }.to change { @user.services.count }.by(1) | |
| 122 | + service = @user.services.first | |
| 123 | + service.provider.should == 'github' | |
| 124 | + service.name.should == 'dsander' | |
| 125 | + service.uid.should == '12345' | |
| 126 | + service.token.should == 'agithubtoken' | |
| 127 | + end | |
| 128 | + end | |
| 129 | +end | 
| @@ -1,4 +1,3 @@ | ||
| 1 | -# This file is copied to spec/ when you run 'rails generate rspec:install' | |
| 2 | 1 | ENV["RAILS_ENV"] ||= 'test' | 
| 3 | 2 |  | 
| 4 | 3 | if ENV['COVERAGE'] | 
| @@ -9,6 +8,10 @@ else | ||
| 9 | 8 |    Coveralls.wear!('rails') | 
| 10 | 9 | end | 
| 11 | 10 |  | 
| 11 | +# Required ENV variables that are normally set in .env are setup here for the test environment. | |
| 12 | +require 'dotenv' | |
| 13 | +Dotenv.overload File.join(File.dirname(__FILE__), "env.test") | |
| 14 | + | |
| 12 | 15 |  require File.expand_path("../../config/environment", __FILE__) | 
| 13 | 16 | require 'rspec/rails' | 
| 14 | 17 | require 'rspec/autorun' | 
| @@ -19,7 +22,9 @@ WebMock.disable_net_connect! | ||
| 19 | 22 |  | 
| 20 | 23 | # Requires supporting ruby files with custom matchers and macros, etc, | 
| 21 | 24 | # in spec/support/ and its subdirectories. | 
| 22 | -Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} | |
| 25 | +Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } | |
| 26 | + | |
| 27 | +ActiveRecord::Migration.maintain_test_schema! | |
| 23 | 28 |  | 
| 24 | 29 | RSpec.configure do |config| | 
| 25 | 30 | config.mock_with :rr |