diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a1307da6f..4a55f7a504 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - mysql2 - postgresql ruby: - - "2.7" + - "3.2" env: DATABASE_ADAPTER: ${{ matrix.database_adapter }} DATABASE_HOST: "127.0.0.1" @@ -68,6 +68,9 @@ jobs: - name: Run tests run: bundle exec rake + - name: Coveralls + uses: coverallsapp/github-action@v1 + ghcr-build-docker-images: name: ghcr-docker-build-${{ matrix.docker_image }} needs: run-tests diff --git a/Gemfile b/Gemfile index e057571b3e..56675a7ed5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -ruby '>=2.7.0' +ruby '>=3.2.2' # Ensure github repositories are fetched using HTTPS git_source(:github) do |repo_name| @@ -29,18 +29,18 @@ end # Optional libraries. To conserve RAM, comment out any that you don't need, # then run `bundle` and commit the updated Gemfile and Gemfile.lock. -gem 'twilio-ruby', '~> 5.62.0' # TwilioAgent -gem 'ruby-growl', '~> 4.1.0' # GrowlAgent -gem 'net-ftp-list', '~> 3.2.8' # FtpsiteAgent -gem 'forecast_io', '~> 2.0.0' # WeatherAgent -gem 'rturk', '~> 2.12.1' # HumanTaskAgent gem 'erector', github: 'dsander/erector', branch: 'rails6' +gem 'forecast_io', '~> 2.0.0' # WeatherAgent gem 'hipchat', '~> 1.2.0' # HipchatAgent +gem 'hypdf', '~> 1.0.10' # PDFInfoAgent gem 'mini_racer' # JavaScriptAgent -gem 'xmpp4r', '~> 0.5.6' # JabberAgent gem 'mqtt' # MQTTAgent +gem 'net-ftp' +gem 'net-ftp-list' # FtpsiteAgent +gem 'rturk', '~> 2.12.1' # HumanTaskAgent gem 'slack-notifier', '~> 1.0.0' # SlackAgent -gem 'hypdf', '~> 1.0.10' # PDFInfoAgent +gem 'twilio-ruby', '~> 5.62.0' # TwilioAgent +gem 'xmpp4r', '~> 0.5.6' # JabberAgent # Weibo Agents # FIXME needs to loosen omniauth dependency, add rest-client @@ -51,14 +51,15 @@ gem 'google-api-client', '~> 0.13' gem 'google-cloud-translate', '~> 2.0', require: 'google/cloud/translate' # Twitter Agents +gem 'omniauth-twitter' gem 'twitter', github: 'sferik/twitter' # Must to be loaded before cantino-twitter-stream. gem 'twitter-stream', github: 'cantino/twitter-stream', branch: 'huginn' -gem 'omniauth-twitter' # Tumblr Agents # until merge of https://github.com/tumblr/tumblr_client/pull/61 -gem 'tumblr_client', github: 'albertsun/tumblr_client', branch: 'master', ref: 'e046fe6e39291c173add0a49081630c7b60a36c7' gem 'omniauth-tumblr' +gem 'tumblr_client', github: 'albertsun/tumblr_client', branch: 'master', + ref: 'e046fe6e39291c173add0a49081630c7b60a36c7' # Dropbox Agents gem 'dropbox-api', github: 'dsander/dropbox-api', ref: '86cb7b5a1254dc5b054de7263835713c4c1018c7' @@ -68,8 +69,8 @@ gem 'omniauth-dropbox-oauth2', github: 'huginn/omniauth-dropbox-oauth2' gem 'haversine' # EvernoteAgent -gem 'omniauth-evernote' gem 'evernote_oauth' +gem 'omniauth-evernote' # LocalFileAgent (watch functionality) gem 'listen', '~> 3.0.5', require: false @@ -78,17 +79,18 @@ gem 'listen', '~> 3.0.5', require: false gem 'aws-sdk-s3', '~> 1' # ImapFolderAgent -gem 'omniauth-google-oauth2', '>= 0.8.0' gem 'gmail_xoauth' # support for Gmail using OAuth +gem 'omniauth-google-oauth2', '>= 0.8.0' # Bundler <1.5 does not recognize :x64_mingw as a valid platform name. # Unfortunately, it can't self-update because it errors when encountering :x64_mingw. unless Gem::Version.new(Bundler::VERSION) >= Gem::Version.new('1.5.0') - STDERR.puts "Bundler >=1.5.0 is required. Please upgrade bundler with 'gem install bundler'" + warn "Bundler >=1.5.0 is required. Please upgrade bundler with 'gem install bundler'" exit 1 end -gem 'ace-rails-ap', '~> 2.0.1' +gem 'ace-rails-ap' +gem 'bootsnap', require: false gem 'bootstrap-kaminari-views', '~> 0.0.3' gem 'bundler', '>= 1.5.0' gem 'coffee-rails', '~> 5' @@ -97,17 +99,18 @@ gem 'delayed_job' gem 'delayed_job_active_record' gem 'devise', '~> 4.8' gem 'em-http-request', '~> 1.1.2' +gem 'execjs' gem 'faraday', '~> 0.9' gem 'faraday_middleware', '~> 0.12.2' gem 'feedjira', '~> 3.1' gem 'font-awesome-sass', '~> 4.7.0' -gem 'foreman', '~> 0.63.0' +gem 'foreman', '~> 0.87.2' gem 'geokit', '~> 1.13' gem 'geokit-rails', '~> 2.3' -gem 'httparty', '~> 0.13' gem 'httmultiparty', '~> 0.3.16' -gem 'jquery-rails', '~> 4.2.1' +gem 'httparty', '~> 0.13' gem 'huginn_agent' +gem 'jquery-rails', '~> 4.2.1' gem 'json', '~> 2.3' gem 'jsonpath', '~> 1.1' gem 'kaminari', '~> 1.2' @@ -120,16 +123,15 @@ gem 'multi_xml' gem "nokogiri", ">= 1.10.8" gem 'omniauth' gem 'rails', '~> 6.1.7' -gem 'sprockets', '~> 3.7.2' gem 'rails-html-sanitizer', '~> 1.2' gem 'rufus-scheduler', '~> 3.4', require: false gem 'sass-rails', '>= 6.0' -gem 'select2-rails', '~> 3.5.4' +gem 'select2-rails' gem 'spectrum-rails' -gem 'execjs' +gem 'sprockets' +gem 'terser' gem 'typhoeus', '~> 1.3.1' gem 'uglifier', '~> 2.7.2' -gem 'bootsnap', require: false group :development do gem 'better_errors' @@ -137,38 +139,41 @@ group :development do gem 'guard' gem 'guard-livereload' gem 'guard-rspec' - gem 'rack-livereload' gem 'letter_opener_web', '~> 1.4' # 2.0+ requires Ruby 2.7 + gem 'rack-livereload' gem 'web-console', '>= 3.3.0' gem 'capistrano' - gem 'capistrano-rails' gem 'capistrano-bundler' + gem 'capistrano-rails' + + gem 'rubocop', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rspec', require: false if_true(ENV['SPRING']) do - gem 'spring-commands-rspec' gem 'spring' + gem 'spring-commands-rspec' gem 'spring-watcher-listen' end group :test do - gem 'coveralls', require: false - gem 'capybara', '~> 2.18' - gem 'capybara-screenshot' - gem 'capybara-select-2', github: 'Hirurg103/capybara_select2', ref: 'fbf22fb74dec10fa0edcd26da7c5184ba8fa2c76', require: false - gem 'poltergeist' - gem 'pry-rails' - gem 'pry-byebug' + gem 'capybara' + gem 'capybara-select-2', github: 'Hirurg103/capybara_select2', require: false + gem 'puma' + gem 'rails-controller-testing' gem 'rr', require: false gem 'rspec' - gem 'rspec-mocks' - gem 'rspec-rails' gem 'rspec-collection_matchers' gem 'rspec-html-matchers' - gem 'rails-controller-testing' + gem 'rspec-mocks' + gem 'rspec-rails' + gem 'selenium-webdriver' gem 'shoulda-matchers' + gem 'simplecov', require: false + gem 'simplecov-lcov', '~> 0.8.0', require: false gem 'vcr' - gem 'webmock', '~> 3.5.1' + gem 'webmock' end end @@ -178,18 +183,17 @@ end # Platform requirements. require 'rbconfig' -gem 'ffi', '>= 1.9.4' # required by typhoeus; 1.9.4 has fixes for *BSD. +gem 'ffi', '>= 1.9.4' # required by typhoeus; 1.9.4 has fixes for *BSD. gem 'tzinfo', '>= 1.2.0' # required by rails; 1.2.0 has support for *BSD and Solaris. # Windows does not have zoneinfo files, so bundle the tzinfo-data gem. gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw] # BSD systems require rb-kqueue for "listen" to avoid polling for changes. gem 'rb-kqueue', '>= 0.2', require: /bsd|dragonfly/i === RbConfig::CONFIG['target_os'] - on_heroku = ENV['ON_HEROKU'] || - ENV['HEROKU_POSTGRESQL_ROSE_URL'] || - ENV['HEROKU_POSTGRESQL_GOLD_URL'] || - File.read(File.join(File.dirname(__FILE__), 'Procfile')) =~ /intended for Heroku/ + ENV['HEROKU_POSTGRESQL_ROSE_URL'] || + ENV['HEROKU_POSTGRESQL_GOLD_URL'] || + File.read(File.join(File.dirname(__FILE__), 'Procfile')) =~ /intended for Heroku/ ENV['DATABASE_ADAPTER'] ||= if on_heroku diff --git a/Gemfile.lock b/Gemfile.lock index ca81053d41..56692e6522 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,8 @@ GIT remote: https://github.com/Hirurg103/capybara_select2.git - revision: fbf22fb74dec10fa0edcd26da7c5184ba8fa2c76 - ref: fbf22fb74dec10fa0edcd26da7c5184ba8fa2c76 + revision: 48e2c458c2827cf2af2b0a287b30468fa8d761bf specs: - capybara-select-2 (0.3.2) + capybara-select-2 (0.5.1) GIT remote: https://github.com/albertsun/tumblr_client.git @@ -67,15 +66,15 @@ GIT GIT remote: https://github.com/sferik/twitter.git - revision: d11707edf4abd13f7ada0eef57fc1eaa1062d75b + revision: 5a49cb6b6c84ccc8f2f980c42d3e9f852033735a specs: - twitter (5.15.0) + twitter (8.0.0) addressable (~> 2.3) - buftok (~> 0.2.0) + buftok (~> 0.3.0) equalizer (~> 0.0.11) - http (~> 2.0) - http-form_data (~> 1.0) - http_parser.rb (~> 0.6.0) + http (~> 5.1) + http-form_data (~> 2.3) + llhttp-ffi (~> 0.4.0) memoizable (~> 0.4.0) multipart-post (~> 2.0) naught (~> 1.0) @@ -91,7 +90,7 @@ PATH GEM remote: https://rubygems.org/ specs: - ace-rails-ap (2.0.1) + ace-rails-ap (4.5) actioncable (6.1.7.2) actionpack (= 6.1.7.2) activesupport (= 6.1.7.2) @@ -151,10 +150,11 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.1) + addressable (2.8.4) public_suffix (>= 2.0.2, < 6.0) airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) + ast (2.4.2) aws-eventstream (1.2.0) aws-partitions (1.547.0) aws-sdk-core (3.125.2) @@ -184,9 +184,8 @@ GEM bootstrap-kaminari-views (0.0.5) kaminari (>= 0.13) rails (>= 3.1) - buftok (0.2.0) + buftok (0.3.0) builder (3.2.4) - byebug (11.1.3) capistrano (3.16.0) airbrussh (>= 1.0.0) i18n @@ -197,17 +196,15 @@ GEM capistrano-rails (1.6.1) capistrano (~> 3.1) capistrano-bundler (>= 1.1, < 3) - capybara (2.18.0) + capybara (3.39.0) addressable + matrix mini_mime (>= 0.1.3) - nokogiri (>= 1.3.3) - rack (>= 1.0.0) - rack-test (>= 0.5.4) - xpath (>= 2.0, < 4.0) - capybara-screenshot (1.0.17) - capybara (>= 1.0, < 3) - launchy - cliver (0.3.2) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) coderay (1.1.3) coffee-rails (5.0.0) coffee-script (>= 2.2.0) @@ -216,14 +213,8 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - concurrent-ruby (1.2.0) + concurrent-ruby (1.2.2) cookiejar (0.3.3) - coveralls (0.8.23) - json (>= 1.8, < 3) - simplecov (~> 0.16.1) - term-ansicolor (~> 1.3) - thor (>= 0.19.4, < 2.0) - tins (~> 1.6) crack (0.4.5) rexml crass (1.0.6) @@ -244,7 +235,7 @@ GEM warden (~> 1.2.3) diff-lcs (1.5.0) docile (1.4.0) - domain_name (0.5.20170404) + domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) em-http-request (1.1.7) addressable (>= 2.3.4) @@ -269,7 +260,7 @@ GEM evernote-thrift oauth (>= 0.4.1) execjs (2.8.1) - faraday (0.17.4) + faraday (0.17.6) multipart-post (>= 1.2, < 3) faraday_middleware (0.12.2) faraday (>= 0.7.4, < 1.0) @@ -277,15 +268,16 @@ GEM loofah (>= 2.3.1) sax-machine (>= 1.0) ffi (1.15.5) + ffi-compiler (1.0.1) + ffi (>= 1.0.0) + rake font-awesome-sass (4.7.0) sass (>= 3.2) forecast_io (2.0.1) faraday hashie multi_json - foreman (0.63.0) - dotenv (>= 0.7) - thor (>= 0.13.6) + foreman (0.87.2) formatador (1.1.0) fugit (1.5.2) et-orbi (~> 1.1, >= 1.1.8) @@ -393,14 +385,14 @@ GEM httparty (>= 0.7.3) mimemagic multipart-post - http (2.1.0) - addressable (~> 2.3) + http (5.1.1) + addressable (~> 2.8) http-cookie (~> 1.0) - http-form_data (~> 1.0.1) - http_parser.rb (~> 0.6.0) - http-cookie (1.0.3) + http-form_data (~> 2.2) + llhttp-ffi (~> 0.4.0) + http-cookie (1.0.5) domain_name (~> 0.5) - http-form_data (1.0.1) + http-form_data (2.3.0) http_parser.rb (0.6.0) httparty (0.14.0) multi_xml (>= 0.5.2) @@ -417,7 +409,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.1) + json (2.6.3) jsonpath (1.1.0) multi_json jwt (2.3.0) @@ -449,22 +441,24 @@ GEM libv8-node (16.10.0.0-arm64-darwin) libv8-node (16.10.0.0-x86_64-darwin) libv8-node (16.10.0.0-x86_64-linux) - liquid (5.3.0) + liquid (5.4.0) listen (3.0.8) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) + llhttp-ffi (0.4.0) + ffi-compiler (~> 1.0) + rake (~> 13.0) loofah (2.20.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lumberjack (1.2.8) - macaddr (1.7.1) - systemu (~> 2.6.2) mail (2.8.1) mini_mime (>= 0.1.1) net-imap net-pop net-smtp marcel (1.0.2) + matrix (0.4.2) memoist (0.16.2) memoizable (0.4.2) thread_safe (~> 0.3, >= 0.3.1) @@ -480,15 +474,18 @@ GEM mini_portile2 (2.8.1) mini_racer (0.6.3) libv8-node (~> 16.10.0.0) - minitest (5.17.0) + minitest (5.18.0) mqtt (0.3.1) msgpack (1.4.2) multi_json (1.15.0) multi_xml (0.6.0) - multipart-post (2.1.1) + multipart-post (2.3.0) mysql2 (0.5.4) naught (1.1.0) nenv (0.3.0) + net-ftp (0.2.0) + net-protocol + time net-ftp-list (3.2.8) net-imap (0.3.4) date @@ -551,28 +548,23 @@ GEM rack orm_adapter (0.5.0) os (1.1.4) + parallel (1.22.1) + parser (3.2.2.0) + ast (~> 2.4.1) pg (1.4.4) - poltergeist (1.8.1) - capybara (~> 2.1) - cliver (~> 0.3.1) - multi_json (~> 1.0) - websocket-driver (>= 0.2.0) polyglot (0.3.5) pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.9.0) - byebug (~> 11.0) - pry (~> 0.13.0) - pry-rails (0.3.9) - pry (>= 0.10.4) - public_suffix (5.0.0) + public_suffix (5.0.1) + puma (6.2.1) + nio4r (~> 2.0) raabro (1.4.0) racc (1.6.2) rack (2.2.6.4) rack-livereload (0.3.17) rack - rack-test (2.0.2) + rack-test (2.1.0) rack (>= 1.3) rails (6.1.7.2) actioncable (= 6.1.7.2) @@ -589,10 +581,10 @@ GEM bundler (>= 1.15.0) railties (= 6.1.7.2) sprockets-rails (>= 2.0.0) - rails-controller-testing (1.0.4) - actionpack (>= 5.0.1.x) - actionview (>= 5.0.1.x) - activesupport (>= 5.0.1.x) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -604,6 +596,7 @@ GEM method_source rake (>= 12.2) thor (~> 1.0) + rainbow (3.1.1) raindrops (0.20.0) rake (13.0.6) rb-fsevent (0.11.2) @@ -611,6 +604,7 @@ GEM ffi (~> 1.0) rb-kqueue (0.2.4) ffi (>= 0.5.0) + regexp_parser (2.7.0) representable (3.1.1) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -625,7 +619,7 @@ GEM retriable (3.1.2) rexml (3.2.5) rly (0.2.3) - rr (3.0.9) + rr (3.1.0) rspec (3.12.0) rspec-core (~> 3.12.0) rspec-expectations (~> 3.12.0) @@ -640,7 +634,7 @@ GEM rspec-html-matchers (0.10.0) nokogiri (~> 1) rspec (>= 3.0.0.a) - rspec-mocks (3.12.3) + rspec-mocks (3.12.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-rails (6.0.1) @@ -656,8 +650,28 @@ GEM erector nokogiri rest-client - ruby-growl (4.1) - uuid (~> 2.3, >= 2.3.5) + rubocop (1.50.1) + json (~> 2.3) + parallel (~> 1.10) + parser (>= 3.2.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.28.0) + parser (>= 3.2.1.0) + rubocop-capybara (2.17.1) + rubocop (~> 1.41) + rubocop-performance (1.17.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-rspec (2.18.1) + rubocop (~> 1.33) + rubocop-capybara (~> 2.17) + ruby-progressbar (1.13.0) + rubyzip (2.3.2) rufus-scheduler (3.8.1) fugit (~> 1.1, >= 1.1.6) sass (3.7.4) @@ -676,7 +690,11 @@ GEM sprockets-rails tilt sax-machine (1.3.2) - select2-rails (3.5.11) + select2-rails (4.0.13) + selenium-webdriver (4.8.6) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) shellany (0.0.1) shoulda-matchers (4.0.1) activesupport (>= 4.2.0) @@ -686,11 +704,13 @@ GEM jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simple_oauth (0.3.1) - simplecov (0.16.1) + simplecov (0.22.0) docile (~> 1.1) - json (>= 1.8, < 3) - simplecov-html (~> 0.10.0) - simplecov-html (0.10.2) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov-lcov (0.8.0) + simplecov_json_formatter (0.1.4) slack-notifier (1.0.0) spectrum-rails (1.3.4) railties (>= 3.1) @@ -700,9 +720,9 @@ GEM spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) - sprockets (3.7.2) + sprockets (4.2.0) concurrent-ruby (~> 1.0) - rack (> 1, < 3) + rack (>= 2.2.4, < 4) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) @@ -710,16 +730,14 @@ GEM sshkit (1.21.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - sync (0.5.0) - systemu (2.6.4) - term-ansicolor (1.7.1) - tins (~> 1.0) + terser (1.1.14) + execjs (>= 0.3.0, < 3) thor (1.2.1) thread_safe (0.3.6) tilt (2.0.10) + time (0.2.2) + date timeout (0.3.1) - tins (1.31.0) - sync trailblazer-option (0.1.2) treetop (1.6.10) polyglot (~> 0.3) @@ -737,13 +755,12 @@ GEM json (>= 1.8.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.4) + unf_ext (0.0.8.2) + unicode-display_width (2.4.2) unicorn (6.1.0) kgio (~> 2.6) raindrops (~> 0.7) - uuid (2.3.7) - macaddr (~> 1.0) - vcr (3.0.3) + vcr (6.1.0) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.0) @@ -751,16 +768,17 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webmock (3.5.1) - addressable (>= 2.3.6) + webmock (3.18.1) + addressable (>= 2.8.0) crack (>= 0.3.2) - hashdiff + hashdiff (>= 0.4.0, < 2.0.0) webrick (1.7.0) + websocket (1.2.9) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xmpp4r (0.5.6) - xpath (3.0.0) + xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.6.7) @@ -772,7 +790,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - ace-rails-ap (~> 2.0.1) + ace-rails-ap aws-sdk-s3 (~> 1) better_errors binding_of_caller @@ -782,11 +800,9 @@ DEPENDENCIES capistrano capistrano-bundler capistrano-rails - capybara (~> 2.18) - capybara-screenshot + capybara capybara-select-2! coffee-rails (~> 5) - coveralls daemons (~> 1.1.9) delayed_job delayed_job_active_record @@ -804,7 +820,7 @@ DEPENDENCIES ffi (>= 1.9.4) font-awesome-sass (~> 4.7.0) forecast_io (~> 2.0.0) - foreman (~> 0.63.0) + foreman (~> 0.87.2) geokit (~> 1.13) geokit-rails (~> 2.3) gmail_xoauth @@ -834,7 +850,8 @@ DEPENDENCIES mqtt multi_xml mysql2 (~> 0.5) - net-ftp-list (~> 3.2.8) + net-ftp + net-ftp-list nokogiri (>= 1.10.8) omniauth omniauth-dropbox-oauth2! @@ -843,9 +860,7 @@ DEPENDENCIES omniauth-tumblr omniauth-twitter pg (~> 1.1) - poltergeist - pry-byebug - pry-rails + puma rack-livereload rails (~> 6.1.7) rails-controller-testing @@ -858,17 +873,23 @@ DEPENDENCIES rspec-mocks rspec-rails rturk (~> 2.12.1) - ruby-growl (~> 4.1.0) + rubocop + rubocop-performance + rubocop-rspec rufus-scheduler (~> 3.4) sass-rails (>= 6.0) - select2-rails (~> 3.5.4) + select2-rails + selenium-webdriver shoulda-matchers + simplecov + simplecov-lcov (~> 0.8.0) slack-notifier (~> 1.0.0) spectrum-rails spring spring-commands-rspec spring-watcher-listen - sprockets (~> 3.7.2) + sprockets + terser tumblr_client! twilio-ruby (~> 5.62.0) twitter! @@ -880,12 +901,12 @@ DEPENDENCIES unicorn vcr web-console (>= 3.3.0) - webmock (~> 3.5.1) + webmock weibo_2! xmpp4r (~> 0.5.6) RUBY VERSION - ruby 2.7.6p219 + ruby 3.2.2p53 BUNDLED WITH 2.4.12 diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 0000000000..b16e53d6d5 --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,3 @@ +//= link_tree ../images +//= link_directory ../javascripts .js +//= link_directory ../stylesheets .css diff --git a/app/assets/javascripts/ace.js b/app/assets/javascripts/ace.js new file mode 100644 index 0000000000..7ca68dcfa3 --- /dev/null +++ b/app/assets/javascripts/ace.js @@ -0,0 +1 @@ +//= require ace-rails-ap diff --git a/app/assets/javascripts/ace.js.coffee b/app/assets/javascripts/ace.js.coffee deleted file mode 100644 index 1b9d6f4866..0000000000 --- a/app/assets/javascripts/ace.js.coffee +++ /dev/null @@ -1,8 +0,0 @@ -#= require ace/ace -#= require ace/mode-javascript.js -#= require ace/mode-markdown.js -#= require ace/mode-coffee.js -#= require ace/mode-sql.js -#= require ace/mode-json.js -#= require ace/mode-yaml.js -#= require ace/mode-text.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js new file mode 100644 index 0000000000..28e2e537ee --- /dev/null +++ b/app/assets/javascripts/application.js @@ -0,0 +1,13 @@ +//= require jquery +//= require rails-ujs +//= require typeahead.bundle +//= require bootstrap +//= require select2 +//= require json2 +//= require jquery.json-editor +//= require jquery.serializeObject +//= require latlon_and_geo +//= require spectrum +//= require_tree ./components +//= require_tree ./pages +//= require_self diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee deleted file mode 100644 index 564fe9a695..0000000000 --- a/app/assets/javascripts/application.js.coffee +++ /dev/null @@ -1,13 +0,0 @@ -#= require jquery -#= require rails-ujs -#= require typeahead.bundle -#= require bootstrap -#= require select2 -#= require json2 -#= require jquery.json-editor -#= require jquery.serializeObject -#= require latlon_and_geo -#= require spectrum -#= require_tree ./components -#= require_tree ./pages -#= require_self diff --git a/app/assets/javascripts/components/core.js b/app/assets/javascripts/components/core.js new file mode 100644 index 0000000000..7159077250 --- /dev/null +++ b/app/assets/javascripts/components/core.js @@ -0,0 +1,56 @@ +$(function () { + // Flash + if ($(".flash").length) { + setTimeout(() => $(".flash").slideUp(() => $(".flash").remove()), 5000); + } + + // Help popovers + $(".hover-help").popover({ trigger: "hover", html: true }); + + // Pressing '/' selects the search box. + $("body").on("keypress", function (e) { + if (e.keyCode === 47) { + // The '/' key + if (e.target.nodeName === "BODY") { + e.preventDefault(); + return $agentNavigate.focus(); + } + } + }); + + // Select2 Selects + $(".select2").select2({ width: "resolve" }); + + $(".select2-linked-tags").select2({ + width: "resolve", + templateSelection: ({ id, text, element }) => { + const a = document.createElement("a"); + a.href = `${element.closest("select").dataset.urlPrefix}/${id}/edit`; + a.onClick = "Utils.select2TagClickHandler(event, this)"; + a.appendChild(document.createTextNode(text)); + return a; + }, + }); + + // Helper for selecting text when clicked + $(".selectable-text").each(function () { + return $(this).click(function () { + const range = document.createRange(); + range.setStartBefore(this.firstChild); + range.setEndAfter(this.lastChild); + const sel = window.getSelection(); + sel.removeAllRanges(); + return sel.addRange(range); + }); + }); + + // Agent navbar dropdown + return $(".navbar .dropdown.dropdown-hover").hover( + function () { + return $(this).addClass("open"); + }, + function () { + return $(this).removeClass("open"); + } + ); +}); diff --git a/app/assets/javascripts/components/core.js.coffee b/app/assets/javascripts/components/core.js.coffee deleted file mode 100644 index aea4e1ef6b..0000000000 --- a/app/assets/javascripts/components/core.js.coffee +++ /dev/null @@ -1,36 +0,0 @@ -$ -> - # Flash - if $(".flash").length - setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000) - - # Help popovers - $('.hover-help').popover(trigger: 'hover', html: true) - - # Pressing '/' selects the search box. - $("body").on "keypress", (e) -> - if e.keyCode == 47 # The '/' key - if e.target.nodeName == "BODY" - e.preventDefault() - $agentNavigate.focus() - - # Select2 Selects - $(".select2").select2(width: 'resolve') - - $(".select2-linked-tags").select2( - width: 'resolve', - formatSelection: (obj) -> - "#{Utils.escape(obj.text)}" - ) - - # Helper for selecting text when clicked - $('.selectable-text').each -> - $(this).click -> - range = document.createRange() - range.setStartBefore(this.firstChild) - range.setEndAfter(this.lastChild) - sel = window.getSelection() - sel.removeAllRanges(); - sel.addRange(range) - - # Agent navbar dropdown - $('.navbar .dropdown.dropdown-hover').hover (-> $(this).addClass('open')), (-> $(this).removeClass('open')) \ No newline at end of file diff --git a/app/assets/javascripts/components/form_configurable.js b/app/assets/javascripts/components/form_configurable.js new file mode 100644 index 0000000000..e38ee66d1a --- /dev/null +++ b/app/assets/javascripts/components/form_configurable.js @@ -0,0 +1,95 @@ +$(function () { + const getFormData = function (elem) { + const form_data = $("#edit_agent, #new_agent").serializeObject(); + const attribute = $(elem).data("attribute"); + form_data["attribute"] = attribute; + delete form_data["_method"]; + return form_data; + }; + + return (window.initializeFormCompletable = function () { + $("input[role~=validatable], select[role~=validatable]").on( + "change", + (e) => { + const form_data = getFormData(e.currentTarget); + const form_group = $(e.currentTarget).closest(".form-group"); + return $.ajax("/agents/validate", { + type: "POST", + data: form_data, + success: (data) => { + form_group.addClass("has-feedback").removeClass("has-error"); + form_group.find("span").addClass("hidden"); + form_group.find(".glyphicon-ok").removeClass("hidden"); + }, + error: (data) => { + form_group.addClass("has-feedback").addClass("has-error"); + form_group.find("span").addClass("hidden"); + form_group.find(".glyphicon-remove").removeClass("hidden"); + }, + }); + } + ); + + $("input[role~=validatable], select[role~=validatable]").trigger("change"); + + $.each($("select[role~=completable]"), (i, select) => { + const $select = $(select); + const value = $select.data("value"); + + const setValue = (value) => { + if ( + $select + .find("option") + .toArray() + .some((option) => option.value == value) + ) { + $select.val(value).trigger("change"); + } else { + $select + .append(new Option(value, value, true, true)) + .trigger("change"); + } + }; + + if ($select.data("cacheResponse")) { + const loadData = (data) => { + $select.select2({ data: data, tags: true }); + setValue(value); + }; + + $.ajax("/agents/complete", { + type: "POST", + data: getFormData(select), + success: (data) => loadData(data), + error: (data) => + loadData([{ id: undefined, text: "Error loading data." }]), + }); + } else { + $select.select2({ + ajax: { + url: "/agents/complete", + type: "POST", + data: (params) => getFormData(select), + processResults: (data) => ({ results: data }), + }, + tags: true, + }); + setValue(value); + } + }); + + $("input[type=radio][role~=form-configurable]").change(function (e) { + const input = $(e.currentTarget) + .parents() + .siblings( + `input[data-attribute=${$(e.currentTarget).data("attribute")}]` + ); + if ($(e.currentTarget).val() === "manual") { + input.removeClass("hidden"); + } else { + input.val($(e.currentTarget).val()); + input.addClass("hidden"); + } + }); + }); +}); diff --git a/app/assets/javascripts/components/form_configurable.js.coffee b/app/assets/javascripts/components/form_configurable.js.coffee deleted file mode 100644 index ac6f8512fc..0000000000 --- a/app/assets/javascripts/components/form_configurable.js.coffee +++ /dev/null @@ -1,81 +0,0 @@ -$ -> - getFormData = (elem) -> - form_data = $("#edit_agent, #new_agent").serializeObject() - attribute = $(elem).data('attribute') - form_data['attribute'] = attribute - delete form_data['_method'] - form_data - - window.initializeFormCompletable = -> - returnedResults = {} - completableDefaultOptions = (input) -> - results: [ - (returnedResults[$(input).data('attribute')] || {text: 'Options', children: [{id: undefined, text: 'loading ...'}]}), - { - text: 'Current', - children: [id: $(input).val(), text: $(input).val()] - }, - { - text: 'Custom', - children: [id: 'manualInput', text: 'manual input'] - }, - ] - - $("input[role~=validatable], select[role~=validatable]").on 'change', (e) => - form_data = getFormData(e.currentTarget) - form_group = $(e.currentTarget).closest('.form-group') - $.ajax '/agents/validate', - type: 'POST', - data: form_data - success: (data) -> - form_group.addClass('has-feedback').removeClass('has-error') - form_group.find('span').addClass('hidden') - form_group.find('.glyphicon-ok').removeClass('hidden') - returnedResults = {} - error: (data) -> - form_group.addClass('has-feedback').addClass('has-error') - form_group.find('span').addClass('hidden') - form_group.find('.glyphicon-remove').removeClass('hidden') - returnedResults = {} - - $("input[role~=validatable], select[role~=validatable]").trigger('change') - - $.each $("input[role~=completable]"), (i, input) -> - $(input).select2( - data: -> - completableDefaultOptions(input) - ).on("change", (e) -> - if e.added && e.added.id == 'manualInput' - $(e.currentTarget).select2("destroy") - $(e.currentTarget).val(e.removed.id) - ) - - updateDropdownData = (form_data, element, data) -> - returnedResults[form_data.attribute] = {text: 'Options', children: data} - $(element).trigger('change') - $("input[role~=completable]").off 'select2-opening', select2OpeningCallback - $(element).select2('open') - $("input[role~=completable]").on 'select2-opening', select2OpeningCallback - - select2OpeningCallback = (e) -> - form_data = getFormData(e.currentTarget) - delete returnedResults[form_data.attribute] if returnedResults[form_data.attribute] && !$(e.currentTarget).data('cacheResponse') - return if returnedResults[form_data.attribute] - - $.ajax '/agents/complete', - type: 'POST', - data: form_data - success: (data) -> - updateDropdownData(form_data, e.currentTarget, data) - error: (data) -> - updateDropdownData(form_data, e.currentTarget, [{id: undefined, text: 'Error loading data.'}]) - - $("input[role~=completable]").on 'select2-opening', select2OpeningCallback - - $("input[type=radio][role~=form-configurable]").change (e) -> - input = $(e.currentTarget).parents().siblings("input[data-attribute=#{$(e.currentTarget).data('attribute')}]") - if $(e.currentTarget).val() == 'manual' - input.removeClass('hidden') - else - input.val($(e.currentTarget).val()) - input.addClass('hidden') diff --git a/app/assets/javascripts/components/json-editor.js.coffee.erb b/app/assets/javascripts/components/json-editor.js.coffee.erb deleted file mode 100644 index c9a10cb08d..0000000000 --- a/app/assets/javascripts/components/json-editor.js.coffee.erb +++ /dev/null @@ -1,14 +0,0 @@ -window.setupJsonEditor = ($editors = $(".live-json-editor")) -> - JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>' - JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>' - editors = [] - $editors.each -> - $editor = $(this) - jsonEditor = new JSONEditor($editor, $editor.data('width') || 400, $editor.data('height') || 500) - jsonEditor.doTruncation true - jsonEditor.showFunctionButtons() - editors.push jsonEditor - return editors - -$ -> - window.jsonEditor = setupJsonEditor()[0] diff --git a/app/assets/javascripts/components/json-editor.js.erb b/app/assets/javascripts/components/json-editor.js.erb new file mode 100644 index 0000000000..519ad5b276 --- /dev/null +++ b/app/assets/javascripts/components/json-editor.js.erb @@ -0,0 +1,22 @@ +window.setupJsonEditor = function ($editors) { + if ($editors == null) { + $editors = $(".live-json-editor"); + } + JSONEditor.prototype.ADD_IMG = "<%= image_path 'json-editor/add.png' %>"; + JSONEditor.prototype.DELETE_IMG = "<%= image_path 'json-editor/delete.png' %>"; + const editors = []; + $editors.each(function () { + const $editor = $(this); + const jsonEditor = new JSONEditor( + $editor, + $editor.data("width") || 400, + $editor.data("height") || 500 + ); + jsonEditor.doTruncation(true); + jsonEditor.showFunctionButtons(); + return editors.push(jsonEditor); + }); + return editors; +}; + +$(() => (window.jsonEditor = setupJsonEditor()[0])); diff --git a/app/assets/javascripts/components/search.js b/app/assets/javascripts/components/search.js new file mode 100644 index 0000000000..e0213a05ce --- /dev/null +++ b/app/assets/javascripts/components/search.js @@ -0,0 +1,51 @@ +$(function () { + const $agentNavigate = $("#agent-navigate"); + + // initialize typeahead listener + $agentNavigate.bind("typeahead:selected", function (event, object, name) { + const item = object["value"]; + $agentNavigate.typeahead("val", ""); + if (window.agentPaths[item]) { + $(".spinner").show(); + const navigationData = window.agentPaths[item]; + if ( + !(navigationData instanceof Object) || + !navigationData.method || + navigationData.method === "GET" + ) { + return (window.location = navigationData.url || navigationData); + } else { + return $.rails.handleMethod.apply( + $( + `` + ) + .appendTo($("body")) + .get(0) + ); + } + } + }); + + // substring matcher for typeahead + const substringMatcher = function (strings) { + let findMatches; + return (findMatches = function (query, callback) { + const matches = []; + const substrRegex = new RegExp(query, "i"); + $.each(strings, function (i, str) { + if (substrRegex.test(str)) { + return matches.push({ value: str }); + } + }); + return callback(matches.slice(0, 6)); + }); + }; + + return $agentNavigate.typeahead( + { + minLength: 1, + highlight: true, + }, + { source: substringMatcher(window.agentNames) } + ); +}); diff --git a/app/assets/javascripts/components/search.js.coffee b/app/assets/javascripts/components/search.js.coffee deleted file mode 100644 index f8c2519abd..0000000000 --- a/app/assets/javascripts/components/search.js.coffee +++ /dev/null @@ -1,29 +0,0 @@ -$ -> - $agentNavigate = $('#agent-navigate') - - # initialize typeahead listener - $agentNavigate.bind "typeahead:selected", (event, object, name) -> - item = object['value'] - $agentNavigate.typeahead('val', '') - if window.agentPaths[item] - $(".spinner").show() - navigationData = window.agentPaths[item] - if !(navigationData instanceof Object) || !navigationData.method || navigationData.method == 'GET' - window.location = navigationData.url || navigationData - else - $.rails.handleMethod.apply $("").appendTo($("body")).get(0) - - # substring matcher for typeahead - substringMatcher = (strings) -> - findMatches = (query, callback) -> - matches = [] - substrRegex = new RegExp(query, "i") - $.each strings, (i, str) -> - matches.push value: str if substrRegex.test(str) - callback(matches.slice(0,6)) - - $agentNavigate.typeahead - minLength: 1, - highlight: true, - , - source: substringMatcher(window.agentNames) diff --git a/app/assets/javascripts/components/utils.js b/app/assets/javascripts/components/utils.js new file mode 100644 index 0000000000..d86bdc0f6b --- /dev/null +++ b/app/assets/javascripts/components/utils.js @@ -0,0 +1,221 @@ +(function () { + let escapeMap = undefined; + let createEscaper = undefined; + const Cls = (this.Utils = class Utils { + static initClass() { + // _.escape from underscore: https://github.com/jashkenas/underscore/blob/1e68f06610fa4ecb7f2c45d1eb2ad0173d6a2cc1/underscore.js#L1411-L1436 + escapeMap = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + "`": "`", + }; + + createEscaper = function (map) { + const escaper = (match) => map[match]; + + // Regexes for identifying a key that needs to be escaped. + const source = "(?:" + Object.keys(map).join("|") + ")"; + const testRegexp = RegExp(source); + const replaceRegexp = RegExp(source, "g"); + return function (string) { + string = string === null ? "" : "" + string; + if (testRegexp.test(string)) { + return string.replace(replaceRegexp, escaper); + } else { + return string; + } + }; + }; + + this.escape = createEscaper(escapeMap); + } + static navigatePath(path) { + if (!path.match(/^\//)) { + path = "/" + path; + } + return (window.location.href = path); + } + + static currentPath() { + return window.location.href.replace(/https?:\/\/.*?\//g, ""); + } + + static registerPage(klass, options) { + if (options == null) { + options = {}; + } + if (options.forPathsMatching != null) { + if (Utils.currentPath().match(options.forPathsMatching)) { + return (window.currentPage = new klass()); + } + } else { + return new klass(); + } + } + + static showDynamicModal(content, param) { + if (content == null) { + content = ""; + } + if (param == null) { + param = {}; + } + const { title, body, onHide } = param; + $("body").append(`\ +\ +`); + const modal = document.querySelector("#dynamic-modal"); + $(modal) + .find(".modal-title") + .text(title || "") + .end() + .on("hidden.bs.modal", function () { + $("#dynamic-modal").remove(); + return typeof onHide === "function" ? onHide() : undefined; + }); + if (typeof body === "function") { + body(modal.querySelector(".modal-body")); + } + return $(modal).modal("show"); + } + + static handleDryRunButton(button, data) { + if (data == null) { + data = button.form + ? $(':input[name!="_method"]', button.form).serialize() + : ""; + } + $(button).prop("disabled", true); + const cleanup = () => $(button).prop("disabled", false); + + const url = $(button).data("action-url"); + const with_event_mode = $(button).data("with-event-mode"); + + if (with_event_mode === "no") { + return this.invokeDryRun(url, data, cleanup); + } + return $.ajax(url, { + method: "GET", + data: { + with_event_mode, + source_ids: $.map($(".link-region select option:selected"), (el) => + $(el).val() + ), + }, + success: (modal_data) => { + return Utils.showDynamicModal(modal_data, { + body: (body) => { + let previous; + const form = $(body).find(".dry-run-form"); + const payload_editor = form.find(".payload-editor"); + + if ((previous = $(button).data("payload"))) { + payload_editor.text(previous); + } + + const editor = window.setupJsonEditor(payload_editor)[0]; + + $(body) + .find(".dry-run-event-sample") + .click((e) => { + e.preventDefault(); + editor.json = $(e.currentTarget).data("payload"); + return editor.rebuild(); + }); + + form.submit((e) => { + let dry_run_data; + e.preventDefault(); + let json = $(e.target).find(".payload-editor").val(); + if (json === "") { + json = "{}"; + } + try { + const payload = JSON.parse( + json.replace(/\\\\([n|r|t])/g, "\\$1") + ); + if (payload.constructor !== Object) { + throw true; + } + if (Object.keys(payload).length === 0) { + json = ""; + } else { + json = JSON.stringify(payload); + } + } catch (error) { + alert("Invalid JSON object."); + return; + } + if (json === "") { + if (with_event_mode === "yes") { + alert("Event is required for this agent to run."); + return; + } + dry_run_data = data; + $(button).data("payload", null); + } else { + dry_run_data = `event=${encodeURIComponent(json)}&${data}`; + $(button).data("payload", json); + } + return $(body) + .closest("[role=dialog]") + .on("hidden.bs.modal", () => { + return this.invokeDryRun(url, dry_run_data, cleanup); + }) + .modal("hide"); + }); + return $(body) + .closest("[role=dialog]") + .on("shown.bs.modal", function () { + return $(this).find(".btn-primary").focus(); + }); + }, + title: "Dry Run", + onHide: cleanup, + }); + }, + }); + } + + static invokeDryRun(url, data, callback) { + $("body").css({ cursor: "progress" }); + return $.ajax({ type: "POST", url, dataType: "html", data }) + .always(() => { + return $("body").css({ cursor: "auto" }); + }) + .done((modal_data) => { + return Utils.showDynamicModal(modal_data, { + title: "Dry Run Results", + onHide: callback, + }); + }) + .fail(function (xhr, status, error) { + alert("Error: " + error); + return callback(); + }); + } + + static select2TagClickHandler(e, elem) { + if (e.which === 1) { + return (window.location = $(elem).attr("href")); + } else { + return window.open($(elem).attr("href")); + } + } + }); + Cls.initClass(); + return Cls; +})(); diff --git a/app/assets/javascripts/components/utils.js.coffee b/app/assets/javascripts/components/utils.js.coffee deleted file mode 100644 index b53ea9dd64..0000000000 --- a/app/assets/javascripts/components/utils.js.coffee +++ /dev/null @@ -1,138 +0,0 @@ -class @Utils - @navigatePath: (path) -> - path = "/" + path unless path.match(/^\//) - window.location.href = path - - @currentPath: -> - window.location.href.replace(/https?:\/\/.*?\//g, '') - - @registerPage: (klass, options = {}) -> - if options.forPathsMatching? - if Utils.currentPath().match(options.forPathsMatching) - window.currentPage = new klass() - else - new klass() - - @showDynamicModal: (content = '', { title, body, onHide } = {}) -> - $("body").append """ - - """ - modal = document.querySelector('#dynamic-modal') - $(modal).find('.modal-title').text(title || '').end().on 'hidden.bs.modal', -> - $('#dynamic-modal').remove() - onHide?() - body?(modal.querySelector('.modal-body')) - $(modal).modal('show') - - @handleDryRunButton: (button, data = if button.form then $(':input[name!="_method"]', button.form).serialize() else '') -> - $(button).prop('disabled', true) - cleanup = -> $(button).prop('disabled', false) - - url = $(button).data('action-url') - with_event_mode = $(button).data('with-event-mode') - - if with_event_mode is 'no' - return @invokeDryRun(url, data, cleanup) - $.ajax url, - method: 'GET', - data: - with_event_mode: with_event_mode - source_ids: $.map($(".link-region select option:selected"), (el) -> $(el).val() ) - success: (modal_data) => - Utils.showDynamicModal modal_data, - body: (body) => - form = $(body).find('.dry-run-form') - payload_editor = form.find('.payload-editor') - - if previous = $(button).data('payload') - payload_editor.text(previous) - - editor = window.setupJsonEditor(payload_editor)[0] - - $(body).find('.dry-run-event-sample').click (e) => - e.preventDefault() - editor.json = $(e.currentTarget).data('payload') - editor.rebuild() - - form.submit (e) => - e.preventDefault() - json = $(e.target).find('.payload-editor').val() - json = '{}' if json == '' - try - payload = JSON.parse(json.replace(/\\\\([n|r|t])/g, "\\$1")) - throw true unless payload.constructor is Object - if Object.keys(payload).length == 0 - json = '' - else - json = JSON.stringify(payload) - catch - alert 'Invalid JSON object.' - return - if json == '' - if with_event_mode is 'yes' - alert 'Event is required for this agent to run.' - return - dry_run_data = data - $(button).data('payload', null) - else - dry_run_data = "event=#{encodeURIComponent(json)}&#{data}" - $(button).data('payload', json) - $(body).closest('[role=dialog]').on 'hidden.bs.modal', => - @invokeDryRun(url, dry_run_data, cleanup) - .modal('hide') - $(body).closest('[role=dialog]').on 'shown.bs.modal', -> - $(this).find('.btn-primary').focus() - title: 'Dry Run' - onHide: cleanup - - @invokeDryRun: (url, data, callback) -> - $('body').css(cursor: 'progress') - $.ajax type: 'POST', url: url, dataType: 'html', data: data - .always => - $('body').css(cursor: 'auto') - .done (modal_data) => - Utils.showDynamicModal modal_data, - title: 'Dry Run Results', - onHide: callback - .fail (xhr, status, error) -> - alert('Error: ' + error) - callback() - - @select2TagClickHandler: (e, elem) -> - if e.which == 1 - window.location = $(elem).attr('href') - else - window.open($(elem).attr('href')) - - # _.escape from underscore: https://github.com/jashkenas/underscore/blob/1e68f06610fa4ecb7f2c45d1eb2ad0173d6a2cc1/underscore.js#L1411-L1436 - escapeMap = - '&': '&' - '<': '<' - '>': '>' - '"': '"' - '\'': ''' - '`': '`' - - createEscaper = (map) -> - escaper = (match) -> - map[match] - - # Regexes for identifying a key that needs to be escaped. - source = '(?:' + Object.keys(map).join('|') + ')' - testRegexp = RegExp(source) - replaceRegexp = RegExp(source, 'g') - (string) -> - string = if string == null then '' else '' + string - if testRegexp.test(string) then string.replace(replaceRegexp, escaper) else string - - @escape = createEscaper(escapeMap) diff --git a/app/assets/javascripts/components/worker-checker.js b/app/assets/javascripts/components/worker-checker.js new file mode 100644 index 0000000000..ffe5d1efd7 --- /dev/null +++ b/app/assets/javascripts/components/worker-checker.js @@ -0,0 +1,90 @@ +$(function () { + let sinceId = null; + let previousJobs = null; + + if ($(".job-indicator").length) { + var check = function () { + const query = sinceId != null ? "?since_id=" + sinceId : ""; + return $.getJSON("/worker_status" + query, function (json) { + for (var method of ["pending", "awaiting_retry", "recent_failures"]) { + var count = json[method]; + var elem = $(`.job-indicator[role=${method}]`); + if (count > 0) { + var tooltipOptions = { + title: `${count} jobs ${method.split("_").join(" ")}`, + delay: 0, + placement: "bottom", + trigger: "hover", + }; + if (elem.is(":visible")) { + elem + .tooltip("destroy") + .tooltip(tooltipOptions) + .find(".number") + .text(count); + } else { + elem + .tooltip("destroy") + .tooltip(tooltipOptions) + .fadeIn() + .find(".number") + .text(count); + } + } else { + if (elem.is(":visible")) { + elem.tooltip("destroy").fadeOut(); + } + } + } + + if (sinceId != null && json.event_count > 0) { + $("#event-indicator") + .tooltip("destroy") + .tooltip({ + title: "Click to see the events", + delay: 0, + placement: "bottom", + trigger: "hover", + }) + .find("a") + .attr({ href: json.events_url }) + .end() + .fadeIn() + .find(".number") + .text(json.event_count); + } else { + $("#event-indicator").tooltip("destroy").fadeOut(); + } + + if (sinceId == null) { + sinceId = json.max_id; + } + const currentJobs = [ + json.pending, + json.awaiting_retry, + json.recent_failures, + ]; + if ( + document.location.pathname === "/jobs" && + $(".modal[aria-hidden=false]").length === 0 && + previousJobs != null && + previousJobs.join(",") !== currentJobs.join(",") + ) { + if ( + !document.location.search || + document.location.search === "?page=1" + ) { + $.get("/jobs", (data) => { + return $("#main-content").html(data); + }); + } + } + previousJobs = currentJobs; + + return (window.workerCheckTimeout = setTimeout(check, 2000)); + }); + }; + + return check(); + } +}); diff --git a/app/assets/javascripts/components/worker-checker.js.coffee b/app/assets/javascripts/components/worker-checker.js.coffee deleted file mode 100644 index 2a267f14f7..0000000000 --- a/app/assets/javascripts/components/worker-checker.js.coffee +++ /dev/null @@ -1,51 +0,0 @@ -$ -> - sinceId = null - previousJobs = null - - if $(".job-indicator").length - check = -> - query = - if sinceId? - '?since_id=' + sinceId - else - '' - $.getJSON "/worker_status" + query, (json) -> - for method in ['pending', 'awaiting_retry', 'recent_failures'] - count = json[method] - elem = $(".job-indicator[role=#{method}]") - if count > 0 - tooltipOptions = { - title: "#{count} jobs #{method.split('_').join(' ')}" - delay: 0 - placement: "bottom" - trigger: "hover" - } - if elem.is(":visible") - elem.tooltip('destroy').tooltip(tooltipOptions).find(".number").text(count) - else - elem.tooltip('destroy').tooltip(tooltipOptions).fadeIn().find(".number").text(count) - else - if elem.is(":visible") - elem.tooltip('destroy').fadeOut() - - if sinceId? && json.event_count > 0 - $("#event-indicator").tooltip('destroy'). - tooltip(title: "Click to see the events", delay: 0, placement: "bottom", trigger: "hover"). - find('a').attr(href: json.events_url).end(). - fadeIn(). - find(".number"). - text(json.event_count) - else - $("#event-indicator").tooltip('destroy').fadeOut() - - sinceId ?= json.max_id - currentJobs = [json.pending, json.awaiting_retry, json.recent_failures] - if document.location.pathname == '/jobs' && $(".modal[aria-hidden=false]").length == 0 && previousJobs? && previousJobs.join(',') != currentJobs.join(',') - if !document.location.search || document.location.search == '?page=1' - $.get '/jobs', (data) => - $("#main-content").html(data) - previousJobs = currentJobs - - window.workerCheckTimeout = setTimeout check, 2000 - - check() diff --git a/app/assets/javascripts/diagram.js b/app/assets/javascripts/diagram.js new file mode 100644 index 0000000000..e8721bc49a --- /dev/null +++ b/app/assets/javascripts/diagram.js @@ -0,0 +1,29 @@ +// This is not included in the core application.js bundle. + +$(function () { + const svg = document.querySelector(".agent-diagram svg.diagram"); + const overlay = document.querySelector(".agent-diagram .overlay"); + $(overlay).width($(svg).width()).height($(svg).height()); + const getTopLeft = function (node) { + const bbox = node.getBBox(); + const point = svg.createSVGPoint(); + point.x = bbox.x + bbox.width; + point.y = bbox.y; + return point.matrixTransform(node.getCTM()); + }; + return $(svg) + .find("g.node[data-badge-id]") + .each(function () { + const tl = getTopLeft(this); + $("#" + this.getAttribute("data-badge-id"), overlay).each(function () { + const badge = $(this); + badge + .css({ + left: tl.x - badge.outerWidth() * (2 / 3), + top: tl.y - badge.outerHeight() * (1 / 3), + "background-color": badge.find(".label").css("background-color"), + }) + .show(); + }); + }); +}); diff --git a/app/assets/javascripts/diagram.js.coffee b/app/assets/javascripts/diagram.js.coffee deleted file mode 100644 index 70275f734d..0000000000 --- a/app/assets/javascripts/diagram.js.coffee +++ /dev/null @@ -1,23 +0,0 @@ -# This is not included in the core application.js bundle. - -$ -> - svg = document.querySelector('.agent-diagram svg.diagram') - overlay = document.querySelector('.agent-diagram .overlay') - $(overlay).width($(svg).width()).height($(svg).height()) - getTopLeft = (node) -> - bbox = node.getBBox() - point = svg.createSVGPoint() - point.x = bbox.x + bbox.width - point.y = bbox.y - point.matrixTransform(node.getCTM()) - $(svg).find('g.node[data-badge-id]').each -> - tl = getTopLeft(this) - $('#' + this.getAttribute('data-badge-id'), overlay).each -> - badge = $(this) - badge.css - left: tl.x - badge.outerWidth() * (2/3) - top: tl.y - badge.outerHeight() * (1/3) - 'background-color': badge.find('.label').css('background-color') - .show() - return - return diff --git a/app/assets/javascripts/graphing.js b/app/assets/javascripts/graphing.js new file mode 100644 index 0000000000..f1030784a3 --- /dev/null +++ b/app/assets/javascripts/graphing.js @@ -0,0 +1,76 @@ +//= require d3 +//= require rickshaw +//= require_self + +// This is not included in the core application.js bundle. + +window.renderGraph = function ($chart, data, peaks, name) { + const graph = new Rickshaw.Graph({ + element: $chart.find(".chart").get(0), + width: 700, + height: 240, + series: [ + { + data, + name, + color: "steelblue", + }, + ], + }); + + const x_axis = new Rickshaw.Graph.Axis.Time({ graph }); + + const annotator = new Rickshaw.Graph.Annotate({ + graph, + element: $chart.find(".timeline").get(0), + }); + $.each(peaks, function () { + return annotator.add(this, "Peak"); + }); + + const y_axis = new Rickshaw.Graph.Axis.Y({ + graph, + orientation: "left", + tickFormat: Rickshaw.Fixtures.Number.formatKMBT, + element: $chart.find(".y-axis").get(0), + }); + + graph.onUpdate(function () { + const mean = d3.mean(data, (i) => i.y); + const standard_deviation = Math.sqrt( + d3.mean(data.map((i) => Math.pow(i.y - mean, 2))) + ); + const minX = d3.min(data, (i) => i.x); + const maxX = d3.max(data, (i) => i.x); + graph.vis + .append("svg:line") + .attr("x1", graph.x(minX)) + .attr("x2", graph.x(maxX)) + .attr("y1", graph.y(mean)) + .attr("y2", graph.y(mean)) + .attr("class", "summary-statistic mean"); + graph.vis + .append("svg:line") + .attr("x1", graph.x(minX)) + .attr("x2", graph.x(maxX)) + .attr("y1", graph.y(mean + standard_deviation)) + .attr("y2", graph.y(mean + standard_deviation)) + .attr("class", "summary-statistic one-std"); + graph.vis + .append("svg:line") + .attr("x1", graph.x(minX)) + .attr("x2", graph.x(maxX)) + .attr("y1", graph.y(mean + 2 * standard_deviation)) + .attr("y2", graph.y(mean + 2 * standard_deviation)) + .attr("class", "summary-statistic two-std"); + return graph.vis + .append("svg:line") + .attr("x1", graph.x(minX)) + .attr("x2", graph.x(maxX)) + .attr("y1", graph.y(mean + 3 * standard_deviation)) + .attr("y2", graph.y(mean + 3 * standard_deviation)) + .attr("class", "summary-statistic three-std"); + }); + + return graph.render(); +}; diff --git a/app/assets/javascripts/graphing.js.coffee b/app/assets/javascripts/graphing.js.coffee deleted file mode 100644 index 21aac67224..0000000000 --- a/app/assets/javascripts/graphing.js.coffee +++ /dev/null @@ -1,62 +0,0 @@ -#= require d3 -#= require rickshaw -#= require_self - -# This is not included in the core application.js bundle. - -window.renderGraph = ($chart, data, peaks, name) -> - graph = new Rickshaw.Graph - element: $chart.find(".chart").get(0) - width: 700 - height: 240 - series: [ - data: data - name: name - color: 'steelblue' - ] - - x_axis = new Rickshaw.Graph.Axis.Time(graph: graph) - - annotator = new Rickshaw.Graph.Annotate - graph: graph - element: $chart.find(".timeline").get(0) - $.each peaks, -> - annotator.add this, "Peak" - - y_axis = new Rickshaw.Graph.Axis.Y - graph: graph - orientation: 'left' - tickFormat: Rickshaw.Fixtures.Number.formatKMBT - element: $chart.find(".y-axis").get(0) - - graph.onUpdate -> - mean = d3.mean data, (i) -> i.y - standard_deviation = Math.sqrt(d3.mean(data.map((i) -> Math.pow(i.y - mean, 2)))) - minX = d3.min data, (i) -> i.x - maxX = d3.max data, (i) -> i.x - graph.vis.append("svg:line") - .attr('x1', graph.x(minX)) - .attr('x2', graph.x(maxX)) - .attr('y1', graph.y(mean)) - .attr('y2', graph.y(mean)) - .attr 'class', 'summary-statistic mean' - graph.vis.append("svg:line") - .attr('x1', graph.x(minX)) - .attr('x2', graph.x(maxX)) - .attr('y1', graph.y(mean + standard_deviation)) - .attr('y2', graph.y(mean + standard_deviation)) - .attr 'class', 'summary-statistic one-std' - graph.vis.append("svg:line") - .attr('x1', graph.x(minX)) - .attr('x2', graph.x(maxX)) - .attr('y1', graph.y(mean + 2 * standard_deviation)) - .attr('y2', graph.y(mean + 2 * standard_deviation)) - .attr 'class', 'summary-statistic two-std' - graph.vis.append("svg:line") - .attr('x1', graph.x(minX)) - .attr('x2', graph.x(maxX)) - .attr('y1', graph.y(mean + 3 * standard_deviation)) - .attr('y2', graph.y(mean + 3 * standard_deviation)) - .attr 'class', 'summary-statistic three-std' - - graph.render() diff --git a/app/assets/javascripts/map_marker.js b/app/assets/javascripts/map_marker.js new file mode 100644 index 0000000000..c599c27cf6 --- /dev/null +++ b/app/assets/javascripts/map_marker.js @@ -0,0 +1,48 @@ +window.map_marker = function (map, options) { + let marker; + if (options == null) { + options = {}; + } + const pos = new google.maps.LatLng(options.lat, options.lng); + + if (options.radius > 0) { + marker = new google.maps.Circle({ + map, + strokeColor: "#FF0000", + strokeOpacity: 0.8, + strokeWeight: 2, + fillColor: "#FF0000", + fillOpacity: 0.35, + center: pos, + radius: options.radius, + }); + return marker; + } else if (options.course) { + const p1 = new LatLon(pos.lat(), pos.lng()); + const speed = options.speed != null ? options.speed : 1; + const p2 = p1.destinationPoint(options.course, Math.max(0.2, speed) * 0.1); + + const lineCoordinates = [pos, new google.maps.LatLng(p2.lat(), p2.lon())]; + + const lineSymbol = { path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW }; + + const arrow = new google.maps.Polyline({ + map, + path: lineCoordinates, + icons: [ + { + icon: lineSymbol, + offset: "100%", + }, + ], + }); + return arrow; + } else { + marker = new google.maps.Marker({ + map, + position: pos, + title: "Recorded Location", + }); + return marker; + } +}; diff --git a/app/assets/javascripts/map_marker.js.coffee b/app/assets/javascripts/map_marker.js.coffee deleted file mode 100644 index 661b0311a1..0000000000 --- a/app/assets/javascripts/map_marker.js.coffee +++ /dev/null @@ -1,43 +0,0 @@ -window.map_marker = (map, options = {}) -> - pos = new google.maps.LatLng(options.lat, options.lng) - - if options.radius > 0 - marker = new google.maps.Circle - map: map - strokeColor: '#FF0000' - strokeOpacity: 0.8 - strokeWeight: 2 - fillColor: '#FF0000' - fillOpacity: 0.35 - center: pos - radius: options.radius - return marker - else if options.course - p1 = new LatLon(pos.lat(), pos.lng()) - speed = options.speed ? 1 - p2 = p1.destinationPoint(options.course, Math.max(0.2, speed) * 0.1) - - lineCoordinates = [ - pos - new google.maps.LatLng(p2.lat(), p2.lon()) - ] - - lineSymbol = - path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW - - arrow = new google.maps.Polyline - map: map - path: lineCoordinates - icons: [ - { - icon: lineSymbol - offset: '100%' - } - ] - return arrow - else - marker = new google.maps.Marker - map: map - position: pos - title: 'Recorded Location' - return marker diff --git a/app/assets/javascripts/pages/agent-edit-page.js b/app/assets/javascripts/pages/agent-edit-page.js new file mode 100644 index 0000000000..56edc7f164 --- /dev/null +++ b/app/assets/javascripts/pages/agent-edit-page.js @@ -0,0 +1,340 @@ +(function () { + let formatAgentForSelect = undefined; + const Cls = (this.AgentEditPage = class AgentEditPage { + static initClass() { + formatAgentForSelect = function (agent) { + const originalOption = agent.element; + const description = agent.element[0].title; + return "" + agent.text + "
" + description; + }; + } + constructor() { + this.invokeDryRun = this.invokeDryRun.bind(this); + $("#agent_source_ids").on("change", this.showEventDescriptions); + this.showCorrectRegionsOnStartup(); + $("form.agent-form").on("submit", () => this.updateFromEditors()); + + // Validate agents_options Json on form submit + $("form.agent-form").submit(function (e) { + if ($("textarea#agent_options").length) { + try { + JSON.parse($("#agent_options").val()); + } catch (err) { + e.preventDefault(); + alert( + "Sorry, there appears to be an error in your JSON input. Please fix it before continuing." + ); + return false; + } + } + + if ( + $(".link-region").length && + $(".link-region").data("can-receive-events") === false + ) { + $(".link-region .select2-linked-tags option:selected").removeAttr( + "selected" + ); + } + + if ( + $(".control-link-region").length && + $(".control-link-region").data("can-control-other-agents") === false + ) { + $( + ".control-link-region .select2-linked-tags option:selected" + ).removeAttr("selected"); + } + + if ( + $(".event-related-region").length && + $(".event-related-region").data("can-create-events") === false + ) { + return $( + ".event-related-region .select2-linked-tags option:selected" + ).removeAttr("selected"); + } + }); + + $("#agent_name").each(function () { + // Select the number suffix if this is a cloned agent. + let matches; + if ((matches = this.value.match(/ \(\d+\)$/))) { + this.focus(); + if (this.selectionStart != null) { + this.selectionStart = matches.index; + return (this.selectionEnd = this.value.length); + } + } + }); + + // The type selector is only available on the new agent form. + if ($("#agent_type").length) { + $("#agent_type").on("change", () => this.handleTypeChange(false)); + this.handleTypeChange(true); + + // Update the dropdown to match agent description as well as agent name + $("select#agent_type").select2({ + width: "resolve", + formatResult: formatAgentForSelect, + escapeMarkup: (m) => m, + matcher: (params, data) => { + const term = params.term; + if (term == null) return data; + const upperTerm = term.toUpperCase(); + return data.text.toUpperCase().indexOf(upperTerm) >= 0 || + data.title.toUpperCase().indexOf(upperTerm) >= 0 + ? data + : null; + }, + }); + } else { + this.enableDryRunButton(); + this.buildAce(); + } + } + + handleTypeChange(firstTime) { + $(".event-descriptions").html("").hide(); + const type = $("#agent_type").val(); + + if (type === "Agent") { + $(".agent-settings").hide(); + return $(".description").hide(); + } else { + $(".agent-settings").show(); + $("#agent-spinner").fadeIn(); + if (!firstTime) { + $(".model-errors").hide(); + } + return $.getJSON("/agents/type_details", { type }, (json) => { + if (json.can_be_scheduled) { + if (firstTime) { + this.showSchedule(); + } else { + this.showSchedule(json.default_schedule); + } + } else { + this.hideSchedule(); + } + + if (json.can_receive_events) { + this.showLinks(); + } else { + this.hideLinks(); + } + + if (json.can_control_other_agents) { + this.showControlLinks(); + } else { + this.hideControlLinks(); + } + + if (json.can_create_events) { + this.showEventCreation(); + } else { + this.hideEventCreation(); + } + + if (json.description_html != null) { + $(".description").show().html(json.description_html); + } + + if (!firstTime) { + if (json.oauthable != null) { + $(".oauthable-form").html(json.oauthable); + } + if (json.form_options != null) { + $(".agent-options").html(json.form_options); + } + window.jsonEditor = setupJsonEditor()[0]; + } + + this.enableDryRunButton(); + this.buildAce(); + + window.initializeFormCompletable(); + + return $("#agent-spinner").stop(true, true).fadeOut(); + }); + } + } + + hideSchedule() { + $(".schedule-region .can-be-scheduled").hide(); + return $(".schedule-region .cannot-be-scheduled").show(); + } + + showSchedule(defaultSchedule = null) { + if (defaultSchedule != null) { + $(".schedule-region select").val(defaultSchedule).change(); + } + $(".schedule-region .can-be-scheduled").show(); + return $(".schedule-region .cannot-be-scheduled").hide(); + } + + hideLinks() { + $(".link-region .select2-container").hide(); + $(".link-region .propagate-immediately").hide(); + $(".link-region .cannot-receive-events").show(); + return $(".link-region").data("can-receive-events", false); + } + + showLinks() { + $(".link-region .select2-container").show(); + $(".link-region .propagate-immediately").show(); + $(".link-region .cannot-receive-events").hide(); + $(".link-region").data("can-receive-events", true); + return this.showEventDescriptions(); + } + + hideControlLinks() { + $(".control-link-region").hide(); + return $(".control-link-region").data("can-control-other-agents", false); + } + + showControlLinks() { + $(".control-link-region").show(); + return $(".control-link-region").data("can-control-other-agents", true); + } + + hideEventCreation() { + $(".event-related-region .select2-container").hide(); + $(".event-related-region .cannot-create-events").show(); + return $(".event-related-region").data("can-create-events", false); + } + + showEventCreation() { + $(".event-related-region .select2-container").show(); + $(".event-related-region .cannot-create-events").hide(); + return $(".event-related-region").data("can-create-events", true); + } + + showEventDescriptions() { + if ($("#agent_source_ids").val()) { + return $.getJSON( + "/agents/event_descriptions", + { ids: $("#agent_source_ids").val().join(",") }, + (json) => { + if (json.description_html != null) { + return $(".event-descriptions") + .show() + .html(json.description_html); + } else { + return $(".event-descriptions").hide(); + } + } + ); + } else { + return $(".event-descriptions").html("").hide(); + } + } + + showCorrectRegionsOnStartup() { + if ($(".schedule-region")) { + if ($(".schedule-region").data("can-be-scheduled") === true) { + this.showSchedule(); + } else { + this.hideSchedule(); + } + } + + if ($(".link-region")) { + if ($(".link-region").data("can-receive-events") === true) { + this.showLinks(); + } else { + this.hideLinks(); + } + } + + if ($(".control-link-region")) { + if ( + $(".control-link-region").data("can-control-other-agents") === true + ) { + this.showControlLinks(); + } else { + this.hideControlLinks(); + } + } + + if ($(".event-related-region")) { + if ($(".event-related-region").data("can-create-events") === true) { + return this.showEventCreation(); + } else { + return this.hideEventCreation(); + } + } + } + + buildAce() { + return $(".ace-editor").each(function () { + if (!$(this).data("initialized")) { + const $this = $(this); + $this.data("initialized", true); + const $source = $($this.data("source")).hide(); + const editor = ace.edit(this); + $this.data("ace-editor", editor); + const session = editor.getSession(); + session.setTabSize(2); + session.setUseSoftTabs(true); + session.setUseWrapMode(false); + + const setSyntax = function () { + let mode, theme; + if ((mode = $this.data("mode"))) { + session.setMode("ace/mode/" + mode); + } + + if ((theme = $this.data("theme"))) { + editor.setTheme("ace/theme/" + theme); + } + + if ((mode = $("[name='agent[options][language]']").val())) { + switch (mode) { + case "JavaScript": + return session.setMode("ace/mode/javascript"); + case "CoffeeScript": + return session.setMode("ace/mode/coffee"); + default: + return session.setMode("ace/mode/" + mode); + } + } + }; + + $("[name='agent[options][language]']").on("change", setSyntax); + setSyntax(); + + return session.setValue($source.val()); + } + }); + } + + updateFromEditors() { + return $(".ace-editor").each(function () { + const $source = $($(this).data("source")); + return $source.val($(this).data("ace-editor").getSession().getValue()); + }); + } + + enableDryRunButton() { + return $(".agent-dry-run-button") + .prop("disabled", false) + .off() + .on("click", this.invokeDryRun); + } + + disableDryRunButton() { + return $(".agent-dry-run-button").prop("disabled", true); + } + + invokeDryRun(e) { + e.preventDefault(); + this.updateFromEditors(); + return Utils.handleDryRunButton(e.currentTarget); + } + }); + Cls.initClass(); + return Cls; +})(); + +$(() => Utils.registerPage(AgentEditPage, { forPathsMatching: /^agents/ })); diff --git a/app/assets/javascripts/pages/agent-edit-page.js.coffee b/app/assets/javascripts/pages/agent-edit-page.js.coffee deleted file mode 100644 index 0eb5e01cc3..0000000000 --- a/app/assets/javascripts/pages/agent-edit-page.js.coffee +++ /dev/null @@ -1,231 +0,0 @@ -class @AgentEditPage - constructor: -> - $("#agent_source_ids").on "change", @showEventDescriptions - @showCorrectRegionsOnStartup() - $("form.agent-form").on "submit", => @updateFromEditors() - - # Validate agents_options Json on form submit - $('form.agent-form').submit (e) -> - if $('textarea#agent_options').length - try - JSON.parse $('#agent_options').val() - catch err - e.preventDefault() - alert 'Sorry, there appears to be an error in your JSON input. Please fix it before continuing.' - return false - - if $(".link-region").length && $(".link-region").data("can-receive-events") == false - $(".link-region .select2-linked-tags option:selected").removeAttr('selected') - - if $(".control-link-region").length && $(".control-link-region").data("can-control-other-agents") == false - $(".control-link-region .select2-linked-tags option:selected").removeAttr('selected') - - if $(".event-related-region").length && $(".event-related-region").data("can-create-events") == false - $(".event-related-region .select2-linked-tags option:selected").removeAttr('selected') - - $("#agent_name").each -> - # Select the number suffix if this is a cloned agent. - if matches = this.value.match(/ \(\d+\)$/) - this.focus() - if this.selectionStart? - this.selectionStart = matches.index - this.selectionEnd = this.value.length - - # The type selector is only available on the new agent form. - if $("#agent_type").length - $("#agent_type").on "change", => @handleTypeChange(false) - @handleTypeChange(true) - - # Update the dropdown to match agent description as well as agent name - $('select#agent_type').select2 - width: 'resolve' - formatResult: formatAgentForSelect - escapeMarkup: (m) -> - m - matcher: (term, text, opt) -> - description = opt.attr('title') - text.toUpperCase().indexOf(term.toUpperCase()) >= 0 or description.toUpperCase().indexOf(term.toUpperCase()) >= 0 - - else - @enableDryRunButton() - @buildAce() - - handleTypeChange: (firstTime) -> - $(".event-descriptions").html("").hide() - type = $('#agent_type').val() - - if type == 'Agent' - $(".agent-settings").hide() - $(".description").hide() - else - $(".agent-settings").show() - $("#agent-spinner").fadeIn() - $(".model-errors").hide() unless firstTime - $.getJSON "/agents/type_details", { type: type }, (json) => - if json.can_be_scheduled - if firstTime - @showSchedule() - else - @showSchedule(json.default_schedule) - else - @hideSchedule() - - if json.can_receive_events - @showLinks() - else - @hideLinks() - - if json.can_control_other_agents - @showControlLinks() - else - @hideControlLinks() - - if json.can_create_events - @showEventCreation() - else - @hideEventCreation() - - $(".description").show().html(json.description_html) if json.description_html? - - unless firstTime - $('.oauthable-form').html(json.oauthable) if json.oauthable? - $('.agent-options').html(json.form_options) if json.form_options? - window.jsonEditor = setupJsonEditor()[0] - - @enableDryRunButton() - @buildAce() - - window.initializeFormCompletable() - - $("#agent-spinner").stop(true, true).fadeOut(); - - hideSchedule: -> - $(".schedule-region .can-be-scheduled").hide() - $(".schedule-region .cannot-be-scheduled").show() - - showSchedule: (defaultSchedule = null) -> - if defaultSchedule? - $(".schedule-region select").val(defaultSchedule).change() - $(".schedule-region .can-be-scheduled").show() - $(".schedule-region .cannot-be-scheduled").hide() - - hideLinks: -> - $(".link-region .select2-container").hide() - $(".link-region .propagate-immediately").hide() - $(".link-region .cannot-receive-events").show() - $(".link-region").data("can-receive-events", false) - - showLinks: -> - $(".link-region .select2-container").show() - $(".link-region .propagate-immediately").show() - $(".link-region .cannot-receive-events").hide() - $(".link-region").data("can-receive-events", true) - @showEventDescriptions() - - hideControlLinks: -> - $(".control-link-region").hide() - $(".control-link-region").data("can-control-other-agents", false) - - showControlLinks: -> - $(".control-link-region").show() - $(".control-link-region").data("can-control-other-agents", true) - - hideEventCreation: -> - $(".event-related-region .select2-container").hide() - $(".event-related-region .cannot-create-events").show() - $(".event-related-region").data("can-create-events", false) - - showEventCreation: -> - $(".event-related-region .select2-container").show() - $(".event-related-region .cannot-create-events").hide() - $(".event-related-region").data("can-create-events", true) - - showEventDescriptions: -> - if $("#agent_source_ids").val() - $.getJSON "/agents/event_descriptions", { ids: $("#agent_source_ids").val().join(",") }, (json) => - if json.description_html? - $(".event-descriptions").show().html(json.description_html) - else - $(".event-descriptions").hide() - else - $(".event-descriptions").html("").hide() - - showCorrectRegionsOnStartup: -> - if $(".schedule-region") - if $(".schedule-region").data("can-be-scheduled") == true - @showSchedule() - else - @hideSchedule() - - if $(".link-region") - if $(".link-region").data("can-receive-events") == true - @showLinks() - else - @hideLinks() - - if $(".control-link-region") - if $(".control-link-region").data("can-control-other-agents") == true - @showControlLinks() - else - @hideControlLinks() - - if $(".event-related-region") - if $(".event-related-region").data("can-create-events") == true - @showEventCreation() - else - @hideEventCreation() - - buildAce: -> - $(".ace-editor").each -> - unless $(this).data('initialized') - $this = $(this) - $this.data('initialized', true) - $source = $($this.data('source')).hide() - editor = ace.edit(this) - $this.data('ace-editor', editor) - session = editor.getSession() - session.setTabSize(2) - session.setUseSoftTabs(true) - session.setUseWrapMode(false) - - setSyntax = -> - if mode = $this.data('mode') - session.setMode("ace/mode/" + mode) - - if theme = $this.data('theme') - editor.setTheme("ace/theme/" + theme); - - if mode = $("[name='agent[options][language]']").val() - switch mode - when 'JavaScript' then session.setMode("ace/mode/javascript") - when 'CoffeeScript' then session.setMode("ace/mode/coffee") - else session.setMode("ace/mode/" + mode) - - $("[name='agent[options][language]']").on 'change', setSyntax - setSyntax() - - session.setValue($source.val()) - - updateFromEditors: -> - $(".ace-editor").each -> - $source = $($(this).data('source')) - $source.val($(this).data('ace-editor').getSession().getValue()) - - enableDryRunButton: -> - $(".agent-dry-run-button").prop('disabled', false).off().on "click", @invokeDryRun - - disableDryRunButton: -> - $(".agent-dry-run-button").prop('disabled', true) - - invokeDryRun: (e) => - e.preventDefault() - @updateFromEditors() - Utils.handleDryRunButton(e.currentTarget) - - formatAgentForSelect = (agent) -> - originalOption = agent.element - description = agent.element[0].title - '' + agent.text + '
' + description - -$ -> - Utils.registerPage(AgentEditPage, forPathsMatching: /^agents/) diff --git a/app/assets/javascripts/pages/agent-show-page.js b/app/assets/javascripts/pages/agent-show-page.js new file mode 100644 index 0000000000..ab5cf63285 --- /dev/null +++ b/app/assets/javascripts/pages/agent-show-page.js @@ -0,0 +1,112 @@ +this.AgentShowPage = class AgentShowPage { + constructor() { + let tab; + $(".agent-show #show-tabs a[href='#logs'], #logs .refresh").on( + "click", + this.fetchLogs + ); + $(".agent-show #logs .clear").on("click", this.clearLogs); + $(".agent-show #memory .clear").on("click", this.clearMemory); + $("#toggle-memory").on("click", this.toggleMemory); + + // Trigger tabs when navigated to. + if ( + (tab = __guard__(window.location.href.match(/tab=(\w+)\b/i), (x) => x[1])) + ) { + if (["details", "logs"].includes(tab)) { + $(`.agent-show .nav-pills li a[href='#${tab}']`).click(); + } + } + } + + fetchLogs(e) { + const agentId = $(e.target).closest("[data-agent-id]").data("agent-id"); + e.preventDefault(); + $("#logs .spinner").show(); + $("#logs .refresh, #logs .clear").hide(); + return $.get(`/agents/${agentId}/logs`, (html) => { + $("#logs .logs").html(html); + $("#logs .logs .show-log-details").each(function () { + const $button = $(this); + return $button.on("click", function (e) { + e.preventDefault(); + return Utils.showDynamicModal("
", {
+            title: $button.data("modal-title"),
+            body(body) {
+              return $(body).find("pre").text($button.data("modal-content"));
+            },
+          });
+        });
+      });
+
+      return $("#logs .spinner")
+        .stop(true, true)
+        .fadeOut(() => $("#logs .refresh, #logs .clear").show());
+    });
+  }
+
+  clearLogs(e) {
+    if (confirm("Are you sure you want to clear all logs for this Agent?")) {
+      const agentId = $(e.target).closest("[data-agent-id]").data("agent-id");
+      e.preventDefault();
+      $("#logs .spinner").show();
+      $("#logs .refresh, #logs .clear").hide();
+      return $.post(
+        `/agents/${agentId}/logs/clear`,
+        { _method: "DELETE" },
+        (html) => {
+          $("#logs .logs").html(html);
+          $("#show-tabs li a.recent-errors").removeClass("recent-errors");
+          return $("#logs .spinner")
+            .stop(true, true)
+            .fadeOut(() => $("#logs .refresh, #logs .clear").show());
+        }
+      );
+    }
+  }
+
+  toggleMemory(e) {
+    e.preventDefault();
+    if ($("pre.memory").hasClass("hidden")) {
+      $("pre.memory").removeClass("hidden");
+      return $("#toggle-memory").text("Hide");
+    } else {
+      $("pre.memory").addClass("hidden");
+      return $("#toggle-memory").text("Show");
+    }
+  }
+
+  clearMemory(e) {
+    if (
+      confirm(
+        "Are you sure you want to completely clear the memory of this Agent?"
+      )
+    ) {
+      const agentId = $(e.target).closest("[data-agent-id]").data("agent-id");
+      e.preventDefault();
+      $("#memory .spinner").css({ display: "inline-block" });
+      $("#memory .clear").hide();
+      return $.post(`/agents/${agentId}/memory`, { _method: "DELETE" })
+        .done(() =>
+          $("#memory .spinner").fadeOut(() =>
+            $("#memory + .memory").text("{\n}\n")
+          )
+        )
+        .fail(() =>
+          $("#memory .spinner").fadeOut(() =>
+            $("#memory .clear").css({ display: "inline-block" })
+          )
+        );
+    }
+  }
+};
+
+$(() =>
+  Utils.registerPage(AgentShowPage, { forPathsMatching: /^agents\/\d+/ })
+);
+
+function __guard__(value, transform) {
+  return typeof value !== "undefined" && value !== null
+    ? transform(value)
+    : undefined;
+}
diff --git a/app/assets/javascripts/pages/agent-show-page.js.coffee b/app/assets/javascripts/pages/agent-show-page.js.coffee
deleted file mode 100644
index f692982423..0000000000
--- a/app/assets/javascripts/pages/agent-show-page.js.coffee
+++ /dev/null
@@ -1,68 +0,0 @@
-class @AgentShowPage
-  constructor: ->
-    $(".agent-show #show-tabs a[href='#logs'], #logs .refresh").on "click", @fetchLogs
-    $(".agent-show #logs .clear").on "click", @clearLogs
-    $(".agent-show #memory .clear").on "click", @clearMemory
-    $('#toggle-memory').on "click", @toggleMemory
-
-    # Trigger tabs when navigated to.
-    if tab = window.location.href.match(/tab=(\w+)\b/i)?[1]
-      if tab in ["details", "logs"]
-        $(".agent-show .nav-pills li a[href='##{tab}']").click()
-
-  fetchLogs: (e) ->
-    agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
-    e.preventDefault()
-    $("#logs .spinner").show()
-    $("#logs .refresh, #logs .clear").hide()
-    $.get "/agents/#{agentId}/logs", (html) =>
-      $("#logs .logs").html html
-      $("#logs .logs .show-log-details").each ->
-        $button = $(this)
-        $button.on 'click', (e) ->
-          e.preventDefault()
-          Utils.showDynamicModal '
',
-            title: $button.data('modal-title'),
-            body: (body) ->
-              $(body).find('pre').text $button.data('modal-content')
-
-      $("#logs .spinner").stop(true, true).fadeOut ->
-        $("#logs .refresh, #logs .clear").show()
-
-  clearLogs: (e) ->
-    if confirm("Are you sure you want to clear all logs for this Agent?")
-      agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
-      e.preventDefault()
-      $("#logs .spinner").show()
-      $("#logs .refresh, #logs .clear").hide()
-      $.post "/agents/#{agentId}/logs/clear", { "_method": "DELETE" }, (html) =>
-        $("#logs .logs").html html
-        $("#show-tabs li a.recent-errors").removeClass 'recent-errors'
-        $("#logs .spinner").stop(true, true).fadeOut ->
-          $("#logs .refresh, #logs .clear").show()
-
-  toggleMemory: (e) ->
-    e.preventDefault()
-    if $('pre.memory').hasClass('hidden')
-      $('pre.memory').removeClass 'hidden'
-      $('#toggle-memory').text('Hide')
-    else
-      $('pre.memory').addClass 'hidden'
-      $('#toggle-memory').text('Show')
-
-  clearMemory: (e) ->
-    if confirm("Are you sure you want to completely clear the memory of this Agent?")
-      agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
-      e.preventDefault()
-      $("#memory .spinner").css(display: 'inline-block')
-      $("#memory .clear").hide()
-      $.post "/agents/#{agentId}/memory", { "_method": "DELETE" }
-        .done ->
-          $("#memory .spinner").fadeOut ->
-            $("#memory + .memory").text "{\n}\n"
-        .fail ->
-          $("#memory .spinner").fadeOut ->
-            $("#memory .clear").css(display: 'inline-block')
-
-$ ->
-  Utils.registerPage(AgentShowPage, forPathsMatching: /^agents\/\d+/)
diff --git a/app/assets/javascripts/pages/scenario-form-page.js b/app/assets/javascripts/pages/scenario-form-page.js
new file mode 100644
index 0000000000..77bc6bc79d
--- /dev/null
+++ b/app/assets/javascripts/pages/scenario-form-page.js
@@ -0,0 +1,23 @@
+this.ScenarioFormPage = class ScenarioFormPage {
+  constructor() {
+    this.enabledSelect2();
+  }
+
+  format(icon) {
+    const originalOption = icon.element;
+    return (
+      ' ' + icon.text
+    );
+  }
+
+  enabledSelect2() {
+    return $(".select2-fountawesome-icon").select2({
+      width: "100%",
+      formatResult: this.format,
+    });
+  }
+};
+
+$(() =>
+  Utils.registerPage(ScenarioFormPage, { forPathsMatching: /^scenarios/ })
+);
diff --git a/app/assets/javascripts/pages/scenario-form-page.js.coffee b/app/assets/javascripts/pages/scenario-form-page.js.coffee
deleted file mode 100644
index 04bdb2fbe6..0000000000
--- a/app/assets/javascripts/pages/scenario-form-page.js.coffee
+++ /dev/null
@@ -1,15 +0,0 @@
-class @ScenarioFormPage
-  constructor:() ->
-    @enabledSelect2()
-
-  format: (icon) ->
-    originalOption = icon.element
-    ' ' + icon.text
-
-  enabledSelect2: () ->
-    $('.select2-fountawesome-icon').select2
-      width: '100%'
-      formatResult: @format
-      
-$ ->
-  Utils.registerPage(ScenarioFormPage, forPathsMatching: /^scenarios/)
\ No newline at end of file
diff --git a/app/assets/javascripts/pages/scenario-show-page.js b/app/assets/javascripts/pages/scenario-show-page.js
new file mode 100644
index 0000000000..e3b858554d
--- /dev/null
+++ b/app/assets/javascripts/pages/scenario-show-page.js
@@ -0,0 +1,24 @@
+this.ScenarioShowPage = class ScenarioShowPage {
+  constructor() {
+    this.changeModalText();
+  }
+
+  changeModalText() {
+    $("#disable-all").click(function () {
+      $("#enable-disable-agents .modal-body").text(
+        "Would you like to disable all agents?"
+      );
+      return $("#scenario-disabled-value").val("true");
+    });
+    return $("#enable-all").click(function () {
+      $("#enable-disable-agents .modal-body").text(
+        "Would you like to enable all agents?"
+      );
+      return $("#scenario-disabled-value").val("false");
+    });
+  }
+};
+
+$(() =>
+  Utils.registerPage(ScenarioShowPage, { forPathsMatching: /^scenarios/ })
+);
diff --git a/app/assets/javascripts/pages/scenario-show-page.js.coffee b/app/assets/javascripts/pages/scenario-show-page.js.coffee
deleted file mode 100644
index 0a41554357..0000000000
--- a/app/assets/javascripts/pages/scenario-show-page.js.coffee
+++ /dev/null
@@ -1,15 +0,0 @@
-class @ScenarioShowPage
-  constructor:() ->
-    @changeModalText()
-
-  changeModalText: () ->
-    $('#disable-all').click ->
-      $('#enable-disable-agents .modal-body').text 'Would you like to disable all agents?'
-      $('#scenario-disabled-value').val 'true'
-    $('#enable-all').click ->
-      $('#enable-disable-agents .modal-body').text 'Would you like to enable all agents?'
-      $('#scenario-disabled-value').val 'false'
-
-$ ->
-  Utils.registerPage(ScenarioShowPage, forPathsMatching: /^scenarios/)
-
diff --git a/app/assets/javascripts/pages/user-credential-page.js b/app/assets/javascripts/pages/user-credential-page.js
new file mode 100644
index 0000000000..c1afba6e63
--- /dev/null
+++ b/app/assets/javascripts/pages/user-credential-page.js
@@ -0,0 +1,33 @@
+this.UserCredentialPage = class UserCredentialPage {
+  constructor() {
+    const editor = ace.edit("ace-credential-value");
+    editor.getSession().setTabSize(2);
+    editor.getSession().setUseSoftTabs(true);
+    editor.getSession().setUseWrapMode(false);
+
+    const setMode = function () {
+      const mode = $("#user_credential_mode").val();
+      if (mode === "java_script") {
+        return editor.getSession().setMode("ace/mode/javascript");
+      } else {
+        return editor.getSession().setMode("ace/mode/text");
+      }
+    };
+
+    setMode();
+    $("#user_credential_mode").on("change", setMode);
+
+    const $textarea = $("#user_credential_credential_value").hide();
+    editor.getSession().setValue($textarea.val());
+
+    $textarea
+      .closest("form")
+      .on("submit", () => $textarea.val(editor.getSession().getValue()));
+  }
+};
+
+$(() =>
+  Utils.registerPage(UserCredentialPage, {
+    forPathsMatching: /^user_credentials\/(\d+|new)/,
+  })
+);
diff --git a/app/assets/javascripts/pages/user-credential-page.js.coffee b/app/assets/javascripts/pages/user-credential-page.js.coffee
deleted file mode 100644
index 733f5568de..0000000000
--- a/app/assets/javascripts/pages/user-credential-page.js.coffee
+++ /dev/null
@@ -1,25 +0,0 @@
-class @UserCredentialPage
-  constructor: ->
-    editor = ace.edit("ace-credential-value")
-    editor.getSession().setTabSize(2)
-    editor.getSession().setUseSoftTabs(true)
-    editor.getSession().setUseWrapMode(false)
-
-    setMode = ->
-      mode = $("#user_credential_mode").val()
-      if mode == 'java_script'
-        editor.getSession().setMode("ace/mode/javascript")
-      else
-        editor.getSession().setMode("ace/mode/text")
-
-    setMode()
-    $("#user_credential_mode").on 'change', setMode
-
-    $textarea = $('#user_credential_credential_value').hide()
-    editor.getSession().setValue($textarea.val())
-
-    $textarea.closest('form').on 'submit', ->
-      $textarea.val(editor.getSession().getValue())
-
-$ ->
-  Utils.registerPage(UserCredentialPage, forPathsMatching: /^user_credentials\/(\d+|new)/)
diff --git a/app/assets/javascripts/tweets.js b/app/assets/javascripts/tweets.js
new file mode 100644
index 0000000000..6e9eabb455
--- /dev/null
+++ b/app/assets/javascripts/tweets.js
@@ -0,0 +1,15 @@
+$(() =>
+  $(".tweet-body").each(function () {
+    return $(this).click(function () {
+      $(this).off("click");
+      return twttr.widgets
+        .createTweet(
+          this.dataset.tweetId,
+          this
+          // conversation: 'none'
+          // cards: 'hidden'
+        )
+        .then((el) => (el.previousSibling.style.display = "none"));
+    });
+  })
+);
diff --git a/app/assets/javascripts/tweets.js.coffee b/app/assets/javascripts/tweets.js.coffee
deleted file mode 100644
index 126d82417f..0000000000
--- a/app/assets/javascripts/tweets.js.coffee
+++ /dev/null
@@ -1,11 +0,0 @@
-$ ->
-  $('.tweet-body').each ->
-    $(this).click ->
-      $(this).off('click')
-      twttr.widgets.createTweet(
-        this.dataset.tweetId
-        this
-        # conversation: 'none'
-        # cards: 'hidden'
-      ).then (el) ->
-        el.previousSibling.style.display = 'none'
diff --git a/app/concerns/dry_runnable.rb b/app/concerns/dry_runnable.rb
index 3e7cf448be..023c1e287b 100644
--- a/app/concerns/dry_runnable.rb
+++ b/app/concerns/dry_runnable.rb
@@ -21,23 +21,25 @@ def dry_run!(event = nil)
 
     begin
       raise "#{short_type} does not support dry-run" unless can_dry_run?
+
       readonly!
       @dry_run_started_at = Time.zone.now
       @dry_run_logger.info('Dry Run started')
       if event
         raise "This agent cannot receive an event!" unless can_receive_events?
+
         receive([event])
       else
         check
       end
       @dry_run_logger.info('Dry Run finished')
-    rescue => e
+    rescue StandardError => e
       @dry_run_logger.info('Dry Run failed')
       error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}"
     end
 
     @dry_run_results.update(
-      memory: memory,
+      memory:,
       log: log.string,
     )
   ensure
@@ -57,35 +59,41 @@ module Wrapper
 
     def logger
       return super unless dry_run?
+
       @dry_run_logger
     end
 
-    def save(options = {})
+    def save(**options)
       return super unless dry_run?
+
       perform_validations(options)
     end
 
-    def save!(options = {})
+    def save!(**options)
       return super unless dry_run?
-      save(options) or raise_record_invalid
+
+      save(**options) or raise_record_invalid
     end
 
     def log(message, options = {})
       return super unless dry_run?
-      case options[:level] || 3
-      when 0..2
-        sev = Logger::DEBUG
-      when 3
-        sev = Logger::INFO
-      else
-        sev = Logger::ERROR
-      end
+
+      sev =
+        case options[:level] || 3
+        when 0..2
+          Logger::DEBUG
+        when 3
+          Logger::INFO
+        else
+          Logger::ERROR
+        end
 
       logger.log(sev, message)
     end
 
     def create_event(event)
       return super unless dry_run?
+
       if can_create_events?
         event = build_event(event)
         @dry_run_results[:events] << event.payload
diff --git a/app/concerns/email_concern.rb b/app/concerns/email_concern.rb
index cc43348a7b..b579925d6c 100644
--- a/app/concerns/email_concern.rb
+++ b/app/concerns/email_concern.rb
@@ -8,12 +8,15 @@ module EmailConcern
   end
 
   def validate_email_options
-    errors.add(:base, "subject and expected_receive_period_in_days are required") unless options['subject'].present? && options['expected_receive_period_in_days'].present?
+    errors.add(
+      :base,
+      "subject and expected_receive_period_in_days are required"
+    ) unless options['subject'].present? && options['expected_receive_period_in_days'].present?
 
     if options['recipients'].present?
       emails = options['recipients']
       emails = [emails] if emails.is_a?(String)
-      unless emails.all? { |email| email =~ Devise.email_regexp || email =~ /\{/ }
+      unless emails.all? { |email| Devise.email_regexp === email || /\{/ === email }
         errors.add(:base, "'when provided, 'recipients' should be an email address or an array of email addresses")
       end
     end
@@ -40,16 +43,16 @@ def present(payload)
     if payload.is_a?(Hash)
       payload = ActiveSupport::HashWithIndifferentAccess.new(payload)
       MAIN_KEYS.each do |key|
-        return { :title => payload[key].to_s, :entries => present_hash(payload, key) } if payload.has_key?(key)
+        return { title: payload[key].to_s, entries: present_hash(payload, key) } if payload.has_key?(key)
       end
 
-      { :title => "Event", :entries => present_hash(payload) }
+      { title: "Event", entries: present_hash(payload) }
     else
-      { :title => payload.to_s, :entries => [] }
+      { title: payload.to_s, entries: [] }
     end
   end
 
   def present_hash(hash, skip_key = nil)
-    hash.to_a.sort_by {|a| a.first.to_s }.map { |k, v| "#{k}: #{v}" unless k.to_s == skip_key.to_s }.compact
+    hash.to_a.sort_by { |a| a.first.to_s }.map { |k, v| "#{k}: #{v}" unless k.to_s == skip_key.to_s }.compact
   end
 end
diff --git a/app/concerns/event_headers_concern.rb b/app/concerns/event_headers_concern.rb
index 78e61ec26f..6bdfc77bac 100644
--- a/app/concerns/event_headers_concern.rb
+++ b/app/concerns/event_headers_concern.rb
@@ -14,11 +14,11 @@ def validate_event_headers_options!
   def event_headers_normalizer
     case interpolated['event_headers_style']
     when nil, '', 'capitalized'
-      ->name { name.gsub(/[^-]+/, &:capitalize) }
+      ->(name) { name.gsub(/[^-]+/, &:capitalize) }
     when 'downcased'
       :downcase.to_proc
-    when 'snakecased', nil
-      ->name { name.tr('A-Z-', 'a-z_') }
+    when 'snakecased'
+      ->(name) { name.tr('A-Z-', 'a-z_') }
     when 'raw'
       :itself.to_proc
     else
diff --git a/app/concerns/liquid_droppable.rb b/app/concerns/liquid_droppable.rb
index bad29253d5..a0f293c253 100644
--- a/app/concerns/liquid_droppable.rb
+++ b/app/concerns/liquid_droppable.rb
@@ -1,11 +1,6 @@
 # frozen_string_literal: true
 
-# Include this mix-in to make a class droppable to Liquid, and adjust
-# its behavior in Liquid by implementing its dedicated Drop class
-# named with a "Drop" suffix.
 module LiquidDroppable
-  extend ActiveSupport::Concern
-
   class Drop < Liquid::Drop
     def initialize(object)
       @object = object
@@ -23,21 +18,9 @@ def each
 
     def as_json
       return {} unless defined?(self.class::METHODS)
-      Hash[self.class::METHODS.map { |m| [m, send(m).as_json]}]
-    end
-  end
 
-  included do
-    const_set :Drop,
-              if Kernel.const_defined?(drop_name = "#{name}Drop")
-                Kernel.const_get(drop_name)
-              else
-                Kernel.const_set(drop_name, Class.new(Drop))
-              end
-  end
-
-  def to_liquid
-    self.class::Drop.new(self)
+      self.class::METHODS.to_h { |m| [m, send(m).as_json] }
+    end
   end
 
   class MatchDataDrop < Drop
diff --git a/app/concerns/twitter_concern.rb b/app/concerns/twitter_concern.rb
index c0c78b4897..54261c6a1f 100644
--- a/app/concerns/twitter_concern.rb
+++ b/app/concerns/twitter_concern.rb
@@ -85,19 +85,10 @@ def format_tweet(tweet)
     text.gsub!(RE_HTML_ENTITIES, HTML_ENTITIES)
     expanded_text.gsub!(RE_HTML_ENTITIES, HTML_ENTITIES)
 
-    if attrs[:text]
-      {
-        **attrs,
-        text: text,
-        expanded_text: expanded_text,
-      }
-    else
-      {
-        **attrs,
-        full_text: text,
-        expanded_text: expanded_text,
-      }
-    end
+    attrs[:text] &&= text
+    attrs[:full_text] &&= text
+
+    attrs.update(expanded_text:)
   end
 
   module_function :format_tweet
diff --git a/app/concerns/web_request_concern.rb b/app/concerns/web_request_concern.rb
index ead57151a2..82f7909bb4 100644
--- a/app/concerns/web_request_concern.rb
+++ b/app/concerns/web_request_concern.rb
@@ -15,11 +15,11 @@ def self.decode(val)
   end
 
   class CharacterEncoding < Faraday::Middleware
-    def initialize(app, force_encoding: nil, default_encoding: nil, unzip: nil)
+    def initialize(app, options = {})
       super(app)
-      @force_encoding   = force_encoding
-      @default_encoding = default_encoding
-      @unzip            = unzip
+      @force_encoding   = options[:force_encoding]
+      @default_encoding = options[:default_encoding]
+      @unzip            = options[:unzip]
     end
 
     def call(env)
@@ -38,8 +38,12 @@ def call(env)
           # Not all Faraday adapters support automatic charset
           # detection, so we do that.
           case env[:response_headers][:content_type]
-          when /;\s*charset\s*=\s*([^()<>@,;:\\\"\/\[\]?={}\s]+)/i
-            encoding = Encoding.find($1) rescue @default_encoding
+          when /;\s*charset\s*=\s*([^()<>@,;:\\"\/\[\]?={}\s]+)/i
+            encoding = begin
+              Encoding.find($1)
+            rescue StandardError
+              @default_encoding
+            end
           when /\A\s*(?:text\/[^\s;]+|application\/(?:[^\s;]+\+)?(?:xml|json))\s*(?:;|\z)/i
             encoding = @default_encoding
           else
@@ -62,11 +66,11 @@ def validate_web_request_options!
     if options['user_agent'].present?
       errors.add(:base, "user_agent must be a string") unless options['user_agent'].is_a?(String)
     end
-    
+
     if options['proxy'].present?
       errors.add(:base, "proxy must be a string") unless options['proxy'].is_a?(String)
     end
-    
+
     if options['disable_ssl_verification'].present? && boolify(options['disable_ssl_verification']).nil?
       errors.add(:base, "if provided, disable_ssl_verification must be true or false")
     end
@@ -112,13 +116,13 @@ def faraday
     @faraday ||= Faraday.new(faraday_options) { |builder|
       builder.response :character_encoding,
                        force_encoding: interpolated['force_encoding'].presence,
-                       default_encoding: default_encoding,
+                       default_encoding:,
                        unzip: interpolated['unzip'].presence
 
       builder.headers = headers if headers.length > 0
 
       builder.headers[:user_agent] = user_agent
-      
+
       builder.proxy interpolated['proxy'].presence
 
       unless boolify(interpolated['disable_redirect_follow'])
@@ -140,8 +144,8 @@ def faraday
       builder.use FaradayMiddleware::Gzip
 
       case backend = faraday_backend
-        when :typhoeus
-          require 'typhoeus/adapters/faraday'
+      when :typhoeus
+        require 'typhoeus/adapters/faraday'
       end
       builder.adapter backend
     }
@@ -153,12 +157,12 @@ def headers(value = interpolated['headers'])
 
   def basic_auth_credentials(value = interpolated['basic_auth'])
     case value
-      when nil, ''
-        return nil
-      when Array
-        return value if value.size == 2
-      when /:/
-        return value.split(/:/, 2)
+    when nil, ''
+      return nil
+    when Array
+      return value if value.size == 2
+    when /:/
+      return value.split(/:/, 2)
     end
     raise ArgumentError.new("bad value for basic_auth: #{value.inspect}")
   end
diff --git a/app/controllers/agents/dry_runs_controller.rb b/app/controllers/agents/dry_runs_controller.rb
index 7dfde8aa7c..8905f07aed 100644
--- a/app/controllers/agents/dry_runs_controller.rb
+++ b/app/controllers/agents/dry_runs_controller.rb
@@ -7,7 +7,7 @@ def index
                   current_user.agents.find_by(id: params[:agent_id]).received_events.limit(5)
                 elsif params[:source_ids]
                   Event.where(agent_id: current_user.agents.where(id: params[:source_ids]).pluck(:id))
-                       .order("id DESC").limit(5)
+                    .order("id DESC").limit(5)
                 else
                   []
                 end
@@ -40,11 +40,14 @@ def create
 
         @results = agent.dry_run!(event)
       else
-        @results = { events: [], memory: [],
-                     log:  [
-                       "#{pluralize(agent.errors.count, "error")} prohibited this Agent from being saved:",
-                       *agent.errors.full_messages
-                     ].join("\n- ") }
+        @results = {
+          events: [],
+          memory: [],
+          log: [
+            "#{pluralize(agent.errors.count, "error")} prohibited this Agent from being saved:",
+            *agent.errors.full_messages
+          ].join("\n- ")
+        }
       end
 
       render layout: false
diff --git a/app/helpers/dot_helper.rb b/app/helpers/dot_helper.rb
index 1a32eaf006..807ffb3ed1 100644
--- a/app/helpers/dot_helper.rb
+++ b/app/helpers/dot_helper.rb
@@ -1,6 +1,6 @@
 module DotHelper
   def render_agents_diagram(agents, layout: nil)
-    if svg = dot_to_svg(agents_dot(agents, rich: true, layout: layout))
+    if svg = dot_to_svg(agents_dot(agents, rich: true, layout:))
       decorate_svg(svg, agents).html_safe
     else
       # Google chart request url
@@ -141,7 +141,7 @@ def draw(vars = {}, &block)
   end
 
   def agents_dot(agents, rich: false, layout: nil)
-    draw(agents: agents,
+    draw(agents:,
          agent_id: ->(agent) { 'a%d' % agent.id },
          agent_label: ->(agent) {
            agent.name.gsub(/(.{20}\S*)\s+/) {
@@ -150,7 +150,7 @@ def agents_dot(agents, rich: false, layout: nil)
            }
          },
          agent_url: ->(agent) { agent_path(agent.id) },
-         rich: rich) {
+         rich:) {
       @disabled = '#999999'
 
       def agent_node(agent)
@@ -175,7 +175,7 @@ def agent_edge(agent, receiver)
       block('digraph', 'Agent Event Flow') {
         layout ||= ENV['DIAGRAM_DEFAULT_LAYOUT'].presence
         if rich && /\A[a-z]+\z/ === layout
-          statement 'graph', layout: layout, overlap: 'false'
+          statement 'graph', layout:, overlap: 'false'
         end
         statement 'node',
                   shape: 'box',
@@ -253,7 +253,7 @@ def decorate_svg(xml, agents)
           }
         }
       }
-      # See also: app/assets/diagram.js.coffee
+      # See also: app/assets/diagram.js
     }.at('div.agent-diagram').to_s
   end
 end
diff --git a/app/helpers/jobs_helper.rb b/app/helpers/jobs_helper.rb
index 305e38f172..8a23c59691 100644
--- a/app/helpers/jobs_helper.rb
+++ b/app/helpers/jobs_helper.rb
@@ -1,5 +1,4 @@
 module JobsHelper
-
   def status(job)
     case
     when job.failed_at
@@ -24,7 +23,7 @@ def relative_distance_of_time_in_words(time)
   #
   # Can return nil, or an instance of Agent.
   def agent_from_job(job)
-    data = YAML.load(job.handler.to_s).try(:job_data)
+    data = YAML.unsafe_load(job.handler.to_s).try(:job_data)
     case data['job_class']
     when 'AgentCheckJob', 'AgentReceiveJob'
       Agent.find_by_id(data['arguments'][0])
diff --git a/app/helpers/scenario_helper.rb b/app/helpers/scenario_helper.rb
index e71fba96d0..393c524ffd 100644
--- a/app/helpers/scenario_helper.rb
+++ b/app/helpers/scenario_helper.rb
@@ -1,7 +1,6 @@
 module ScenarioHelper
-
   def style_colors(scenario)
-    colors = {
+    {
       color: scenario.tag_fg_color || default_scenario_fg_color,
       background_color: scenario.tag_bg_color || default_scenario_bg_color
     }.map { |key, value| "#{key.to_s.dasherize}:#{value}" }.join(';')
@@ -19,5 +18,4 @@ def default_scenario_bg_color
   def default_scenario_fg_color
     '#FFFFFF'
   end
-
 end
diff --git a/app/models/agent.rb b/app/models/agent.rb
index fb83581d4b..d39062fe3a 100644
--- a/app/models/agent.rb
+++ b/app/models/agent.rb
@@ -11,7 +11,6 @@ class Agent < ActiveRecord::Base
   include WorkingHelpers
   include LiquidInterpolatable
   include HasGuid
-  include LiquidDroppable
   include DryRunnable
   include SortableEvents
 
@@ -325,7 +324,7 @@ def build_clone(original)
         # Give it a unique name
         2.step do |i|
           name = '%s (%d)' % [original.name, i]
-          unless exists?(name: name)
+          unless exists?(name:)
             clone.name = name
             break
           end
@@ -454,7 +453,7 @@ def async_receive(agent_id, event_ids)
     def run_schedule(schedule)
       return if schedule == 'never'
 
-      types = where(schedule: schedule).group(:type).pluck(:type)
+      types = where(schedule:).group(:type).pluck(:type)
       types.each do |type|
         next unless valid_type?(type)
 
@@ -478,40 +477,47 @@ def async_check(agent_id)
       AgentCheckJob.perform_later(agent_id)
     end
   end
-end
 
-class AgentDrop
-  def type
-    @object.short_type
-  end
-
-  METHODS = %i[
-    id
-    name
-    type
-    options
-    memory
-    sources
-    receivers
-    schedule
-    controllers
-    control_targets
-    disabled
-    keep_events_for
-    propagate_immediately
-  ]
+  public def to_liquid
+    Drop.new(self)
+  end
 
-  METHODS.each { |attr|
-    define_method(attr) {
-      @object.__send__(attr)
-    } unless method_defined?(attr)
-  }
+  class Drop < LiquidDroppable::Drop
+    def type
+      @object.short_type
+    end
 
-  def working
-    @object.working?
-  end
+    METHODS = %i[
+      id
+      name
+      type
+      options
+      memory
+      sources
+      receivers
+      schedule
+      controllers
+      control_targets
+      disabled
+      keep_events_for
+      propagate_immediately
+    ]
+
+    METHODS.each { |attr|
+      define_method(attr) {
+        @object.__send__(attr)
+      } unless method_defined?(attr)
+    }
+
+    def working
+      @object.working?
+    end
 
-  def url
-    Rails.application.routes.url_helpers.agent_url(@object, Rails.application.config.action_mailer.default_url_options)
+    def url
+      Rails.application.routes.url_helpers.agent_url(
+        @object,
+        Rails.application.config.action_mailer.default_url_options
+      )
+    end
   end
 end
diff --git a/app/models/agent_log.rb b/app/models/agent_log.rb
index 026327d714..ffb2e3a2a6 100644
--- a/app/models/agent_log.rb
+++ b/app/models/agent_log.rb
@@ -3,11 +3,11 @@
 # Agents' `last_error_log_at` column.  These are often used to determine if an Agent is `working?`.
 class AgentLog < ActiveRecord::Base
   belongs_to :agent
-  belongs_to :inbound_event, :class_name => "Event", optional: true
-  belongs_to :outbound_event, :class_name => "Event", optional: true
+  belongs_to :inbound_event, class_name: "Event", optional: true
+  belongs_to :outbound_event, class_name: "Event", optional: true
 
   validates_presence_of :message
-  validates_numericality_of :level, :only_integer => true, :greater_than_or_equal_to => 0, :less_than => 5
+  validates_numericality_of :level, only_integer: true, greater_than_or_equal_to: 0, less_than: 5
 
   before_validation :scrub_message
   before_save :truncate_message
@@ -15,7 +15,7 @@ class AgentLog < ActiveRecord::Base
   def self.log_for_agent(agent, message, options = {})
     puts "Agent##{agent.id}: #{message}" unless Rails.env.test?
 
-    log = agent.logs.create! options.merge(:message => message)
+    log = agent.logs.create! options.merge(message:)
     if agent.logs.count > log_length
       oldest_id_to_keep = agent.logs.limit(1).offset(log_length - 1).pluck("agent_logs.id")
       agent.logs.where("agent_logs.id < ?", oldest_id_to_keep).delete_all
@@ -35,7 +35,7 @@ def self.log_length
   def scrub_message
     if message_changed? && !message.nil?
       self.message = message.inspect unless message.is_a?(String)
-      self.message.scrub!{ |bytes| "<#{bytes.unpack('H*')[0]}>" }
+      self.message.scrub! { |bytes| "<#{bytes.unpack1('H*')}>" }
     end
     true
   end
diff --git a/app/models/agents/adioso_agent.rb b/app/models/agents/adioso_agent.rb
index 3c4d47eda4..4459fe1478 100644
--- a/app/models/agents/adioso_agent.rb
+++ b/app/models/agents/adioso_agent.rb
@@ -2,26 +2,25 @@ module Agents
   class AdiosoAgent < Agent
     cannot_receive_events!
 
-  	default_schedule "every_1d"
+    default_schedule "every_1d"
 
-    description <<-MD
-  		The Adioso Agent will tell you the minimum airline prices between a pair of cities, and within a certain period of time.
+    description <<~MD
+      The Adioso Agent will tell you the minimum airline prices between a pair of cities, and within a certain period of time.
 
-      The currency is USD. Please make sure that the difference between `start_date` and `end_date` is less than 150 days. You will need to contact [Adioso](http://adioso.com/)
-  		for a `username` and `password`.
+      The currency is USD. Please make sure that the difference between `start_date` and `end_date` is less than 150 days. You will need to contact [Adioso](http://adioso.com/) for a `username` and `password`.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       If flights are present then events look like:
 
           {
             "cost": 75.23,
             "date": "June 25, 2013",
-  			    "route": "New York to Chicago"
+            "route": "New York to Chicago"
           }
 
       otherwise
-    
+
           {
             "nonetodest": "No flights found to the specified destination"
           }
@@ -30,12 +29,12 @@ class AdiosoAgent < Agent
     def default_options
       {
         'start_date' => Date.today.httpdate[0..15],
-        'end_date'   => Date.today.plus_with_duration(100).httpdate[0..15],
-        'from'       => "New York",
-        'to'         => "Chicago",
-        'username'   => "xx",
-        'password'   => "xx",
-				'expected_update_period_in_days' => "1"
+        'end_date' => Date.today.plus_with_duration(100).httpdate[0..15],
+        'from' => "New York",
+        'to' => "Chicago",
+        'username' => "xx",
+        'password' => "xx",
+        'expected_update_period_in_days' => "1"
       }
     end
 
@@ -44,10 +43,12 @@ def working?
     end
 
     def validate_options
-			unless %w[start_date end_date from to username password expected_update_period_in_days].all? { |field| options[field].present? }
-				errors.add(:base, "All fields are required")
-			end
-		end
+      unless %w[
+        start_date end_date from to username password expected_update_period_in_days
+      ].all? { |field| options[field].present? }
+        errors.add(:base, "All fields are required")
+      end
+    end
 
     def date_to_unix_epoch(date)
       date.to_time.to_i
@@ -60,19 +61,24 @@ def check
           password: interpolated[:password]
         }
       }
-      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?#{{ q: "#{interpolated[:from]} to #{interpolated[:to]}" }.to_query}", auth_options
-      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(interpolated['end_date'])}\\3#{date_to_unix_epoch(interpolated['start_date'])}"
+      parse_response = HTTParty.get(
+        "http://api.adioso.com/v2/search/parse?#{{ q: "#{interpolated[:from]} to #{interpolated[:to]}" }.to_query}",
+        auth_options
+      )
+      fare_request = parse_response["search_url"].gsub(
+        /(end=)(\d*)([^\d]*)(\d*)/,
+        "\\1#{date_to_unix_epoch(interpolated['end_date'])}\\3#{date_to_unix_epoch(interpolated['start_date'])}"
+      )
       fare = HTTParty.get fare_request, auth_options
 
-			if fare["warnings"]
-				create_event :payload => fare["warnings"]
-			else
-				event = fare["results"].min {|a,b| a["cost"] <=> b["cost"]}
-				event["date"]  = Time.at(event["date"]).to_date.httpdate[0..15]
-				event["route"] = "#{interpolated['from']} to #{interpolated['to']}"
-				create_event :payload => event
-			end
+      if fare["warnings"]
+        create_event payload: fare["warnings"]
+      else
+        event = fare["results"].min_by { |x| x["cost"] }
+        event["date"]  = Time.at(event["date"]).to_date.httpdate[0..15]
+        event["route"] = "#{interpolated['from']} to #{interpolated['to']}"
+        create_event payload: event
+      end
     end
   end
 end
-
diff --git a/app/models/agents/aftership_agent.rb b/app/models/agents/aftership_agent.rb
index 14e08a00a7..62185acbef 100644
--- a/app/models/agents/aftership_agent.rb
+++ b/app/models/agents/aftership_agent.rb
@@ -2,21 +2,20 @@
 
 module Agents
   class AftershipAgent < Agent
-
     cannot_receive_events!
 
     default_schedule "every_10m"
 
-    description <<-MD
+    description <<~MD
       The Aftership agent allows you to track your shipment from aftership and emit them into events.
 
       To be able to use the Aftership API, you need to generate an `API Key`. You need a paying plan to use their tracking feature.
 
       You can use this agent to retrieve tracking data.
- 
-      Provide the `path` for the API endpoint that you'd like to hit. For example, for all active packages, enter `trackings` 
-      (see https://www.aftership.com/docs/api/4/trackings), for a specific package, use `trackings/SLUG/TRACKING_NUMBER` 
-      and replace `SLUG` with a courier code and `TRACKING_NUMBER` with the tracking number. You can request last checkpoint of a package 
+
+      Provide the `path` for the API endpoint that you'd like to hit. For example, for all active packages, enter `trackings`
+      (see https://www.aftership.com/docs/api/4/trackings), for a specific package, use `trackings/SLUG/TRACKING_NUMBER`
+      and replace `SLUG` with a courier code and `TRACKING_NUMBER` with the tracking number. You can request last checkpoint of a package
       by providing `last_checkpoint/SLUG/TRACKING_NUMBER` instead.
 
       You can get a list of courier information here `https://www.aftership.com/courier`
@@ -27,7 +26,7 @@ class AftershipAgent < Agent
       * `path request and its full path`
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       A typical tracking event have 2 important objects (tracking, and checkpoint) and the tracking/checkpoint looks like this.
 
           "trackings": [
@@ -87,8 +86,9 @@ class AftershipAgent < Agent
     MD
 
     def default_options
-      { 'api_key' => 'YOUR_API_KEY',
-        'path' => 'trackings'
+      {
+        'api_key' => 'YOUR_API_KEY',
+        'path' => 'trackings',
       }
     end
 
@@ -104,10 +104,11 @@ def validate_options
     def check
       response = HTTParty.get(event_url, request_options)
       events = JSON.parse response.body
-      create_event :payload => events
+      create_event payload: events
     end
 
-  private
+    private
+
     def base_url
       "https://api.aftership.com/v4/"
     end
@@ -117,7 +118,12 @@ def event_url
     end
 
     def request_options
-      {:headers => {"aftership-api-key" => interpolated['api_key'], "Content-Type"=>"application/json"} }
+      {
+        headers: {
+          "aftership-api-key" => interpolated['api_key'],
+          "Content-Type" => "application/json",
+        }
+      }
     end
   end
 end
diff --git a/app/models/agents/attribute_difference_agent.rb b/app/models/agents/attribute_difference_agent.rb
index c75b927796..ab69e8d115 100644
--- a/app/models/agents/attribute_difference_agent.rb
+++ b/app/models/agents/attribute_difference_agent.rb
@@ -2,7 +2,7 @@ module Agents
   class AttributeDifferenceAgent < Agent
     cannot_be_scheduled!
 
-    description <<-MD
+    description <<~MD
       The Attribute Difference Agent receives events and emits a new event with
       the difference or change of a specific attribute in comparison to the previous
       event received.
@@ -30,7 +30,7 @@ class AttributeDifferenceAgent < Agent
       All configuration options will be liquid interpolated based on the incoming event.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       This will change based on the source event.
     MD
 
@@ -80,23 +80,26 @@ def handle(opts, event)
         payload[opts['output']] = difference
       end
 
-      created_event = create_event(payload: payload)
+      created_event = create_event(payload:)
       log('Propagating new event', outbound_event: created_event, inbound_event: event)
       update_memory(attribute_value)
     end
 
     def calculate_integer_difference(new_value)
       return 0 if last_value.nil?
+
       (new_value.to_i - last_value.to_i)
     end
 
     def calculate_decimal_difference(new_value, dec_pre)
       return 0.0 if last_value.nil?
+
       (new_value.to_f - last_value.to_f).round(dec_pre.to_i)
     end
 
     def calculate_percentage_change(new_value, dec_pre)
       return 0.0 if last_value.nil?
+
       (((new_value.to_f / last_value.to_f) * 100) - 100).round(dec_pre.to_i)
     end
 
diff --git a/app/models/agents/change_detector_agent.rb b/app/models/agents/change_detector_agent.rb
index 0c8c789ea4..a07c2fceb5 100644
--- a/app/models/agents/change_detector_agent.rb
+++ b/app/models/agents/change_detector_agent.rb
@@ -2,7 +2,7 @@ module Agents
   class ChangeDetectorAgent < Agent
     cannot_be_scheduled!
 
-    description <<-MD
+    description <<~MD
       The Change Detector Agent receives a stream of events and emits a new event when a property of the received event changes.
 
       `property` specifies a [Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) template that expands to the property to be watched, where you can use a variable `last_property` for the last property value.  If you want to detect a new lowest price, try this: `{% assign drop = last_property | minus: price %}{% if last_property == blank or drop > 0 %}{{ price | default: last_property }}{% else %}{{ last_property }}{% endif %}`
@@ -12,22 +12,22 @@ class ChangeDetectorAgent < Agent
       The resulting event will be a copy of the received event.
     MD
 
-    event_description <<-MD
-    This will change based on the source event. If you were event from the ShellCommandAgent, your outbound event might look like:
+    event_description <<~MD
+      This will change based on the source event. If you were event from the ShellCommandAgent, your outbound event might look like:
 
-      {
-        'command' => 'pwd',
-        'path' => '/home/Huginn',
-        'exit_status' => '0',
-        'errors' => '',
-        'output' => '/home/Huginn'
-      }
+          {
+            'command' => 'pwd',
+            'path' => '/home/Huginn',
+            'exit_status' => '0',
+            'errors' => '',
+            'output' => '/home/Huginn'
+          }
     MD
 
     def default_options
       {
-          'property' => '{{output}}',
-          'expected_update_period_in_days' => 1
+        'property' => '{{output}}',
+        'expected_update_period_in_days' => 1
       }
     end
 
@@ -55,12 +55,13 @@ def receive(incoming_events)
     def handle(opts, event = nil)
       property = opts['property']
       if has_changed?(property)
-        created_event = create_event :payload => event.payload
+        created_event = create_event payload: event.payload
 
-        log("Propagating new event as property has changed to #{property} from #{last_property}", :outbound_event => created_event, :inbound_event => event )
+        log("Propagating new event as property has changed to #{property} from #{last_property}",
+            outbound_event: created_event, inbound_event: event)
         update_memory(property)
       else
-        log("Not propagating as incoming event has not changed from #{last_property}.", :inbound_event => event )
+        log("Not propagating as incoming event has not changed from #{last_property}.", inbound_event: event)
       end
     end
 
diff --git a/app/models/agents/commander_agent.rb b/app/models/agents/commander_agent.rb
index ac2eca932b..aae045fc8a 100644
--- a/app/models/agents/commander_agent.rb
+++ b/app/models/agents/commander_agent.rb
@@ -4,7 +4,7 @@ class CommanderAgent < Agent
 
     cannot_create_events!
 
-    description <<-MD
+    description <<~MD
       The Commander Agent is triggered by schedule or an incoming event, and commands other agents ("targets") to run, disable, configure, or enable themselves.
 
       # Action types
@@ -28,7 +28,7 @@ class CommanderAgent < Agent
 
       - If you want to update a WeatherAgent based on a UserLocationAgent, you could use `'action': 'configure'` and set 'configure_options' to `{ 'location': '{{_location_.latlng}}' }`.
 
-      - In templating, you can use the variable `target` to refer to each target agent, which has the following attributes: #{AgentDrop.instance_methods(false).map { |m| "`#{m}`" }.to_sentence}.
+      - In templating, you can use the variable `target` to refer to each target agent, which has the following attributes: #{Agent::Drop.instance_methods(false).map { |m| "`#{m}`" }.to_sentence}.
 
       # Targets
 
diff --git a/app/models/agents/csv_agent.rb b/app/models/agents/csv_agent.rb
index 50e1e9f829..051d3b9c0e 100644
--- a/app/models/agents/csv_agent.rb
+++ b/app/models/agents/csv_agent.rb
@@ -19,7 +19,7 @@ def default_options
     end
 
     description do
-      <<-MD
+      <<~MD
         The `CsvAgent` parses or serializes CSV data. When parsing, events can either be emitted for the entire CSV, or one per row.
 
         Set `mode` to `parse` to parse CSV from incoming event, when set to `serialize` the agent serilizes the data of events to CSV.
@@ -53,28 +53,44 @@ def default_options
     end
 
     event_description do
-      "Events will looks like this:\n\n    %s" % if interpolated['mode'] == 'parse'
-        rows = if boolify(interpolated['with_header'])
-          [{'column' => 'row1 value1', 'column2' => 'row1 value2'}, {'column' => 'row2 value3', 'column2' => 'row2 value4'}]
-        else
-          [['row1 value1', 'row1 value2'], ['row2 value1', 'row2 value2']]
-        end
-        if interpolated['output'] == 'event_per_row'
-          Utils.pretty_print(interpolated['data_key'] => rows[0])
+      data =
+        if interpolated['mode'] == 'parse'
+          rows =
+            if boolify(interpolated['with_header'])
+              [
+                { 'column' => 'row1 value1', 'column2' => 'row1 value2' },
+                { 'column' => 'row2 value3', 'column2' => 'row2 value4' },
+              ]
+            else
+              [
+                ['row1 value1', 'row1 value2'],
+                ['row2 value1', 'row2 value2'],
+              ]
+            end
+          if interpolated['output'] == 'event_per_row'
+            rows[0]
+          else
+            rows
+          end
         else
-          Utils.pretty_print(interpolated['data_key'] => rows)
+          <<~EOS
+            "generated","csv","data"
+            "column1","column2","column3"
+          EOS
         end
-      else
-        Utils.pretty_print(interpolated['data_key'] => '"generated","csv","data"' + "\n" + '"column1","column2","column3"')
-      end
+
+      "Events will looks like this:\n\n    " +
+        Utils.pretty_print({
+          interpolated['data_key'] => data
+        })
     end
 
-    form_configurable :mode, type: :array, values: %w(parse serialize)
+    form_configurable :mode, type: :array, values: %w[parse serialize]
     form_configurable :separator, type: :string
     form_configurable :data_key, type: :string
     form_configurable :with_header, type: :boolean
     form_configurable :use_fields, type: :string
-    form_configurable :output, type: :array, values: %w(event_per_row event_per_file)
+    form_configurable :output, type: :array, values: %w[event_per_row event_per_file]
     form_configurable :data_path, type: :string
 
     def validate_options
@@ -100,27 +116,28 @@ def receive(incoming_events)
     end
 
     private
+
     def serialize(incoming_events)
       mo = interpolated(incoming_events.first)
       rows = rows_from_events(incoming_events, mo)
-      csv = CSV.generate(col_sep: separator(mo), force_quotes: true ) do |csv|
+      csv = CSV.generate(col_sep: separator(mo), force_quotes: true) do |csv|
         if boolify(mo['with_header']) && rows.first.is_a?(Hash)
-          if mo['use_fields'].present?
-            csv << extract_options(mo)
-          else
-            csv << rows.first.keys
-          end
+          csv << if mo['use_fields'].present?
+                   extract_options(mo)
+                 else
+                   rows.first.keys
+                 end
         end
         rows.each do |data|
-          if data.is_a?(Hash)
-            if mo['use_fields'].present?
-              csv << data.extract!(*extract_options(mo)).values
-            else
-              csv << data.values
-            end
-          else
-            csv << data
-          end
+          csv << if data.is_a?(Hash)
+                   if mo['use_fields'].present?
+                     data.extract!(*extract_options(mo)).values
+                   else
+                     data.values
+                   end
+                 else
+                   data
+                 end
         end
       end
       create_event payload: { mo['data_key'] => csv }
@@ -143,6 +160,7 @@ def parse(incoming_events)
       incoming_events.each do |event|
         mo = interpolated(event)
         next unless io = local_get_io(event)
+
         if mo['output'] == 'event_per_row'
           parse_csv(io, mo) do |payload|
             create_event payload: { mo['data_key'] => payload }
diff --git a/app/models/agents/data_output_agent.rb b/app/models/agents/data_output_agent.rb
index f027432002..405e28a34b 100644
--- a/app/models/agents/data_output_agent.rb
+++ b/app/models/agents/data_output_agent.rb
@@ -5,13 +5,13 @@ class DataOutputAgent < Agent
     cannot_be_scheduled!
     cannot_create_events!
 
-    description  do
-      <<-MD
+    description do
+      <<~MD
         The Data Output Agent outputs received events as either RSS or JSON.  Use it to output a public or private stream of Huginn data.
 
         This Agent will output data at:
 
-        `https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id: user_id, secret: ':secret', format: :xml)}`
+        `https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id:, secret: ':secret', format: :xml)}`
 
         where `:secret` is one of the allowed secrets specified in your options and the extension can be `xml` or `json`.
 
@@ -104,7 +104,8 @@ def validate_options
       end
 
       unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
-        errors.add(:base, "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
+        errors.add(:base,
+                   "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
       end
 
       unless options['template'].present? && options['template']['item'].present? && options['template']['item'].is_a?(Hash)
@@ -153,11 +154,12 @@ def feed_link
 
     def feed_url(options = {})
       interpolated['template']['self'].presence ||
-        feed_link + Rails.application.routes.url_helpers.
-                    web_requests_path(agent_id: id || ':id',
-                                      user_id: user_id,
-                                      secret: options[:secret],
-                                      format: options[:format])
+        feed_link + Rails.application.routes.url_helpers.web_requests_path(
+          agent_id: id || ':id',
+          user_id:,
+          secret: options[:secret],
+          format: options[:format]
+        )
     end
 
     def feed_icon
@@ -165,9 +167,9 @@ def feed_icon
     end
 
     def itunes_icon
-      if(boolify(interpolated['ns_itunes']))
+      if boolify(interpolated['ns_itunes'])
         ""
-      end  
+      end
     end
 
     def feed_description
@@ -181,13 +183,13 @@ def rss_content_type
     def xml_namespace
       namespaces = ['xmlns:atom="http://www.w3.org/2005/Atom"']
 
-      if (boolify(interpolated['ns_dc']))
+      if boolify(interpolated['ns_dc'])
         namespaces << 'xmlns:dc="http://purl.org/dc/elements/1.1/"'
       end
-      if (boolify(interpolated['ns_media']))
+      if boolify(interpolated['ns_media'])
         namespaces << 'xmlns:media="http://search.yahoo.com/mrss/"'
       end
-      if (boolify(interpolated['ns_itunes']))
+      if boolify(interpolated['ns_itunes'])
         namespaces << 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"'
       end
       namespaces.join(' ')
@@ -211,8 +213,8 @@ def latest_events(reload = false)
 
       events =
         if (event_ids = memory[:event_ids]) &&
-           memory[:events_order] == events_order &&
-           memory[:events_to_show] >= events_to_show
+            memory[:events_order] == events_order &&
+            memory[:events_to_show] >= events_to_show
           received_events.where(id: event_ids).to_a
         else
           memory[:last_event_id] = nil
@@ -231,8 +233,8 @@ def latest_events(reload = false)
             source_ids.flat_map { |source_id|
               # dig twice as many events as the number of
               # `events_to_show`
-              received_events.where(agent_id: source_id).
-                last(2 * events_to_show)
+              received_events.where(agent_id: source_id)
+                .last(2 * events_to_show)
             }.sort_by(&:id)
           end
 
@@ -260,18 +262,20 @@ def receive_web_request(params, method, format)
         end
       end
 
-      source_events = sort_events(latest_events(), 'events_list_order')
+      source_events = sort_events(latest_events, 'events_list_order')
 
       interpolate_with('events' => source_events) do
         items = source_events.map do |event|
           interpolated = interpolate_options(options['template']['item'], event)
-          interpolated['guid'] = {'_attributes' => {'isPermaLink' => 'false'},
-                                  '_contents' => interpolated['guid'].presence || event.id}
+          interpolated['guid'] = {
+            '_attributes' => { 'isPermaLink' => 'false' },
+            '_contents' => interpolated['guid'].presence || event.id
+          }
           date_string = interpolated['pubDate'].to_s
           date =
             begin
-              Time.zone.parse(date_string)  # may return nil
-            rescue => e
+              Time.zone.parse(date_string) # may return nil
+            rescue StandardError => e
               error "Error parsing a \"pubDate\" value \"#{date_string}\": #{e.message}"
               nil
             end || event.created_at
@@ -299,23 +303,23 @@ def receive_web_request(params, method, format)
 
           items = items_to_xml(items)
 
-          return [<<-XML, 200, rss_content_type, interpolated['response_headers'].presence]
-
-
-
- 
- #{feed_icon.encode(xml: :text)}
- #{itunes_icon}
-#{hub_links}
- #{feed_title.encode(xml: :text)}
- #{feed_description.encode(xml: :text)}
- #{feed_link.encode(xml: :text)}
- #{now.rfc2822.to_s.encode(xml: :text)}
- #{now.rfc2822.to_s.encode(xml: :text)}
- #{feed_ttl}
-#{items}
-
-
+          return [<<~XML, 200, rss_content_type, interpolated['response_headers'].presence]
+            
+            
+            
+             
+             #{feed_icon.encode(xml: :text)}
+             #{itunes_icon}
+            #{hub_links}
+             #{feed_title.encode(xml: :text)}
+             #{feed_description.encode(xml: :text)}
+             #{feed_link.encode(xml: :text)}
+             #{now.rfc2822.to_s.encode(xml: :text)}
+             #{now.rfc2822.to_s.encode(xml: :text)}
+             #{feed_ttl}
+            #{items}
+            
+            
           XML
         end
       end
@@ -336,13 +340,17 @@ def receive(incoming_events)
 
     class XMLNode
       def initialize(tag_name, attributes, contents)
-        @tag_name, @attributes, @contents = tag_name, attributes, contents
+        @tag_name = tag_name
+        @attributes = attributes
+        @contents = contents
       end
 
       def to_xml(options)
         if @contents.is_a?(Hash)
           options[:builder].tag! @tag_name, @attributes do
-            @contents.each { |key, value| ActiveSupport::XmlMini.to_tag(key, value, options.merge(skip_instruct: true)) }
+            @contents.each { |key, value|
+              ActiveSupport::XmlMini.to_tag(key, value, options.merge(skip_instruct: true))
+            }
           end
         else
           options[:builder].tag! @tag_name, @attributes, @contents
@@ -353,15 +361,16 @@ def to_xml(options)
     def simplify_item_for_xml(item)
       if item.is_a?(Hash)
         item.each.with_object({}) do |(key, value), memo|
-          if value.is_a?(Hash)
-            if value.key?('_attributes') || value.key?('_contents')
-              memo[key] = XMLNode.new(key, value['_attributes'], simplify_item_for_xml(value['_contents']))
+          memo[key] =
+            if value.is_a?(Hash)
+              if value.key?('_attributes') || value.key?('_contents')
+                XMLNode.new(key, value['_attributes'], simplify_item_for_xml(value['_contents']))
+              else
+                simplify_item_for_xml(value)
+              end
             else
-              memo[key] = simplify_item_for_xml(value)
+              value
             end
-          else
-            memo[key] = value
-          end
         end
       elsif item.is_a?(Array)
         item.map { |value| simplify_item_for_xml(value) }
@@ -375,13 +384,14 @@ def simplify_item_for_json(item)
         item.each.with_object({}) do |(key, value), memo|
           if value.is_a?(Hash)
             if value.key?('_attributes') || value.key?('_contents')
-              contents = if value['_contents'] && value['_contents'].is_a?(Hash)
-                           simplify_item_for_json(value['_contents'])
-                         elsif value['_contents']
-                           { "contents" => value['_contents'] }
-                         else
-                           {}
-                         end
+              contents =
+                if value['_contents'] && value['_contents'].is_a?(Hash)
+                  simplify_item_for_json(value['_contents'])
+                elsif value['_contents']
+                  { "contents" => value['_contents'] }
+                else
+                  {}
+                end
 
               memo[key] = contents.merge(value['_attributes'] || {})
             else
@@ -436,8 +446,8 @@ def push_to_hub(hub, url)
           'hub.mode' => 'publish',
           'hub.url' => url
         }
-     rescue => e
-       error "Push failed: #{e.message}"
+      rescue StandardError => e
+        error "Push failed: #{e.message}"
       end
     end
   end
diff --git a/app/models/agents/de_duplication_agent.rb b/app/models/agents/de_duplication_agent.rb
index 463bd54853..fdda960f84 100644
--- a/app/models/agents/de_duplication_agent.rb
+++ b/app/models/agents/de_duplication_agent.rb
@@ -3,7 +3,7 @@ class DeDuplicationAgent < Agent
     include FormConfigurable
     cannot_be_scheduled!
 
-    description <<-MD
+    description <<~MD
       The De-duplication Agent receives a stream of events and remits the event if it is not a duplicate.
 
       `property` the value that should be used to determine the uniqueness of the event (empty to use the whole payload)
@@ -13,7 +13,7 @@ class DeDuplicationAgent < Agent
       `expected_update_period_in_days` is used to determine if the Agent is working.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       The DeDuplicationAgent just reemits events it received.
     MD
 
@@ -56,18 +56,22 @@ def receive(incoming_events)
     def handle(opts, event = nil)
       property = get_hash(options['property'].blank? ? JSON.dump(event.payload) : opts['property'])
       if is_unique?(property)
-        created_event = create_event :payload => event.payload
+        outbound_event = create_event payload: event.payload
 
-        log("Propagating new event as '#{property}' is a new unique property.", :inbound_event => event )
+        log(
+          "Propagating new event as '#{property}' is a new unique property.",
+          inbound_event: event,
+          outbound_event:
+        )
         update_memory(property, opts['lookback'].to_i)
       else
-        log("Not propagating as incoming event is a duplicate.", :inbound_event => event )
+        log("Not propagating as incoming event is a duplicate.", inbound_event: event)
       end
     end
 
     def get_hash(property)
       if property.to_s.length > 10
-        Zlib::crc32(property).to_s
+        Zlib.crc32(property).to_s
       else
         property
       end
diff --git a/app/models/agents/delay_agent.rb b/app/models/agents/delay_agent.rb
index 9d01cd3033..ffd9543dae 100644
--- a/app/models/agents/delay_agent.rb
+++ b/app/models/agents/delay_agent.rb
@@ -4,7 +4,7 @@ class DelayAgent < Agent
 
     default_schedule 'every_12h'
 
-    description <<-MD
+    description <<~MD
       The DelayAgent stores received Events and emits copies of them on a schedule. Use this as a buffer or queue of Events.
 
       `max_events` should be set to the maximum number of events that you'd like to hold in the buffer. When this number is
@@ -33,7 +33,8 @@ def default_options
 
     def validate_options
       unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
-        errors.add(:base, "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
+        errors.add(:base,
+                   "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
       end
 
       unless options['keep'].present? && options['keep'].in?(%w[newest oldest])
diff --git a/app/models/agents/digest_agent.rb b/app/models/agents/digest_agent.rb
index f49e6f19f9..2f255b129e 100644
--- a/app/models/agents/digest_agent.rb
+++ b/app/models/agents/digest_agent.rb
@@ -4,7 +4,7 @@ class DigestAgent < Agent
 
     default_schedule "6am"
 
-    description <<-MD
+    description <<~MD
       The Digest Agent collects any Events sent to it and emits them as a single event.
 
       The resulting Event will have a payload message of `message`. You can use liquid templating in the `message`, have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) for details.
@@ -16,7 +16,7 @@ class DigestAgent < Agent
       For instance, say `retained_events` is set to 3 and the Agent has received Events `5`, `4`, and `3`. When a digest is sent, Events `5`, `4`, and `3` are retained for a future digest. After Event `6` is received, the next digest will contain Events `6`, `5`, and `4`.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       Events look like this:
 
           {
@@ -27,9 +27,9 @@ class DigestAgent < Agent
 
     def default_options
       {
-          "expected_receive_period_in_days" => "2",
-          "message" => "{{ events | map: 'message' | join: ',' }}",
-          "retained_events" => "0"
+        "expected_receive_period_in_days" => "2",
+        "message" => "{{ events | map: 'message' | join: ',' }}",
+        "retained_events" => "0"
       }
     end
 
@@ -38,7 +38,8 @@ def default_options
     form_configurable :retained_events
 
     def validate_options
-      errors.add(:base, 'retained_events must be 0 to 999') unless options['retained_events'].to_i >= 0 && options['retained_events'].to_i < 1000
+      errors.add(:base,
+                 'retained_events must be 0 to 999') unless options['retained_events'].to_i >= 0 && options['retained_events'].to_i < 1000
     end
 
     def working?
@@ -60,7 +61,7 @@ def check
         events = received_events.where(id: self.memory["queue"]).order(id: :asc).to_a
         payload = { "events" => events.map { |event| event.payload } }
         payload["message"] = interpolated(payload)["message"]
-        create_event :payload => payload
+        create_event(payload:)
         if interpolated["retained_events"].to_i == 0
           self.memory["queue"] = []
         end
diff --git a/app/models/agents/dropbox_file_url_agent.rb b/app/models/agents/dropbox_file_url_agent.rb
index 7cc7af76b8..2756d49b62 100644
--- a/app/models/agents/dropbox_file_url_agent.rb
+++ b/app/models/agents/dropbox_file_url_agent.rb
@@ -6,7 +6,7 @@ class DropboxFileUrlAgent < Agent
     no_bulk_receive!
     can_dry_run!
 
-    description <<-MD
+    description <<~MD
       The _DropboxFileUrlAgent_ is used to work with Dropbox. It takes a file path (or multiple files paths) and emits events with either [temporary links](https://www.dropbox.com/developers/core/docs#media) or [permanent links](https://www.dropbox.com/developers/core/docs#shares).
 
       #{'## Include the `dropbox-api` and `omniauth-dropbox` gems in your `Gemfile` and set `DROPBOX_OAUTH_KEY` and `DROPBOX_OAUTH_SECRET` in your environment to use Dropbox Agents.' if dependencies_missing?}
@@ -34,39 +34,42 @@ class DropboxFileUrlAgent < Agent
     MD
 
     event_description do
-      "Events will looks like this:\n\n    %s" % if options['link_type'] == 'permanent'
-        Utils.pretty_print({
-          url: "https://www.dropbox.com/s/abcde3/example?dl=1",
-          :".tag" => "file",
-          id: "id:abcde3",
-          name: "hi",
-          path_lower: "/huginn/hi",
-          link_permissions:          {
-            resolved_visibility: {:".tag"=>"public"},
-            requested_visibility: {:".tag"=>"public"},
-            can_revoke: true
-          },
-          client_modified: "2017-10-14T18:38:39Z",
-          server_modified: "2017-10-14T18:38:45Z",
-          rev: "31db0615354b",
-          size: 0
-        })
-      else
-        Utils.pretty_print({
-          url: "https://dl.dropboxusercontent.com/apitl/1/somelongurl",
-          metadata: {
-            name: "hi",
-            path_lower: "/huginn/hi",
-            path_display: "/huginn/hi",
-            id: "id:abcde3",
-            client_modified: "2017-10-14T18:38:39Z",
-            server_modified: "2017-10-14T18:38:45Z",
-            rev: "31db0615354b",
-            size: 0,
-            content_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
-          }
-        })
-      end
+      "Events will looks like this:\n\n    " +
+        Utils.pretty_print(
+          if options['link_type'] == 'permanent'
+            {
+              url: "https://www.dropbox.com/s/abcde3/example?dl=1",
+              ".tag": "file",
+              id: "id:abcde3",
+              name: "hi",
+              path_lower: "/huginn/hi",
+              link_permissions: {
+                resolved_visibility: { ".tag": "public" },
+                requested_visibility: { ".tag": "public" },
+                can_revoke: true
+              },
+              client_modified: "2017-10-14T18:38:39Z",
+              server_modified: "2017-10-14T18:38:45Z",
+              rev: "31db0615354b",
+              size: 0
+            }
+          else
+            {
+              url: "https://dl.dropboxusercontent.com/apitl/1/somelongurl",
+              metadata: {
+                name: "hi",
+                path_lower: "/huginn/hi",
+                path_display: "/huginn/hi",
+                id: "id:abcde3",
+                client_modified: "2017-10-14T18:38:39Z",
+                server_modified: "2017-10-14T18:38:45Z",
+                rev: "31db0615354b",
+                size: 0,
+                content_hash: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+              }
+            }
+          end
+        )
     end
 
     def default_options
@@ -96,10 +99,8 @@ def temporary_url_for(path)
 
     def permanent_url_for(path)
       dropbox.find(path).share_url.response.tap do |response|
-        response['url'].gsub!('?dl=0','?dl=1')
+        response['url'].gsub!('?dl=0', '?dl=1')
       end
     end
-
   end
-
 end
diff --git a/app/models/agents/dropbox_watch_agent.rb b/app/models/agents/dropbox_watch_agent.rb
index ed63dcf187..9905ce6534 100644
--- a/app/models/agents/dropbox_watch_agent.rb
+++ b/app/models/agents/dropbox_watch_agent.rb
@@ -5,13 +5,13 @@ class DropboxWatchAgent < Agent
     cannot_receive_events!
     default_schedule "every_1m"
 
-    description <<-MD
+    description <<~MD
       The Dropbox Watch Agent watches the given `dir_to_watch` and emits events with the detected changes.
-      
+
       #{'## Include the `dropbox-api` and `omniauth-dropbox` gems in your `Gemfile` and set `DROPBOX_OAUTH_KEY` and `DROPBOX_OAUTH_SECRET` in your environment to use Dropbox Agents.' if dependencies_missing?}
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       The event payload will contain the following fields:
 
           {
@@ -34,7 +34,8 @@ def default_options
 
     def validate_options
       errors.add(:base, 'The `dir_to_watch` property is required.') unless options['dir_to_watch'].present?
-      errors.add(:base, 'Invalid `expected_update_period_in_days` format.') unless options['expected_update_period_in_days'].present? && is_positive_integer?(options['expected_update_period_in_days'])
+      errors.add(:base,
+                 'Invalid `expected_update_period_in_days` format.') unless options['expected_update_period_in_days'].present? && is_positive_integer?(options['expected_update_period_in_days'])
     end
 
     def working?
@@ -53,8 +54,8 @@ def check
 
     def ls(dir_to_watch)
       dropbox.ls(dir_to_watch)
-             .select { |entry| entry.respond_to?(:rev) }
-             .map { |file| { 'path' => file.path, 'rev' => file.rev, 'modified' => file.server_modified } }
+        .select { |entry| entry.respond_to?(:rev) }
+        .map { |file| { 'path' => file.path, 'rev' => file.rev, 'modified' => file.server_modified } }
     end
 
     def previous_contents
@@ -69,7 +70,8 @@ def remember(contents)
 
     class DropboxDirDiff
       def initialize(previous, current)
-        @previous, @current = [previous || [], current || []]
+        @previous = previous || []
+        @current = current || []
       end
 
       def empty?
@@ -101,7 +103,5 @@ def find_by_path(array, path)
         array.find { |entry| entry['path'] == path }
       end
     end
-
   end
-
 end
diff --git a/app/models/agents/email_agent.rb b/app/models/agents/email_agent.rb
index 765699879b..efb6b9cd74 100644
--- a/app/models/agents/email_agent.rb
+++ b/app/models/agents/email_agent.rb
@@ -9,7 +9,7 @@ class EmailAgent < Agent
     cannot_create_events!
     no_bulk_receive!
 
-    description <<-MD
+    description <<~MD
       The Email Agent sends any events it receives via email immediately.
 
       You can specify the email's subject line by providing a `subject` option, which can contain [Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) formatting.  E.g.,
@@ -35,9 +35,9 @@ class EmailAgent < Agent
 
     def default_options
       {
-          'subject' => "You have a notification!",
-          'headline' => "Your notification:",
-          'expected_receive_period_in_days' => "2"
+        'subject' => "You have a notification!",
+        'headline' => "Your notification:",
+        'expected_receive_period_in_days' => "2"
       }
     end
 
@@ -48,21 +48,19 @@ def working?
     def receive(incoming_events)
       incoming_events.each do |event|
         recipients(event.payload).each do |recipient|
-          begin
-            SystemMailer.send_message(
-              to: recipient,
-              from: interpolated(event)['from'],
-              subject: interpolated(event)['subject'],
-              headline: interpolated(event)['headline'],
-              body: interpolated(event)['body'],
-              content_type: interpolated(event)['content_type'],
-              groups: [present(event.payload)]
-            ).deliver_now
-            log "Sent mail to #{recipient} with event #{event.id}"
-          rescue => e
-            error("Error sending mail to #{recipient} with event #{event.id}: #{e.message}")
-            raise
-          end
+          SystemMailer.send_message(
+            to: recipient,
+            from: interpolated(event)['from'],
+            subject: interpolated(event)['subject'],
+            headline: interpolated(event)['headline'],
+            body: interpolated(event)['body'],
+            content_type: interpolated(event)['content_type'],
+            groups: [present(event.payload)]
+          ).deliver_now
+          log "Sent mail to #{recipient} with event #{event.id}"
+        rescue StandardError => e
+          error("Error sending mail to #{recipient} with event #{event.id}: #{e.message}")
+          raise
         end
       end
     end
diff --git a/app/models/agents/email_digest_agent.rb b/app/models/agents/email_digest_agent.rb
index 0cfa3ba96e..630d8119a9 100644
--- a/app/models/agents/email_digest_agent.rb
+++ b/app/models/agents/email_digest_agent.rb
@@ -8,7 +8,7 @@ class EmailDigestAgent < Agent
 
     cannot_create_events!
 
-    description <<-MD
+    description <<~MD
       The Email Digest Agent collects any Events sent to it and sends them all via email when scheduled. The number of
       used events also relies on the `Keep events` option of the emitting Agent, meaning that if events expire before
       this agent is scheduled to run, they will not appear in the email.
@@ -30,9 +30,9 @@ class EmailDigestAgent < Agent
 
     def default_options
       {
-          'subject' => "You have some notifications!",
-          'headline' => "Your notifications:",
-          'expected_receive_period_in_days' => "2"
+        'subject' => "You have some notifications!",
+        'headline' => "Your notifications:",
+        'expected_receive_period_in_days' => "2"
       }
     end
 
@@ -52,21 +52,19 @@ def check
         payloads = received_events.reorder("events.id ASC").where(id: self.memory['events']).pluck(:payload).to_a
         groups = payloads.map { |payload| present(payload) }
         recipients.each do |recipient|
-          begin
-            SystemMailer.send_message(
-              to: recipient,
-              from: interpolated['from'],
-              subject: interpolated['subject'],
-              headline: interpolated['headline'],
-              content_type: interpolated['content_type'],
-              groups: groups
-            ).deliver_now
+          SystemMailer.send_message(
+            to: recipient,
+            from: interpolated['from'],
+            subject: interpolated['subject'],
+            headline: interpolated['headline'],
+            content_type: interpolated['content_type'],
+            groups:
+          ).deliver_now
 
-            log "Sent digest mail to #{recipient}"
-          rescue => e
-            error("Error sending digest mail to #{recipient}: #{e.message}")
-            raise
-          end
+          log "Sent digest mail to #{recipient}"
+        rescue StandardError => e
+          error("Error sending digest mail to #{recipient}: #{e.message}")
+          raise
         end
         self.memory['events'] = []
       end
diff --git a/app/models/agents/event_formatting_agent.rb b/app/models/agents/event_formatting_agent.rb
index 4286923f6c..98f4d3f9ad 100644
--- a/app/models/agents/event_formatting_agent.rb
+++ b/app/models/agents/event_formatting_agent.rb
@@ -3,7 +3,7 @@ class EventFormattingAgent < Agent
     cannot_be_scheduled!
     can_dry_run!
 
-    description <<-MD
+    description <<~MD
       The Event Formatting Agent allows you to format incoming Events, adding new fields as needed.
 
       For example, here is a possible Event:
@@ -34,7 +34,7 @@ class EventFormattingAgent < Agent
 
       The special key `created_at` refers to the timestamp of the Event, which can be reformatted by the `date` filter, like `{{created_at | date:"at %I:%M %p" }}`.
 
-      The upstream agent of each received event is accessible via the key `agent`, which has the following attributes: #{''.tap { |s| s << AgentDrop.instance_methods(false).map { |m| "`#{m}`" }.join(', ') }}.
+      The upstream agent of each received event is accessible via the key `agent`, which has the following attributes: #{''.tap { |s| s << Agent::Drop.instance_methods(false).map { |m| "`#{m}`" }.join(', ') }}.
 
       Have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
 
@@ -96,9 +96,10 @@ class EventFormattingAgent < Agent
     end
 
     def validate_options
-      errors.add(:base, "instructions and mode need to be present.") unless options['instructions'].present? && options['mode'].present?
+      errors.add(:base,
+                 "instructions and mode need to be present.") unless options['instructions'].present? && options['mode'].present?
 
-      if options['mode'].present? && !options['mode'].to_s.include?('{{') && !%[clean merge].include?(options['mode'].to_s)
+      if options['mode'].present? && !options['mode'].to_s.include?('{{') && !%(clean merge).include?(options['mode'].to_s)
         errors.add(:base, "mode must be 'clean' or 'merge'")
       end
 
@@ -108,7 +109,7 @@ def validate_options
     def default_options
       {
         'instructions' => {
-          'message' =>  "You received a text {{text}} from {{fields.from}}",
+          'message' => "You received a text {{text}} from {{fields.from}}",
           'agent' => "{{agent.type}}",
           'some_other_field' => "Looks like the weather is going to be {{fields.weather}}"
         },
@@ -156,7 +157,7 @@ def validate_matchers
         if regexp.present?
           begin
             Regexp.new(regexp)
-          rescue
+          rescue StandardError
             errors.add(:base, "bad regexp found in matchers: #{regexp}")
           end
         else
diff --git a/app/models/agents/evernote_agent.rb b/app/models/agents/evernote_agent.rb
index 5eb6260a6e..3356f5a08b 100644
--- a/app/models/agents/evernote_agent.rb
+++ b/app/models/agents/evernote_agent.rb
@@ -2,7 +2,7 @@ module Agents
   class EvernoteAgent < Agent
     include EvernoteConcern
 
-    description <<-MD
+    description <<~MD
       The Evernote Agent connects with a user's Evernote note store.
 
       Visit [Evernote](https://dev.evernote.com/doc/) to set up an Evernote app and receive an api key and secret.
@@ -53,7 +53,7 @@ class EvernoteAgent < Agent
                 }
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       When `mode` is `update`, events look like:
 
           {
@@ -106,7 +106,7 @@ def default_options
     end
 
     def validate_options
-      errors.add(:base, "mode must be 'update' or 'read'") unless %w(read update).include?(options[:mode])
+      errors.add(:base, "mode must be 'update' or 'read'") unless %w[read update].include?(options[:mode])
 
       if options[:mode] == "update" && schedule != "never"
         errors.add(:base, "when mode is set to 'update', schedule must be 'never'")
@@ -116,7 +116,8 @@ def validate_options
         errors.add(:base, "when mode is set to 'read', agent must have a schedule")
       end
 
-      errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
+      errors.add(:base,
+                 "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
 
       if options[:mode] == "update" && options[:note].values.all?(&:empty?)
         errors.add(:base, "you must specify at least one note parameter to create or update a note")
@@ -131,7 +132,7 @@ def receive(incoming_events)
       if options[:mode] == "update"
         incoming_events.each do |event|
           note = note_store.create_or_update_note(note_params(event))
-          create_event :payload => note.attr(include_content: include_xhtml_content?)
+          create_event payload: note.attr(include_content: include_xhtml_content?)
         end
       end
     end
@@ -146,15 +147,17 @@ def check
         opts.merge!(last_checked_at: (memory[:last_checked_at] ||= created_at.to_i * 1000))
 
         if opts[:tagNames]
-          opts.merge!(notes_with_tags: (memory[:notes_with_tags] ||=
-            NoteStore::Search.new(note_store, {tagNames: opts[:tagNames]}).note_guids))
+          notes_with_tags =
+            memory[:notes_with_tags] ||=
+              NoteStore::Search.new(note_store, { tagNames: opts[:tagNames] }).note_guids
+          opts.merge!(notes_with_tags:)
         end
 
         notes = NoteStore::Search.new(note_store, opts).notes
         notes.each do |note|
           memory[:notes_with_tags] << note.guid unless memory[:notes_with_tags].include?(note.guid)
 
-          create_event :payload => note.attr(include_resources: true, include_content: include_xhtml_content?)
+          create_event payload: note.attr(include_resources: true, include_content: include_xhtml_content?)
         end
 
         memory[:last_checked_at] = Time.now.to_i * 1000
@@ -185,19 +188,20 @@ def note_store
     # https://dev.evernote.com/doc/reference/
     class NoteStore
       attr_reader :en_note_store
+
       delegate :createNote, :updateNote, :getNote, :listNotebooks, :listTags, :getNotebook,
-               :createNotebook, :findNotesMetadata, :getNoteTagNames, :to => :en_note_store
+               :createNotebook, :findNotesMetadata, :getNoteTagNames, to: :en_note_store
 
       def initialize(en_note_store)
         @en_note_store = en_note_store
       end
 
       def create_or_update_note(params)
-        search = Search.new(self, {title: params[:title], notebook: params[:notebook]})
+        search = Search.new(self, { title: params[:title], notebook: params[:notebook] })
 
         # evernote search can only filter notes with titles containing a substring;
         # this finds a note with the exact title
-        note = search.notes.detect {|note| note.title == params[:title]}
+        note = search.notes.detect { |note| note.title == params[:title] }
 
         if note
           # a note with specified title and notebook exists, so update it
@@ -227,7 +231,8 @@ def update_note(params)
         # evernote will create any new tags
         tags = getNoteTagNames(params[:guid])
         tags.each { |tag|
-          params[:tagNames] << tag unless params[:tagNames].include?(tag) }
+          params[:tagNames] << tag unless params[:tagNames].include?(tag)
+        }
 
         note = Evernote::EDAM::Type::Note.new(params)
         updateNote(note)
@@ -247,19 +252,19 @@ def build_note(en_note)
       end
 
       def find_tags(guids)
-        listTags.select {|tag| guids.include?(tag.guid)}
+        listTags.select { |tag| guids.include?(tag.guid) }
       end
 
       def find_notebook(params)
         if params[:guid]
-          listNotebooks.detect {|notebook| notebook.guid == params[:guid]}
+          listNotebooks.detect { |notebook| notebook.guid == params[:guid] }
         elsif params[:name]
-          listNotebooks.detect {|notebook| notebook.name == params[:name]}
+          listNotebooks.detect { |notebook| notebook.name == params[:name] }
         end
       end
 
       def create_notebook(name)
-        notebook = Evernote::EDAM::Type::Notebook.new(name: name)
+        notebook = Evernote::EDAM::Type::Notebook.new(name:)
         createNotebook(notebook)
       end
 
@@ -270,7 +275,7 @@ def with_wrapped_content(params)
           params[:content] =
             "" \
             "" \
-            "#{params[:content].encode(:xml => :text)}"
+            "#{params[:content].encode(xml: :text)}"
         end
 
         params
@@ -278,6 +283,7 @@ def with_wrapped_content(params)
 
       class Search
         attr_reader :note_store, :opts
+
         def initialize(note_store, opts)
           @note_store = note_store
           @opts = opts
@@ -297,7 +303,7 @@ def notes
             # and notes that recently had the specified tags added
             metadata.select! do |note_data|
               note_data.updated > opts[:last_checked_at] ||
-              !opts[:notes_with_tags].include?(note_data.guid)
+                !opts[:notes_with_tags].include?(note_data.guid)
             end
 
           elsif opts[:last_checked_at]
@@ -326,7 +332,8 @@ def create_filter
         private
 
         def filtered_metadata
-          filter, spec = create_filter, create_spec
+          filter = create_filter
+          spec = create_spec
           metadata = note_store.findNotesMetadata(filter, 0, 100, spec).notes
         end
 
@@ -346,8 +353,9 @@ def create_spec
     class Note
       attr_accessor :en_note
       attr_reader :notebook, :tags
+
       delegate :guid, :notebookGuid, :title, :tagGuids, :content, :resources,
-               :attributes, :to => :en_note
+               :attributes, to: :en_note
 
       def initialize(en_note, notebook, tags)
         @en_note = en_note
@@ -357,11 +365,11 @@ def initialize(en_note, notebook, tags)
 
       def attr(opts = {})
         return_attr = {
-          title:        title,
-          notebook:     notebook,
-          tags:         tags,
-          source:       attributes.source,
-          source_url:   attributes.sourceURL
+          title:,
+          notebook:,
+          tags:,
+          source: attributes.source,
+          source_url: attributes.sourceURL
         }
 
         return_attr[:content] = content if opts[:include_content]
diff --git a/app/models/agents/ftpsite_agent.rb b/app/models/agents/ftpsite_agent.rb
index 3d803f055f..b6ba78216d 100644
--- a/app/models/agents/ftpsite_agent.rb
+++ b/app/models/agents/ftpsite_agent.rb
@@ -11,7 +11,7 @@ class FtpsiteAgent < Agent
     emits_file_pointer!
 
     description do
-      <<-MD
+      <<~MD
         The Ftp Site Agent checks an FTP site and creates Events based on newly uploaded files in a directory. When receiving events it creates files on the configured FTP server.
 
         #{'## Include `net-ftp-list` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -40,7 +40,7 @@ class FtpsiteAgent < Agent
       MD
     end
 
-    event_description <<-MD
+    event_description <<~MD
       Events look like this:
 
           {
@@ -83,7 +83,7 @@ def validate_options
           URI::FTP === uri or raise
           errors.add(:base, "url must end with a slash") if uri.path.present? && !uri.path.end_with?('/')
         end
-      rescue
+      rescue StandardError
         errors.add(:base, "url must be a valid FTP URL")
       end
 
@@ -118,18 +118,20 @@ def validate_options
       if (timestamp = options['timestamp']).present?
         begin
           Time.parse(timestamp)
-        rescue
+        rescue StandardError
           errors.add(:base, "timestamp cannot be parsed as time")
         end
       end
 
       if options['expected_update_period_in_days'].present?
-        errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days'])
+        errors.add(:base,
+                   "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days'])
       end
     end
 
     def check
       return if interpolated['mode'] != 'read'
+
       saving_entries do |found|
         each_entry { |filename, mtime|
           found[filename, mtime]
@@ -139,9 +141,14 @@ def check
 
     def receive(incoming_events)
       return if interpolated['mode'] != 'write'
+
       incoming_events.each do |event|
         mo = interpolated(event)
-        mo['data'].encode!(interpolated['force_encoding'], invalid: :replace, undef: :replace) if interpolated['force_encoding'].present?
+        mo['data'].encode!(
+          interpolated['force_encoding'],
+          invalid: :replace,
+          undef: :replace
+        ) if interpolated['force_encoding'].present?
         open_ftp(base_uri) do |ftp|
           ftp.storbinary("STOR #{mo['filename']}", StringIO.new(mo['data']), Net::FTP::DEFAULT_BLOCKSIZE)
         end
@@ -168,7 +175,7 @@ def each_entry
           entry = Net::FTP::List.parse line
           filename = entry.basename
           mtime = Time.parse(entry.mtime.to_s).utc
-          
+
           patterns.any? { |pattern|
             File.fnmatch?(pattern, filename)
           } or next
diff --git a/app/models/agents/gap_detector_agent.rb b/app/models/agents/gap_detector_agent.rb
index c371fd93c7..effbc0ef80 100644
--- a/app/models/agents/gap_detector_agent.rb
+++ b/app/models/agents/gap_detector_agent.rb
@@ -2,7 +2,7 @@ module Agents
   class GapDetectorAgent < Agent
     default_schedule "every_10m"
 
-    description <<-MD
+    description <<~MD
       The Gap Detector Agent will watch for holes or gaps in a stream of incoming Events and generate "no data alerts".
 
       The `value_path` value is a [JSONPath](http://goessner.net/articles/JsonPath/) to a value of interest. If either
@@ -10,7 +10,7 @@ class GapDetectorAgent < Agent
       a payload of `message`.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       Events look like:
 
           {
@@ -58,8 +58,10 @@ def check
       if memory['newest_event_created_at'].present? && Time.at(memory['newest_event_created_at']) < window
         unless memory['alerted_at']
           memory['alerted_at'] = Time.now.to_i
-          create_event payload: { message: interpolated['message'],
-                                  gap_started_at: memory['newest_event_created_at'] }
+          create_event payload: {
+            message: interpolated['message'],
+            gap_started_at: memory['newest_event_created_at']
+          }
         end
       end
     end
diff --git a/app/models/agents/google_calendar_publish_agent.rb b/app/models/agents/google_calendar_publish_agent.rb
index 319f3a69df..679083668f 100644
--- a/app/models/agents/google_calendar_publish_agent.rb
+++ b/app/models/agents/google_calendar_publish_agent.rb
@@ -8,7 +8,7 @@ class GoogleCalendarPublishAgent < Agent
 
     gem_dependency_check { defined?(Google) && defined?(Google::Apis::CalendarV3) }
 
-    description <<-MD
+    description <<~MD
       The Google Calendar Publish Agent creates events on your Google Calendar.
 
       #{'## Include `google-api-client` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -73,19 +73,22 @@ class GoogleCalendarPublishAgent < Agent
       }
     MD
 
-    event_description <<-MD
-      {
-        'success' => true,
-        'published_calendar_event' => {
-           ....
-        },
-        'agent_id' => 1234,
-        'event_id' => 3432
-      }
+    event_description <<~MD
+      Events look like:
+
+          {
+            'success' => true,
+            'published_calendar_event' => {
+               ....
+            },
+            'agent_id' => 1234,
+            'event_id' => 3432
+          }
     MD
 
     def validate_options
-      errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
+      errors.add(:base,
+                 "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
     end
 
     def working?
@@ -108,7 +111,6 @@ def receive(incoming_events)
       require 'google_calendar'
       incoming_events.each do |event|
         GoogleCalendar.open(interpolate_options(options, event), Rails.logger) do |calendar|
-
           cal_message = event.payload["message"]
           if cal_message["start"].present? && cal_message["start"]["dateTime"].present? && !cal_message["start"]["date_time"].present?
             cal_message["start"]["date_time"] = cal_message["start"].delete "dateTime"
@@ -118,11 +120,11 @@ def receive(incoming_events)
           end
 
           calendar_event = calendar.publish_as(
-                interpolated(event)['calendar_id'],
-                cal_message
-              )
+            interpolated(event)['calendar_id'],
+            cal_message
+          )
 
-          create_event :payload => {
+          create_event payload: {
             'success' => true,
             'published_calendar_event' => calendar_event,
             'agent_id' => event.agent_id,
@@ -133,4 +135,3 @@ def receive(incoming_events)
     end
   end
 end
-
diff --git a/app/models/agents/google_translation_agent.rb b/app/models/agents/google_translation_agent.rb
index c4f70422ba..01b18e6bec 100644
--- a/app/models/agents/google_translation_agent.rb
+++ b/app/models/agents/google_translation_agent.rb
@@ -4,7 +4,7 @@ class GoogleTranslationAgent < Agent
 
     gem_dependency_check { defined?(Google) && defined?(Google::Cloud::Translate) }
 
-    description <<-MD
+    description <<~MD
       The Translation Agent will attempt to translate text between natural languages.
 
       #{'## Include `google-api-client` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -76,7 +76,7 @@ def google_client
     end
 
     def translate_service
-      @translate_service ||= google_client.discovered_api('translate','v2')
+      @translate_service ||= google_client.discovered_api('translate', 'v2')
     end
 
     def cloud_translate_service
diff --git a/app/models/agents/growl_agent.rb b/app/models/agents/growl_agent.rb
deleted file mode 100644
index 66cd9bd20f..0000000000
--- a/app/models/agents/growl_agent.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-module Agents
-  class GrowlAgent < Agent
-    include FormConfigurable
-    attr_reader :growler
-
-    cannot_be_scheduled!
-    cannot_create_events!
-    can_dry_run!
-
-    gem_dependency_check { defined?(Growl) }
-
-    description <<-MD
-      The Growl Agent sends any events it receives to a Growl GNTP server immediately.
-
-      #{'## Include `ruby-growl` in your Gemfile to use this Agent!' if dependencies_missing?}
-
-      The option `message`, which will hold the body of the growl notification, and the `subject` option,
-      which will have the headline of the Growl notification are required. All other options are optional.
-      When `callback_url` is set to a URL clicking on the notification will open the link in your default browser.
-
-      Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between
-      Events being received by this Agent.
-
-      Have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) to learn
-      more about liquid templating.
-    MD
-
-    def default_options
-      {
-          'growl_server' => 'localhost',
-          'growl_password' => '',
-          'growl_app_name' => 'HuginnGrowl',
-          'growl_notification_name' => 'Notification',
-          'expected_receive_period_in_days' => "2",
-          'subject' => '{{subject}}',
-          'message' => '{{message}}',
-          'sticky' => 'false',
-          'priority' => '0'
-      }
-    end
-
-    form_configurable :growl_server
-    form_configurable :growl_password
-    form_configurable :growl_app_name
-    form_configurable :growl_notification_name
-    form_configurable :expected_receive_period_in_days
-    form_configurable :subject
-    form_configurable :message, type: :text
-    form_configurable :sticky, type: :boolean
-    form_configurable :priority
-    form_configurable :callback_url
-
-    def working?
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
-    end
-
-    def validate_options
-      unless options['growl_server'].present? && options['expected_receive_period_in_days'].present?
-        errors.add(:base, "growl_server and expected_receive_period_in_days are required fields")
-      end
-    end
-
-    def register_growl
-      @growler = Growl::GNTP.new(interpolated['growl_server'], interpolated['growl_app_name'])
-      @growler.password = interpolated['growl_password']
-      @growler.add_notification(interpolated['growl_notification_name'])
-    end
-
-    def notify_growl(subject:, message:, priority:, sticky:, callback_url:)
-      @growler.notify(interpolated['growl_notification_name'], subject, message, priority, sticky, nil, callback_url)
-    end
-
-    def receive(incoming_events)
-      incoming_events.each do |event|
-        interpolate_with(event) do
-          register_growl
-          message = interpolated[:message]
-          subject = interpolated[:subject]
-          if message.present? && subject.present?
-            log "Sending Growl notification '#{subject}': '#{message}' to #{interpolated(event)['growl_server']} with event #{event.id}"
-            notify_growl(subject: subject,
-                         message: message,
-                         priority: interpolated[:priority].to_i,
-                         sticky: boolify(interpolated[:sticky]) || false,
-                         callback_url: interpolated[:callback_url].presence)
-          else
-            log "Event #{event.id} not sent, message and subject expected"
-          end
-        end
-      end
-    end
-  end
-end
diff --git a/app/models/agents/hipchat_agent.rb b/app/models/agents/hipchat_agent.rb
index f89a0c95fd..7f1c41b45f 100644
--- a/app/models/agents/hipchat_agent.rb
+++ b/app/models/agents/hipchat_agent.rb
@@ -8,7 +8,7 @@ class HipchatAgent < Agent
 
     gem_dependency_check { defined?(HipChat) }
 
-    description <<-MD
+    description <<~MD
       The Hipchat Agent sends messages to a Hipchat Room
 
       #{'## Include `hipchat` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -52,16 +52,18 @@ def validate_auth_token
       client.rooms
       true
     rescue HipChat::UnknownResponseCode
-      return false
+      false
     end
 
     def complete_room_name
-      client.rooms.collect { |room| {text: room.name, id: room.name} }
+      client.rooms.collect { |room| { text: room.name, id: room.name } }
     end
 
     def validate_options
-      errors.add(:base, "you need to specify a hipchat auth_token or provide a credential named hipchat_auth_token") unless options['auth_token'].present? || credential('hipchat_auth_token').present?
-      errors.add(:base, "you need to specify a room_name or a room_name_path") if options['room_name'].blank? && options['room_name_path'].blank?
+      errors.add(:base,
+                 "you need to specify a hipchat auth_token or provide a credential named hipchat_auth_token") unless options['auth_token'].present? || credential('hipchat_auth_token').present?
+      errors.add(:base,
+                 "you need to specify a room_name or a room_name_path") if options['room_name'].blank? && options['room_name_path'].blank?
     end
 
     def working?
@@ -71,15 +73,18 @@ def working?
     def receive(incoming_events)
       incoming_events.each do |event|
         mo = interpolated(event)
-        client[mo[:room_name]].send(mo[:username][0..14], mo[:message],
-                                      notify: boolify(mo[:notify]),
-                                      color: mo[:color],
-                                      message_format: mo[:format].presence || 'html'
-                                    )
+        client[mo[:room_name]].send(
+          mo[:username][0..14],
+          mo[:message],
+          notify: boolify(mo[:notify]),
+          color: mo[:color],
+          message_format: mo[:format].presence || 'html'
+        )
       end
     end
 
     private
+
     def client
       @client ||= HipChat::Client.new(interpolated[:auth_token].presence || credential('hipchat_auth_token'))
     end
diff --git a/app/models/agents/http_status_agent.rb b/app/models/agents/http_status_agent.rb
index 313fc2aa17..5dca39b015 100644
--- a/app/models/agents/http_status_agent.rb
+++ b/app/models/agents/http_status_agent.rb
@@ -1,9 +1,7 @@
 require 'time_tracker'
 
 module Agents
-
   class HttpStatusAgent < Agent
-
     include WebRequestConcern
     include FormConfigurable
 
@@ -17,7 +15,7 @@ class HttpStatusAgent < Agent
     form_configurable :changes_only, type: :boolean
     form_configurable :headers_to_save
 
-    description <<-MD
+    description <<~MD
       The HttpStatusAgent will check a url and emit the resulting HTTP status code with the time that it waited for a reply. Additionally, it will optionally emit the value of one or more specified headers.
 
       Specify a `Url` and the Http Status Agent will produce an event with the HTTP status code. If you specify one or more `Headers to save` (comma-delimited) as well, that header or headers' value(s) will be included in the event.
@@ -27,7 +25,7 @@ class HttpStatusAgent < Agent
       The `changes only` option causes the Agent to report an event only when the status changes. If set to false, an event will be created for every check.  If set to true, an event will only be created when the status changes (like if your site goes from 200 to 500).
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       Events will have the following fields:
 
           {
@@ -86,27 +84,28 @@ def check_this_url(url, local_headers)
       # Deal with failures
       if measured_result.result
         final_url = boolify(interpolated['disable_redirect_follow']) ? url : measured_result.result.env.url.to_s
-        payload.merge!({ 'final_url' => final_url, 'redirected' => (url != final_url), 'response_received' => true, 'status' => current_status })
+        payload.merge!({ 'final_url' => final_url, 'redirected' => (url != final_url), 'response_received' => true,
+                         'status' => current_status })
         # Deal with headers
         if local_headers.present?
-          header_results = local_headers.each_with_object({}) { |header, hash| hash[header] = measured_result.result.headers[header] }
+          header_results = local_headers.each_with_object({}) { |header, hash|
+            hash[header] = measured_result.result.headers[header]
+          }
           payload.merge!({ 'headers' => header_results })
         end
-        create_event payload: payload
+        create_event(payload:)
         memory['last_status'] = measured_result.status.to_s
       else
-        create_event payload: payload
+        create_event(payload:)
         memory['last_status'] = nil
       end
-
     end
 
     def ping(url)
       result = faraday.get url
       result.status > 0 ? result : nil
-    rescue
+    rescue StandardError
       nil
     end
   end
-
 end
diff --git a/app/models/agents/human_task_agent.rb b/app/models/agents/human_task_agent.rb
index 90d0784656..2100956376 100644
--- a/app/models/agents/human_task_agent.rb
+++ b/app/models/agents/human_task_agent.rb
@@ -4,7 +4,7 @@ class HumanTaskAgent < Agent
 
     gem_dependency_check { defined?(RTurk) }
 
-    description <<-MD
+    description <<~MD
       The Human Task Agent is used to create Human Intelligence Tasks (HITs) on Mechanical Turk.
 
       #{'## Include `rturk` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -118,7 +118,7 @@ class HumanTaskAgent < Agent
       As with most Agents, `expected_receive_period_in_days` is required if `trigger_on` is set to `event`.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       Events look like:
 
           {
@@ -135,24 +135,45 @@ def validate_options
       options['hit'] ||= {}
       options['hit']['questions'] ||= []
 
-      errors.add(:base, "'trigger_on' must be one of 'schedule' or 'event'") unless %w[schedule event].include?(options['trigger_on'])
-      errors.add(:base, "'hit.assignments' should specify the number of HIT assignments to create") unless options['hit']['assignments'].present? && options['hit']['assignments'].to_i > 0
+      errors.add(
+        :base, "'trigger_on' must be one of 'schedule' or 'event'"
+      ) unless %w[schedule event].include?(options['trigger_on'])
+      errors.add(
+        :base,
+        "'hit.assignments' should specify the number of HIT assignments to create"
+      ) unless options['hit']['assignments'].present? &&
+        options['hit']['assignments'].to_i > 0
       errors.add(:base, "'hit.title' must be provided") unless options['hit']['title'].present?
       errors.add(:base, "'hit.description' must be provided") unless options['hit']['description'].present?
-      errors.add(:base, "'hit.questions' must be provided") unless options['hit']['questions'].present? && options['hit']['questions'].length > 0
+      errors.add(:base, "'hit.questions' must be provided") unless options['hit']['questions'].present?
 
       if options['trigger_on'] == "event"
-        errors.add(:base, "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'") unless options['expected_receive_period_in_days'].present?
+        errors.add(
+          :base,
+          "'expected_receive_period_in_days' is required when 'trigger_on' is set to 'event'"
+        ) unless options['expected_receive_period_in_days'].present?
       elsif options['trigger_on'] == "schedule"
-        errors.add(:base, "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'") unless options['submission_period'].present? && options['submission_period'].to_i > 0
+        errors.add(
+          :base,
+          "'submission_period' must be set to a positive number of hours when 'trigger_on' is set to 'schedule'"
+        ) unless options['submission_period'].present? &&
+          options['submission_period'].to_i > 0
       end
 
-      if options['hit']['questions'].any? { |question| %w[key name required type question].any? {|k| !question[k].present? } }
+      if options['hit']['questions'].any? { |question|
+           %w[key name required type question].any? { |k| question[k].blank? }
+         }
         errors.add(:base, "all questions must set 'key', 'name', 'required', 'type', and 'question'")
       end
 
-      if options['hit']['questions'].any? { |question| question['type'] == "selection" && (!question['selections'].present? || question['selections'].length == 0 || !question['selections'].all? {|s| s['key'].present? } || !question['selections'].all? { |s| s['text'].present? })}
-        errors.add(:base, "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'")
+      if options['hit']['questions'].any? { |question|
+           question['type'] == "selection" && (
+             question['selections'].blank? ||
+               question['selections'].any? { |s| s['key'].blank? || s['text'].blank? }
+           )
+         }
+        errors.add(:base,
+                   "all questions of type 'selection' must have a selections array with selections that set 'key' and 'name'")
       end
 
       if take_majority? && options['hit']['questions'].any? { |question| question['type'] != "selection" }
@@ -160,7 +181,14 @@ def validate_options
       end
 
       if create_poll?
-        errors.add(:base, "poll_options is required when combination_mode is set to 'poll' and must have the keys 'title', 'instructions', 'row_template', and 'assignments'") unless options['poll_options'].is_a?(Hash) && options['poll_options']['title'].present? && options['poll_options']['instructions'].present? && options['poll_options']['row_template'].present? && options['poll_options']['assignments'].to_i > 0
+        errors.add(
+          :base,
+          "poll_options is required when combination_mode is set to 'poll' and must have the keys 'title', 'instructions', 'row_template', and 'assignments'"
+        ) unless options['poll_options'].is_a?(Hash) &&
+          options['poll_options']['title'].present? &&
+          options['poll_options']['instructions'].present? &&
+          options['poll_options']['row_template'].present? &&
+          options['poll_options']['assignments'].to_i > 0
       end
     end
 
@@ -229,7 +257,6 @@ def receive(incoming_events)
     protected
 
     if defined?(RTurk)
-
       def take_majority?
         interpolated['combination_mode'] == "take_majority" || interpolated['take_majority'] == "true"
       end
@@ -266,139 +293,151 @@ def review_hits
           assignments = hit.assignments
 
           log "Looking at HIT #{hit_id}.  I found #{assignments.length} assignments#{" with the statuses: #{assignments.map(&:status).to_sentence}" if assignments.length > 0}"
-          if assignments.length == hit.max_assignments && assignments.all? { |assignment| assignment.status == "Submitted" }
-            inbound_event = event_for_hit(hit_id)
+          next unless assignments.length == hit.max_assignments &&
+            assignments.all? { |assignment|
+              assignment.status == "Submitted"
+            }
 
-            if hit_type(hit_id) == 'poll'
-              # handle completed polls
+          inbound_event = event_for_hit(hit_id)
 
-              log "Handling a poll: #{hit_id}"
+          if hit_type(hit_id) == 'poll'
+            # handle completed polls
 
-              scores = {}
-              assignments.each do |assignment|
-                assignment.answers.each do |index, rating|
-                  scores[index] ||= 0
-                  scores[index] += rating.to_i
-                end
+            log "Handling a poll: #{hit_id}"
+
+            scores = {}
+            assignments.each do |assignment|
+              assignment.answers.each do |index, rating|
+                scores[index] ||= 0
+                scores[index] += rating.to_i
               end
+            end
 
-              top_answer = scores.to_a.sort {|b, a| a.last <=> b.last }.first.first
+            top_answer = scores.to_a.sort { |b, a| a.last <=> b.last }.first.first
 
-              payload = {
-                'answers' => memory['hits'][hit_id]['answers'],
-                'poll' => assignments.map(&:answers),
-                'best_answer' => memory['hits'][hit_id]['answers'][top_answer.to_i - 1]
-              }
+            payload = {
+              'answers' => memory['hits'][hit_id]['answers'],
+              'poll' => assignments.map(&:answers),
+              'best_answer' => memory['hits'][hit_id]['answers'][top_answer.to_i - 1]
+            }
 
-              event = create_event :payload => payload
-              log "Event emitted with answer(s) for poll", :outbound_event => event, :inbound_event => inbound_event
-            else
-              # handle normal completed HITs
-              payload = { 'answers' => assignments.map(&:answers) }
-
-              if take_majority?
-                counts = {}
-                options['hit']['questions'].each do |question|
-                  question_counts = question['selections'].inject({}) { |memo, selection| memo[selection['key']] = 0; memo }
-                  assignments.each do |assignment|
-                    answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
-                    answer = answers[question['key']]
-                    question_counts[answer] += 1
-                  end
-                  counts[question['key']] = question_counts
+            event = create_event(payload:)
+            log("Event emitted with answer(s) for poll", outbound_event: event, inbound_event:)
+          else
+            # handle normal completed HITs
+            payload = { 'answers' => assignments.map(&:answers) }
+
+            if take_majority?
+              counts = {}
+              options['hit']['questions'].each do |question|
+                question_counts = question['selections'].each_with_object({}) { |selection, memo|
+                  memo[selection['key']] = 0
+                }
+                assignments.each do |assignment|
+                  answers = ActiveSupport::HashWithIndifferentAccess.new(assignment.answers)
+                  answer = answers[question['key']]
+                  question_counts[answer] += 1
                 end
-                payload['counts'] = counts
+                counts[question['key']] = question_counts
+              end
+              payload['counts'] = counts
 
-                majority_answer = counts.inject({}) do |memo, (key, question_counts)|
-                  memo[key] = question_counts.to_a.sort {|a, b| a.last <=> b.last }.last.first
-                  memo
-                end
-                payload['majority_answer'] = majority_answer
-
-                if all_questions_are_numeric?
-                  average_answer = counts.inject({}) do |memo, (key, question_counts)|
-                    sum = divisor = 0
-                    question_counts.to_a.each do |num, count|
-                      sum += num.to_s.to_f * count
-                      divisor += count
-                    end
-                    memo[key] = sum / divisor.to_f
-                    memo
+              majority_answer = counts.each_with_object({}) do |(key, question_counts), memo|
+                memo[key] = question_counts.to_a.sort_by(&:last).last.first
+              end
+              payload['majority_answer'] = majority_answer
+
+              if all_questions_are_numeric?
+                average_answer = counts.each_with_object({}) do |(key, question_counts), memo|
+                  sum = divisor = 0
+                  question_counts.to_a.each do |num, count|
+                    sum += num.to_s.to_f * count
+                    divisor += count
                   end
-                  payload['average_answer'] = average_answer
+                  memo[key] = sum / divisor.to_f
                 end
+                payload['average_answer'] = average_answer
               end
+            end
 
-              if create_poll?
-                questions = []
-                selections = 5.times.map { |i| { 'key' => i+1, 'text' => i+1 } }.reverse
-                assignments.length.times do |index|
-                  questions << {
-                    'type' => "selection",
-                    'name' => "Item #{index + 1}",
-                    'key' => index,
-                    'required' => "true",
-                    'question' => interpolate_string(options['poll_options']['row_template'], assignments[index].answers),
-                    'selections' => selections
-                  }
-                end
+            if create_poll?
+              questions = []
+              selections = 5.times.map { |i| { 'key' => i + 1, 'text' => i + 1 } }.reverse
+              assignments.length.times do |index|
+                questions << {
+                  'type' => "selection",
+                  'name' => "Item #{index + 1}",
+                  'key' => index,
+                  'required' => "true",
+                  'question' => interpolate_string(options['poll_options']['row_template'],
+                                                   assignments[index].answers),
+                  'selections' => selections
+                }
+              end
 
-                poll_hit = create_hit 'title' => options['poll_options']['title'],
-                                      'description' => options['poll_options']['instructions'],
-                                      'questions' => questions,
-                                      'assignments' => options['poll_options']['assignments'],
-                                      'lifetime_in_seconds' => options['poll_options']['lifetime_in_seconds'],
-                                      'reward' => options['poll_options']['reward'],
-                                      'payload' => inbound_event && inbound_event.payload,
-                                      'metadata' => { 'type' => 'poll',
-                                                      'original_hit' => hit_id,
-                                                      'answers' => assignments.map(&:answers),
-                                                      'event_id' => inbound_event && inbound_event.id }
-
-                log "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}.  Original HIT: #{hit_id}", :inbound_event => inbound_event
-              else
-                if options[:separate_answers]
-                  payload['answers'].each.with_index do |answer, index|
-                    sub_payload = payload.dup
-                    sub_payload.delete('answers')
-                    sub_payload['answer'] = answer
-                    event = create_event :payload => sub_payload
-                    log "Event emitted with answer ##{index}", :outbound_event => event, :inbound_event => inbound_event
-                  end
-                else
-                  event = create_event :payload => payload
-                  log "Event emitted with answer(s)", :outbound_event => event, :inbound_event => inbound_event
-                end
+              poll_hit = create_hit(
+                'title' => options['poll_options']['title'],
+                'description' => options['poll_options']['instructions'],
+                'questions' => questions,
+                'assignments' => options['poll_options']['assignments'],
+                'lifetime_in_seconds' => options['poll_options']['lifetime_in_seconds'],
+                'reward' => options['poll_options']['reward'],
+                'payload' => inbound_event && inbound_event.payload,
+                'metadata' => {
+                  'type' => 'poll',
+                  'original_hit' => hit_id,
+                  'answers' => assignments.map(&:answers),
+                  'event_id' => inbound_event && inbound_event.id
+                }
+              )
+
+              log(
+                "Poll HIT created with ID #{poll_hit.id} and URL #{poll_hit.url}.  Original HIT: #{hit_id}",
+                inbound_event:
+              )
+            elsif options[:separate_answers]
+              payload['answers'].each.with_index do |answer, index|
+                sub_payload = payload.dup
+                sub_payload.delete('answers')
+                sub_payload['answer'] = answer
+                event = create_event payload: sub_payload
+                log("Event emitted with answer ##{index}", outbound_event: event, inbound_event:)
               end
+            else
+              event = create_event(payload:)
+              log("Event emitted with answer(s)", outbound_event: event, inbound_event:)
             end
+          end
 
-            assignments.each(&:approve!)
-            hit.dispose!
+          assignments.each(&:approve!)
+          hit.dispose!
 
-            memory['hits'].delete(hit_id)
-          end
+          memory['hits'].delete(hit_id)
         end
       end
 
       def all_questions_are_numeric?
         interpolated['hit']['questions'].all? do |question|
           question['selections'].all? do |selection|
-            selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
+            value = selection['key']
+            value == value.to_f.to_s || value == value.to_i.to_s
           end
         end
       end
 
       def create_basic_hit(event = nil)
-        hit = create_hit 'title' => options['hit']['title'],
-                         'description' => options['hit']['description'],
-                         'questions' => options['hit']['questions'],
-                         'assignments' => options['hit']['assignments'],
-                         'lifetime_in_seconds' => options['hit']['lifetime_in_seconds'],
-                         'reward' => options['hit']['reward'],
-                         'payload' => event && event.payload,
-                         'metadata' => { 'event_id' => event && event.id }
-
-        log "HIT created with ID #{hit.id} and URL #{hit.url}", :inbound_event => event
+        hit = create_hit(
+          'title' => options['hit']['title'],
+          'description' => options['hit']['description'],
+          'questions' => options['hit']['questions'],
+          'assignments' => options['hit']['assignments'],
+          'lifetime_in_seconds' => options['hit']['lifetime_in_seconds'],
+          'reward' => options['hit']['reward'],
+          'payload' => event && event.payload,
+          'metadata' => { 'event_id' => event && event.id }
+        )
+
+        log("HIT created with ID #{hit.id} and URL #{hit.url}", inbound_event: event)
       end
 
       def create_hit(opts = {})
@@ -406,13 +445,13 @@ def create_hit(opts = {})
         title = interpolate_string(opts['title'], payload).strip
         description = interpolate_string(opts['description'], payload).strip
         questions = interpolate_options(opts['questions'], payload)
-        hit = RTurk::Hit.create(title: title) do |hit|
+        hit = RTurk::Hit.create(title:) do |hit|
           hit.max_assignments = (opts['assignments'] || 1).to_i
           hit.description = description
           hit.lifetime = (opts['lifetime_in_seconds'] || 24 * 60 * 60).to_i
-          hit.question_form AgentQuestionForm.new(:title => title, :description => description, :questions => questions)
+          hit.question_form AgentQuestionForm.new(title:, description:, questions:)
           hit.reward = (opts['reward'] || 0.05).to_f
-          #hit.qualifications.add :approval_rate, { :gt => 80 }
+          # hit.qualifications.add :approval_rate, { gt: 80 }
         end
         memory['hits'] ||= {}
         memory['hits'][hit.id] = opts['metadata'] || {}
diff --git a/app/models/agents/imap_folder_agent.rb b/app/models/agents/imap_folder_agent.rb
index 70d3c8d7f6..101aa9291b 100644
--- a/app/models/agents/imap_folder_agent.rb
+++ b/app/models/agents/imap_folder_agent.rb
@@ -14,7 +14,7 @@ class ImapFolderAgent < Agent
 
     default_schedule "every_30m"
 
-    description <<-MD
+    description <<~MD
       The Imap Folder Agent checks an IMAP server in specified folders and creates Events based on new mails found since the last run. In the first visit to a folder, this agent only checks for the initial status and does not create events.
 
       Specify an IMAP server to connect with `host`, and set `ssl` to true if the server supports IMAP over SSL.  Specify `port` if you need to connect to a port other than standard (143 or 993 depending on the `ssl` value), and specify login credentials in `username` and `password`.
@@ -50,7 +50,7 @@ class ImapFolderAgent < Agent
           If this key is unspecified or set to null, it is ignored.
 
       - `has_attachment`
-      
+
           Setting this to true or false means only mails that does or does not have an attachment are selected.
 
           If this key is unspecified or set to null, it is ignored.
@@ -73,7 +73,7 @@ class ImapFolderAgent < Agent
       Also, in order to avoid duplicated notification it keeps a list of Message-Id's of 100 most recent mails, so if multiple mails of the same Message-Id are found, you will only see one event out of them.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       Events look like this:
 
           {
@@ -132,10 +132,8 @@ def validate_options
       end
 
       %w[ssl mark_as_read delete include_raw_mail].each { |key|
-        if options[key].present?
-          if boolify(options[key]).nil?
-            errors.add(:base, '%s must be a boolean value' % key)
-          end
+        if options[key].present? && boolify(options[key]).nil?
+          errors.add(:base, '%s must be a boolean value' % key)
         end
       }
 
@@ -175,7 +173,7 @@ def validate_options
             when String
               begin
                 Regexp.new(value)
-              rescue
+              rescue StandardError
                 errors.add(:base, 'conditions.%s contains an invalid regexp' % key)
               end
             else
@@ -187,7 +185,7 @@ def validate_options
               when String
                 begin
                   glob_match?(pattern, '')
-                rescue
+                rescue StandardError
                   errors.add(:base, 'conditions.%s contains an invalid glob pattern' % key)
                 end
               else
@@ -207,7 +205,10 @@ def validate_options
       end
 
       if options['expected_update_period_in_days'].present?
-        errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days'])
+        errors.add(
+          :base,
+          "Invalid expected_update_period_in_days format"
+        ) unless is_positive_integer?(options['expected_update_period_in_days'])
       end
     end
 
@@ -240,14 +241,14 @@ def check
             value.present? or next true
             re = Regexp.new(value)
             matched_part = body_parts.find { |part|
-               if m = re.match(part.scrubbed(:decoded))
-                 m.names.each { |name|
-                   matches[name] = m[name]
-                 }
-                 true
-               else
-                 false
-               end
+              if m = re.match(part.scrubbed(:decoded))
+                m.names.each { |name|
+                  matches[name] = m[name]
+                }
+                true
+              else
+                false
+              end
             }
           when 'from', 'to', 'cc'
             value.present? or next true
@@ -266,7 +267,7 @@ def check
           when 'has_attachment'
             boolify(value) == mail.has_attachment?
           when 'is_unread'
-            true  # already filtered out by each_unread_mail
+            true # already filtered out by each_unread_mail
           else
             log 'Unknown condition key ignored: %s' % key
             true
@@ -295,7 +296,12 @@ def check
             'from' => mail.from_addrs.first,
             'to' => mail.to_addrs,
             'cc' => mail.cc_addrs,
-            'date' => (mail.date.iso8601 rescue nil),
+            'date' =>
+              begin
+                mail.date.iso8601
+              rescue StandardError
+                nil
+              end,
             'mime_type' => mime_type,
             'body' => body,
             'matches' => matches,
@@ -309,12 +315,12 @@ def check
           if interpolated['event_headers'].present?
             headers = mail.header.each_with_object({}) { |field, hash|
               name = field.name
-              hash[name] = (v = hash[name]) ? "#{v}\n#{field.value.to_s}" : field.value.to_s
+              hash[name] = (v = hash[name]) ? "#{v}\n#{field.value}" : field.value.to_s
             }
             payload.update(event_headers_payload(headers))
           end
 
-          create_event payload: payload
+          create_event(payload:)
 
           notified << mail.message_id if mail.message_id
         end
@@ -346,7 +352,7 @@ def each_unread_mail
       port = (Integer(port) if port.present?)
 
       log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
-      Client.open(host, port: port, ssl: ssl) { |imap|
+      Client.open(host, port:, ssl:) { |imap|
         log "Logging in as #{username}"
         if service
           imap.authenticate('XOAUTH2', username, password)
@@ -355,7 +361,8 @@ def each_unread_mail
         end
 
         # 'lastseen' keeps a hash of { uidvalidity => lastseenuid, ... }
-        lastseen, seen = self.lastseen, self.make_seen
+        lastseen = self.lastseen
+        seen = self.make_seen
 
         # 'notified' keeps an array of message-ids of {IDCACHE_SIZE}
         # most recent notified mails.
@@ -384,8 +391,8 @@ def each_unread_mail
           seen[uidvalidity] = lastseenuid
           is_unread = boolify(interpolated['conditions']['is_unread'])
 
-          uids = imap.uid_fetch((lastseenuid + 1)..-1, 'FLAGS').
-                 each_with_object([]) { |data, ret|
+          uids = imap.uid_fetch((lastseenuid + 1)..-1, 'FLAGS')
+            .each_with_object([]) { |data, ret|
             uid, flags = data.attr.values_at('UID', 'FLAGS')
             seen[uidvalidity] = uid
             next if uid <= lastseenuid
@@ -430,8 +437,8 @@ def lastseen
       Seen.new(memory['lastseen'])
     end
 
-    def lastseen= value
-      memory.delete('seen')  # obsolete key
+    def lastseen=(value)
+      memory.delete('seen') # obsolete key
       memory['lastseen'] = value
     end
 
@@ -443,7 +450,7 @@ def notified
       Notified.new(memory['notified'])
     end
 
-    def notified= value
+    def notified=(value)
       memory['notified'] = value
     end
 
@@ -540,7 +547,7 @@ class Message < SimpleDelegator
       module Scrubbed
         def scrubbed(method)
           (@scrubbed ||= {})[method.to_sym] ||=
-            __send__(method).try(:scrub) { |bytes| "<#{bytes.unpack('H*')[0]}>" }
+            __send__(method).try(:scrub) { |bytes| "<#{bytes.unpack1('H*')}>" }
         end
       end
 
@@ -588,7 +595,7 @@ def body_parts(mime_types = DEFAULT_BODY_MIME_TYPES)
           [mail]
         end.select { |part|
           if part.multipart? || part.attachment? || !part.text? ||
-             !mime_types.include?(part.mime_type)
+              !mime_types.include?(part.mime_type)
             false
           else
             part.extend(Scrubbed)
diff --git a/app/models/agents/jabber_agent.rb b/app/models/agents/jabber_agent.rb
index 52e04e509c..634c9f19b4 100644
--- a/app/models/agents/jabber_agent.rb
+++ b/app/models/agents/jabber_agent.rb
@@ -7,7 +7,7 @@ class JabberAgent < Agent
 
     gem_dependency_check { defined?(Jabber) }
 
-    description <<-MD
+    description <<~MD
       The Jabber Agent will send any events it receives to your Jabber/XMPP IM account.
 
       #{'## Include `xmpp4r` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -23,7 +23,7 @@ class JabberAgent < Agent
       Have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       `event` will be set to either `on_join`, `on_leave`, `on_message`, `on_room_message` or `on_subject`
 
           {
@@ -36,12 +36,12 @@ class JabberAgent < Agent
 
     def default_options
       {
-        'jabber_server'   => '127.0.0.1',
-        'jabber_port'     => '5222',
-        'jabber_sender'   => 'huginn@localhost',
+        'jabber_server' => '127.0.0.1',
+        'jabber_port' => '5222',
+        'jabber_sender' => 'huginn@localhost',
         'jabber_receiver' => 'muninn@localhost',
         'jabber_password' => '',
-        'message'         => 'It will be {{temp}} out tomorrow',
+        'message' => 'It will be {{temp}} out tomorrow',
         'expected_receive_period_in_days' => "2"
       }
     end
@@ -71,7 +71,7 @@ def validate_options
     end
 
     def deliver(text)
-      client.send Jabber::Message::new(interpolated['jabber_receiver'], text).set_type(:chat)
+      client.send Jabber::Message.new(interpolated['jabber_receiver'], text).set_type(:chat)
     end
 
     def start_worker?
@@ -81,7 +81,7 @@ def start_worker?
     private
 
     def client
-      Jabber::Client.new(Jabber::JID::new(interpolated['jabber_sender'])).tap do |sender|
+      Jabber::Client.new(Jabber::JID.new(interpolated['jabber_sender'])).tap do |sender|
         sender.connect(interpolated['jabber_server'], interpolated['jabber_port'] || '5222')
         sender.auth interpolated['jabber_password']
       end
@@ -96,7 +96,7 @@ def body(event)
     end
 
     class Worker < LongRunnable::Worker
-      IGNORE_MESSAGES_FOR=5
+      IGNORE_MESSAGES_FOR = 5
 
       def setup
         require 'xmpp4r/muc/helper/simplemucclient'
@@ -124,7 +124,7 @@ def message_handler(event, args)
         time, nick, message = normalize_args(event, args)
 
         AgentRunner.with_connection do
-          agent.create_event(payload: {event: event, time: time, nick: nick, message: message})
+          agent.create_event(payload: { event:, time:, nick:, message: })
         end
       end
 
@@ -139,6 +139,7 @@ def client
       end
 
       private
+
       def normalize_args(event, args)
         case event
         when :on_join, :on_leave
diff --git a/app/models/agents/java_script_agent.rb b/app/models/agents/java_script_agent.rb
index 9a5022af96..4be444f6cc 100644
--- a/app/models/agents/java_script_agent.rb
+++ b/app/models/agents/java_script_agent.rb
@@ -11,7 +11,7 @@ class JavaScriptAgent < Agent
 
     gem_dependency_check { defined?(MiniRacer) }
 
-    description <<-MD
+    description <<~MD
       The JavaScript Agent allows you to write code in JavaScript that can create and receive events.  If other Agents aren't meeting your needs, try this one!
 
       #{'## Include `mini_racer` in your Gemfile to use this Agent!' if dependencies_missing?}
@@ -45,7 +45,8 @@ class JavaScriptAgent < Agent
     def validate_options
       cred_name = credential_referenced_by_code
       if cred_name
-        errors.add(:base, "The credential '#{cred_name}' referenced by code cannot be found") unless credential(cred_name).present?
+        errors.add(:base,
+                   "The credential '#{cred_name}' referenced by code cannot be found") unless credential(cred_name).present?
       else
         errors.add(:base, "The 'code' option is required") unless options['code'].present?
       end
@@ -114,22 +115,22 @@ def execute_js(js_function, incoming_events = [])
       context = MiniRacer::Context.new
       context.eval(setup_javascript)
 
-      context.attach("doCreateEvent", -> (y) { create_event(payload: clean_nans(JSON.parse(y))).payload.to_json })
+      context.attach("doCreateEvent", ->(y) { create_event(payload: clean_nans(JSON.parse(y))).payload.to_json })
       context.attach("getIncomingEvents", -> { incoming_events.to_json })
       context.attach("getOptions", -> { interpolated.to_json })
-      context.attach("doLog", -> (x) { log x })
-      context.attach("doError", -> (x) { error x })
+      context.attach("doLog", ->(x) { log x })
+      context.attach("doError", ->(x) { error x })
       context.attach("getMemory", -> { memory.to_json })
-      context.attach("setMemoryKey", -> (x, y) { memory[x] = clean_nans(y) })
-      context.attach("setMemory", -> (x) { memory.replace(clean_nans(x)) })
-      context.attach("deleteKey", -> (x) { memory.delete(x).to_json })
-      context.attach("escapeHtml", -> (x) { CGI.escapeHTML(x) })
-      context.attach("unescapeHtml", -> (x) { CGI.unescapeHTML(x) })
-      context.attach('getCredential', -> (k) { credential(k); })
-      context.attach('setCredential', -> (k, v) { set_credential(k, v) })
+      context.attach("setMemoryKey", ->(x, y) { memory[x] = clean_nans(y) })
+      context.attach("setMemory", ->(x) { memory.replace(clean_nans(x)) })
+      context.attach("deleteKey", ->(x) { memory.delete(x).to_json })
+      context.attach("escapeHtml", ->(x) { CGI.escapeHTML(x) })
+      context.attach("unescapeHtml", ->(x) { CGI.unescapeHTML(x) })
+      context.attach('getCredential', ->(k) { credential(k); })
+      context.attach('setCredential', ->(k, v) { set_credential(k, v) })
 
       if (options['language'] || '').downcase == 'coffeescript'
-        context.eval(CoffeeScript.compile code)
+        context.eval(CoffeeScript.compile(code))
       else
         context.eval(code)
       end
@@ -223,20 +224,19 @@ def setup_javascript
     end
 
     def log_errors
-      begin
-        yield
-      rescue MiniRacer::Error => e
-        error "JavaScript error: #{e.message}"
-      end
+      yield
+    rescue MiniRacer::Error => e
+      error "JavaScript error: #{e.message}"
     end
 
     def clean_nans(input)
-      if input.is_a?(Array)
-        input.map {|v| clean_nans(v) }
-      elsif input.is_a?(Hash)
-        input.inject({}) { |m, (k, v)| m[k] = clean_nans(v); m }
-      elsif input.is_a?(Float) && input.nan?
-        'NaN'
+      case input
+      when Array
+        input.map { |v| clean_nans(v) }
+      when Hash
+        input.transform_values { |v| clean_nans(v) }
+      when Float
+        input.nan? ? 'NaN' : input
       else
         input
       end
diff --git a/app/models/agents/jira_agent.rb b/app/models/agents/jira_agent.rb
old mode 100644
new mode 100755
index 45280f7013..b00dbd6115
--- a/app/models/agents/jira_agent.rb
+++ b/app/models/agents/jira_agent.rb
@@ -10,11 +10,11 @@ class JiraAgent < Agent
 
     cannot_receive_events!
 
-    description <<-MD
+    description <<~MD
       The Jira Agent subscribes to Jira issue updates.
 
       - `jira_url` specifies the full URL of the jira installation, including https://
-      - `jql` is an optional Jira Query Language-based filter to limit the flow of events. See [JQL Docs](https://confluence.atlassian.com/display/JIRA/Advanced+Searching) for details. 
+      - `jql` is an optional Jira Query Language-based filter to limit the flow of events. See [JQL Docs](https://confluence.atlassian.com/display/JIRA/Advanced+Searching) for details.#{' '}
       - `username` and `password` are optional, and may need to be specified if your Jira instance is read-protected
       - `timeout` is an optional parameter that specifies how long the request processing may take in minutes.
 
@@ -23,18 +23,18 @@ class JiraAgent < Agent
       NOTE: upon the first execution, the agent will fetch everything available by the JQL query. So if it's not desirable, limit the `jql` query by date.
     MD
 
-    event_description <<-MD
+    event_description <<~MD
       Events are the raw JSON generated by Jira REST API
 
-      {
-        "expand": "editmeta,renderedFields,transitions,changelog,operations",
-        "id": "80127",
-        "self": "https://jira.atlassian.com/rest/api/2/issue/80127",
-        "key": "BAM-3512",
-        "fields": {
-          ...
-        }
-      }
+          {
+            "expand": "editmeta,renderedFields,transitions,changelog,operations",
+            "id": "80127",
+            "self": "https://jira.atlassian.com/rest/api/2/issue/80127",
+            "key": "BAM-3512",
+            "fields": {
+              ...
+            }
+          }
     MD
 
     default_schedule "every_10m"
@@ -42,7 +42,7 @@ class JiraAgent < Agent
 
     def default_options
       {
-        'username'  => '',
+        'username' => '',
         'password' => '',
         'jira_url' => 'https://jira.atlassian.com',
         'jql' => '',
@@ -52,9 +52,11 @@ def default_options
     end
 
     def validate_options
-      errors.add(:base, "you need to specify password if user name is set") if options['username'].present? and not options['password'].present?
+      errors.add(:base,
+                 "you need to specify password if user name is set") if options['username'].present? and !options['password'].present?
       errors.add(:base, "you need to specify your jira URL") unless options['jira_url'].present?
-      errors.add(:base, "you need to specify the expected update period") unless options['expected_update_period_in_days'].present?
+      errors.add(:base,
+                 "you need to specify the expected update period") unless options['expected_update_period_in_days'].present?
       errors.add(:base, "you need to specify request timeout") unless options['timeout'].present?
     end
 
@@ -74,41 +76,50 @@ def check
 
         # this check is more precise than in get_issues()
         # see get_issues() for explanation
-        if not last_run or updated > last_run
-          create_event :payload => issue
+        if !last_run or updated > last_run
+          create_event payload: issue
         end
       end
 
       memory[:last_run] = current_run
     end
 
-  private
+    private
+
     def request_url(jql, start_at)
-      "#{interpolated[:jira_url]}/rest/api/2/search?jql=#{CGI::escape(jql)}&fields=*all&startAt=#{start_at}"
+      "#{interpolated[:jira_url]}/rest/api/2/search?jql=#{CGI.escape(jql)}&fields=*all&startAt=#{start_at}"
     end
 
     def request_options
-      ropts = { headers: {"User-Agent" => user_agent} }
+      ropts = { headers: { "User-Agent" => user_agent } }
 
       if !interpolated[:username].empty?
-        ropts = ropts.merge({:basic_auth => {:username =>interpolated[:username], :password=>interpolated[:password]}})
+        ropts = ropts.merge({
+          basic_auth: {
+            username: interpolated[:username],
+            password: interpolated[:password]
+          }
+        })
       end
 
       ropts
     end
 
     def get(url, options)
-        response = HTTParty.get(url, options)
-
-        if response.code == 400
-          raise RuntimeError.new("Jira error: #{response['errorMessages']}") 
-        elsif response.code == 403
-          raise RuntimeError.new("Authentication failed: Forbidden (403)")
-        elsif response.code != 200
-          raise RuntimeError.new("Request failed: #{response}")
-        end
+      response = HTTParty.get(url, options)
+
+      case response.code
+      when 200
+        # OK
+      when 400
+        raise "Jira error: #{response['errorMessages']}"
+      when 403
+        raise "Authentication failed: Forbidden (403)"
+      else
+        raise "Request failed: #{response}"
+      end
 
-        response
+      response
     end
 
     def get_issues(since)
@@ -120,7 +131,7 @@ def get_issues(since)
       # earlier and filter out unnecessary ones at a later
       # stage. Fortunately, the 'updated' field has GMT
       # offset
-      since -= 24*60*60 if since
+      since -= 24 * 60 * 60 if since
 
       jql = ""
 
@@ -138,26 +149,24 @@ def get_issues(since)
         response = get(request_url(jql, startAt), request_options)
 
         if response['issues'].length == 0
-          request_limit+=1
+          request_limit += 1
         end
 
         if request_limit > MAX_EMPTY_REQUESTS
-          raise RuntimeError.new("There is no progress while fetching issues")
+          raise "There is no progress while fetching issues"
         end
 
         if Time.now > start_time + interpolated['timeout'].to_i * 60
-          raise RuntimeError.new("Timeout exceeded while fetching issues")
+          raise "Timeout exceeded while fetching issues"
         end
 
         issues += response['issues']
         startAt += response['issues'].length
- 
+
         break if startAt >= response['total']
       end
 
       issues
     end
-
   end
 end
-
diff --git a/app/models/agents/jq_agent.rb b/app/models/agents/jq_agent.rb
index 5f41e52285..72f62058d3 100644
--- a/app/models/agents/jq_agent.rb
+++ b/app/models/agents/jq_agent.rb
@@ -29,7 +29,7 @@ def self.jq_info
 
     gem_dependency_check { jq_version }
 
-    description <<-MD
+    description <<~MD
       The Jq Agent allows you to process incoming Events with [jq](https://stedolan.github.io/jq/) the JSON processor. (#{jq_info})
 
       It allows you to filter, transform and restructure Events in the way you want using jq's powerful features.
@@ -97,7 +97,8 @@ def self.jq_info
 
     def validate_options
       errors.add(:base, "filter needs to be present.") if !options['filter'].is_a?(String)
-      errors.add(:base, "variables must be a hash if present.") if options.key?('variables') && !options['variables'].is_a?(Hash)
+      errors.add(:base,
+                 "variables must be a hash if present.") if options.key?('variables') && !options['variables'].is_a?(Hash)
     end
 
     def default_options
@@ -196,7 +197,7 @@ def process_event(event)
           log "Creating #{results.size} events"
 
           results.each do |payload|
-            create_event payload: payload
+            create_event(payload:)
           end
         end
       end
diff --git a/app/models/agents/json_parse_agent.rb b/app/models/agents/json_parse_agent.rb
index e5d1ae9f36..7fbd13d7da 100644
--- a/app/models/agents/json_parse_agent.rb
+++ b/app/models/agents/json_parse_agent.rb
@@ -5,7 +5,7 @@ class JsonParseAgent < Agent
     cannot_be_scheduled!
     can_dry_run!
 
-    description <<-MD
+    description <<~MD
       The JSON Parse Agent parses a JSON string and emits the data in a new event or merge with with the original event.
 
       `data` is the JSON to parse. Use [Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) templating to specify the JSON string.
@@ -24,7 +24,7 @@ def default_options
     end
 
     event_description do
-      "Events will looks like this:\n\n    %s" % Utils.pretty_print(interpolated['data_key'] => {parsed: 'object'})
+      "Events will looks like this:\n\n    %s" % Utils.pretty_print(interpolated['data_key'] => { parsed: 'object' })
     end
 
     form_configurable :data
@@ -34,7 +34,7 @@ def default_options
     def validate_options
       errors.add(:base, "data needs to be present") if options['data'].blank?
       errors.add(:base, "data_key needs to be present") if options['data_key'].blank?
-      if options['mode'].present? && !options['mode'].to_s.include?('{{') && !%[clean merge].include?(options['mode'].to_s)
+      if options['mode'].present? && !options['mode'].to_s.include?('{{') && !%(clean merge).include?(options['mode'].to_s)
         errors.add(:base, "mode must be 'clean' or 'merge'")
       end
     end
@@ -45,13 +45,11 @@ def working?
 
     def receive(incoming_events)
       incoming_events.each do |event|
-        begin
-          mo = interpolated(event)
-          existing_payload = mo['mode'].to_s == 'merge' ? event.payload : {}
-          create_event payload: existing_payload.merge({ mo['data_key'] => JSON.parse(mo['data']) })
-        rescue JSON::JSONError => e
-          error("Could not parse JSON: #{e.class} '#{e.message}'")
-        end
+        mo = interpolated(event)
+        existing_payload = mo['mode'].to_s == 'merge' ? event.payload : {}
+        create_event payload: existing_payload.merge({ mo['data_key'] => JSON.parse(mo['data']) })
+      rescue JSON::JSONError => e
+        error("Could not parse JSON: #{e.class} '#{e.message}'")
       end
     end
   end
diff --git a/app/models/agents/key_value_store_agent.rb b/app/models/agents/key_value_store_agent.rb
index ec6f689d12..20a99bf7dd 100644
--- a/app/models/agents/key_value_store_agent.rb
+++ b/app/models/agents/key_value_store_agent.rb
@@ -6,7 +6,7 @@ class KeyValueStoreAgent < Agent
     cannot_be_scheduled!
     cannot_create_events!
 
-    description <<-MD
+    description <<~MD
       The Key-Value Store Agent is a data storage that keeps an associative array in its memory.  It receives events to store values and provides the data to other agents as an object via Liquid Templating.
 
       Liquid templates specified in the `key` and `value` options are evaluated for each received event to be stored in the memory.
diff --git a/app/models/agents/liquid_output_agent.rb b/app/models/agents/liquid_output_agent.rb
index c3f01c5d05..3f699fba27 100644
--- a/app/models/agents/liquid_output_agent.rb
+++ b/app/models/agents/liquid_output_agent.rb
@@ -8,24 +8,24 @@ class LiquidOutputAgent < Agent
     DATE_UNITS = %w[second seconds minute minutes hour hours day days week weeks month months year years]
 
     description  do
-      <<-MD
-        The Liquid Output Agent outputs events through a Liquid template you provide.  Use it to create a HTML page, or a json feed, or anything else that can be rendered as a string from your stream of Huginn data. 
+      <<~MD
+        The Liquid Output Agent outputs events through a Liquid template you provide.  Use it to create a HTML page, or a json feed, or anything else that can be rendered as a string from your stream of Huginn data.
 
         This Agent will output data at:
 
-        `https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id: user_id, secret: ':secret', format: :any_extension)}`
+        `https://#{ENV['DOMAIN']}#{Rails.application.routes.url_helpers.web_requests_path(agent_id: ':id', user_id:, secret: ':secret', format: :any_extension)}`
 
         where `:secret` is the secret specified in your options.  You can use any extension you wish.
 
         Options:
 
-          * `secret` - A token that the requestor must provide for light-weight authentication.
-          * `expected_receive_period_in_days` - How often you expect data to be received by this Agent from other Agents.
-          * `content` - The content to display when someone requests this page.
-          * `mime_type` - The mime type to use when someone requests this page.
-          * `response_headers` - An object with any custom response headers. (example: `{"Access-Control-Allow-Origin": "*"}`)
-          * `mode` - The behavior that determines what data is passed to the Liquid template.
-          * `event_limit` - A limit applied to the events passed to a template when in "Last X events" mode. Can be a count like "1", or an amount of time like "1 day" or "5 minutes".
+        * `secret` - A token that the requestor must provide for light-weight authentication.
+        * `expected_receive_period_in_days` - How often you expect data to be received by this Agent from other Agents.
+        * `content` - The content to display when someone requests this page.
+        * `mime_type` - The mime type to use when someone requests this page.
+        * `response_headers` - An object with any custom response headers. (example: `{"Access-Control-Allow-Origin": "*"}`)
+        * `mode` - The behavior that determines what data is passed to the Liquid template.
+        * `event_limit` - A limit applied to the events passed to a template when in "Last X events" mode. Can be a count like "1", or an amount of time like "1 day" or "5 minutes".
 
         # Liquid Templating
 
@@ -35,61 +35,56 @@ class LiquidOutputAgent < Agent
 
         ### Merge events
 
-          The data for incoming events will be merged. So if two events come in like this:
+        The data for incoming events will be merged. So if two events come in like this:
 
-```
-{ 'a' => 'b',  'c' => 'd'}
-{ 'a' => 'bb', 'e' => 'f'}
-```
+        ```
+        { 'a' => 'b',  'c' => 'd'}
+        { 'a' => 'bb', 'e' => 'f'}
+        ```
 
-          The final result will be:
+        The final result will be:
 
-```
-{ 'a' => 'bb', 'c' => 'd', 'e' => 'f'}
-```
+        ```
+        { 'a' => 'bb', 'c' => 'd', 'e' => 'f'}
+        ```
 
         This merged version will be passed to the Liquid template.
 
         ### Last event in
 
-          The data from the last event will be passed to the template.
+        The data from the last event will be passed to the template.
 
         ### Last X events
 
-          All of the events received by this agent will be passed to the template
-          as the ```events``` array.
+        All of the events received by this agent will be passed to the template as the `events` array.
 
-          The number of events can be controlled via the ```event_limit``` option.
-          If ```event_limit``` is an integer X, the last X events will be passed
-          to the template.  If ```event_limit``` is an integer with a unit of
-          measure like "1 day" or "5 minutes" or "9 years", a date filter will
-          be applied to the events passed to the template.  If no ```event_limit```
-          is provided, then all of the events for the agent will be passed to
-          the template. 
-          
-          For performance, the maximum ```event_limit``` allowed is 1000.
+        The number of events can be controlled via the `event_limit` option.
+        If `event_limit` is an integer X, the last X events will be passed to the template.
+        If `event_limit` is an integer with a unit of measure like "1 day" or "5 minutes" or "9 years", a date filter will be applied to the events passed to the template.
+        If no `event_limit` is provided, then all of the events for the agent will be passed to the template.
 
+        For performance, the maximum `event_limit` allowed is 1000.
       MD
     end
 
     def default_options
-      content = <
-  {% for event in events %}
-    
-      {{ event.title }}
-      Click here to see
-    
-  {% endfor %}
-
-EOF
+      content = <<~EOF
+        When you use the "Last event in" or "Merge events" option, you can use variables from the last event received, like this:
+
+        Name: {{name}}
+        Url:  {{url}}
+
+        If you use the "Last X Events" mode, a set of events will be passed to your Liquid template.  You can use them like this:
+
+        
+          {% for event in events %}
+            
+              
+              
+            
+          {% endfor %}
+        
{{ event.title }}Click here to see
+ EOF { "secret" => "a-secret-key", "expected_receive_period_in_days" => 2, @@ -104,7 +99,7 @@ def default_options form_configurable :expected_receive_period_in_days form_configurable :content, type: :text form_configurable :mime_type - form_configurable :mode, type: :array, values: [ 'Last event in', 'Merge events', 'Last X events'] + form_configurable :mode, type: :array, values: ['Last event in', 'Merge events', 'Last X events'] form_configurable :event_limit def working? @@ -125,35 +120,49 @@ def validate_options end unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0 - errors.add(:base, "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working") + errors.add( + :base, + "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working" + ) end - if options['event_limit'].present? - if (Integer(options['event_limit']) rescue false) == false && date_limit.blank? - errors.add(:base, "Event limit must be an integer that is less than 1001 or an integer plus a valid unit.") - elsif (options['event_limit'].to_i > 1000) - errors.add(:base, "For performance reasons, you cannot have an event limit greater than 1000.") + event_limit = + if value = options['event_limit'].presence + begin + Integer(value) + rescue StandardError + false + end end - else + + if event_limit == false && date_limit.blank? + errors.add(:base, "Event limit must be an integer that is less than 1001 or an integer plus a valid unit.") + elsif event_limit && event_limit > 1000 + errors.add(:base, "For performance reasons, you cannot have an event limit greater than 1000.") end end def receive(incoming_events) return unless ['merge events', 'last event in'].include?(mode) + memory['last_event'] ||= {} incoming_events.each do |event| - case mode - when 'merge events' - memory['last_event'] = memory['last_event'].merge(event.payload) - else - memory['last_event'] = event.payload - end + memory['last_event'] = + case mode + when 'merge events' + memory['last_event'].merge(event.payload) + else + event.payload + end end end def receive_web_request(params, method, format) - valid_authentication?(params) ? [liquified_content, 200, mime_type, interpolated['response_headers'].presence] - : [unauthorized_content(format), 401] + if valid_authentication?(params) + [liquified_content, 200, mime_type, interpolated['response_headers'].presence] + else + [unauthorized_content(format), 401] + end end private @@ -163,8 +172,11 @@ def mode end def unauthorized_content(format) - format =~ /json/ ? { error: "Not Authorized" } - : "Not Authorized" + if format =~ /json/ + { error: "Not Authorized" } + else + "Not Authorized" + end end def valid_authentication?(params) @@ -193,19 +205,29 @@ def data_for_liquid_template end def count_limit - limit = Integer(options['event_limit']) rescue 1000 + limit = begin + Integer(options['event_limit']) + rescue StandardError + 1000 + end limit <= 1000 ? limit : 1000 end def date_limit return nil unless options['event_limit'].to_s.include?(' ') + value, unit = options['event_limit'].split(' ') - value = Integer(value) rescue nil + value = begin + Integer(value) + rescue StandardError + nil + end return nil unless value + unit = unit.to_s.downcase return nil unless DATE_UNITS.include?(unit) + value.send(unit.to_sym).ago end - end end diff --git a/app/models/agents/local_file_agent.rb b/app/models/agents/local_file_agent.rb index 4bf5f32498..63a7548431 100644 --- a/app/models/agents/local_file_agent.rb +++ b/app/models/agents/local_file_agent.rb @@ -13,7 +13,7 @@ def self.should_run? end description do - <<-MD + <<~MD The LocalFileAgent can watch a file/directory for changes or emit an event for every file in that directory. When receiving an event it writes the received data into a file. `mode` determines if the agent is emitting events for (changed) files or writing received event data to disk. @@ -41,22 +41,23 @@ def self.should_run? end event_description do - "Events will looks like this:\n\n %s" % if boolify(interpolated['watch']) - Utils.pretty_print( - "file_pointer" => { - "file" => "/tmp/test/filename", - "agent_id" => id - }, - "event_type" => "modified/added/removed" - ) - else - Utils.pretty_print( - "file_pointer" => { - "file" => "/tmp/test/filename", - "agent_id" => id - } - ) - end + "Events will looks like this:\n\n " + + if boolify(interpolated['watch']) + Utils.pretty_print( + "file_pointer" => { + "file" => "/tmp/test/filename", + "agent_id" => id + }, + "event_type" => "modified/added/removed" + ) + else + Utils.pretty_print( + "file_pointer" => { + "file" => "/tmp/test/filename", + "agent_id" => id + } + ) + end end def default_options @@ -69,8 +70,8 @@ def default_options } end - form_configurable :mode, type: :array, values: %w(read write) - form_configurable :watch, type: :array, values: %w(true false) + form_configurable :mode, type: :array, values: %w[read write] + form_configurable :watch, type: :array, values: %w[true false] form_configurable :path, type: :string form_configurable :append, type: :boolean form_configurable :data, type: :string @@ -98,6 +99,7 @@ def working? def check return if interpolated['mode'] != 'read' || boolify(interpolated['watch']) || !should_run? return unless check_path_existance(true) + if File.directory?(expanded_path) Dir.glob(File.join(expanded_path, '*')).select { |f| File.file?(f) } else @@ -109,6 +111,7 @@ def check def receive(incoming_events) return if interpolated['mode'] != 'write' || !should_run? + incoming_events.each do |event| mo = interpolated(event) expanded_path = File.expand_path(mo['path']) @@ -153,7 +156,8 @@ def should_run?(log = true) class Worker < LongRunnable::Worker def setup require 'listen' - @listener = Listen.to(*listen_options, &method(:callback)) + path, options = listen_options + @listener = Listen.to(path, **options, &method(:callback)) end def run @@ -173,7 +177,7 @@ def callback(*changes) AgentRunner.with_connection do changes.zip([:modified, :added, :removed]).each do |files, event_type| files.each do |file| - agent.create_event payload: agent.get_file_pointer(file).merge(event_type: event_type) + agent.create_event payload: agent.get_file_pointer(file).merge(event_type:) end end agent.touch(:last_check_at) @@ -182,9 +186,15 @@ def callback(*changes) def listen_options if File.directory?(agent.expanded_path) - [agent.expanded_path, ignore!: [] ] + [ + agent.expanded_path, + ignore!: [] + ] else - [File.dirname(agent.expanded_path), { ignore!: [], only: /\A#{Regexp.escape(File.basename(agent.expanded_path))}\z/ } ] + [ + File.dirname(agent.expanded_path), + ignore!: [], only: /\A#{Regexp.escape(File.basename(agent.expanded_path))}\z/ + ] end end end diff --git a/app/models/agents/manual_event_agent.rb b/app/models/agents/manual_event_agent.rb index 0d7a67629a..6704ffb6cf 100644 --- a/app/models/agents/manual_event_agent.rb +++ b/app/models/agents/manual_event_agent.rb @@ -3,7 +3,7 @@ class ManualEventAgent < Agent cannot_be_scheduled! cannot_receive_events! - description <<-MD + description <<~MD The Manual Event Agent is used to manually create Events for testing or other purposes. Connect this Agent to other Agents and create Events using the UI provided on this Agent's Summary page. @@ -24,15 +24,16 @@ def handle_details_post(params) if params['payload'] json = interpolate_options(JSON.parse(params['payload'])) if json['payloads'] && (json.keys - ['payloads']).length > 0 - { :success => false, :error => "If you provide the 'payloads' key, please do not provide any other keys at the top level." } + { success: false, + error: "If you provide the 'payloads' key, please do not provide any other keys at the top level." } else [json['payloads'] || json].flatten.each do |payload| - create_event(:payload => payload) + create_event(payload:) end - { :success => true } + { success: true } end else - { :success => false, :error => "You must provide a JSON payload" } + { success: false, error: "You must provide a JSON payload" } end end diff --git a/app/models/agents/mqtt_agent.rb b/app/models/agents/mqtt_agent.rb index 67aa5f1666..cc74b2e341 100644 --- a/app/models/agents/mqtt_agent.rb +++ b/app/models/agents/mqtt_agent.rb @@ -1,11 +1,10 @@ -# encoding: utf-8 require "json" module Agents class MqttAgent < Agent gem_dependency_check { defined?(MQTT) } - description <<-MD + description <<~MD The MQTT Agent allows both publication and subscription to an MQTT topic. #{'## Include `mqtt` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -59,19 +58,19 @@ class MqttAgent < Agent Find out more detail on [subscription wildcards](http://www.eclipse.org/paho/files/mqttdoc/Cclient/wildcard.html) MD - event_description <<-MD + event_description <<~MD Events are simply nested MQTT payloads. For example, an MQTT payload for Owntracks -
{
-        "topic": "owntracks/kcqlmkgx/Dan",
-        "message": {"_type": "location", "lat": "-34.8493644", "lon": "138.5218119", "tst": "1401771049", "acc": "50.0", "batt": "31", "desc": "Home", "event": "enter"},
-        "time": 1401771051
-      }
+ { + "topic": "owntracks/kcqlmkgx/Dan", + "message": {"_type": "location", "lat": "-34.8493644", "lon": "138.5218119", "tst": "1401771049", "acc": "50.0", "batt": "31", "desc": "Home", "event": "enter"}, + "time": 1401771051 + } MD def validate_options unless options['uri'].present? && - options['topic'].present? + options['topic'].present? errors.add(:base, "topic and uri are required") end end @@ -84,7 +83,7 @@ def default_options { 'uri' => 'mqtts://user:pass@localhost:8883', 'ssl' => :TLSv1, - 'ca_file' => './ca.pem', + 'ca_file' => './ca.pem', 'cert_file' => './client.crt', 'key_file' => './client.key', 'topic' => 'huginn', @@ -94,16 +93,14 @@ def default_options end def mqtt_client - @client ||= begin - MQTT::Client.new(interpolated['uri']).tap do |c| - if interpolated['ssl'] - c.ssl = interpolated['ssl'].to_sym - c.ca_file = interpolated['ca_file'] - c.cert_file = interpolated['cert_file'] - c.key_file = interpolated['key_file'] - end + @client ||= MQTT::Client.new(interpolated['uri']).tap { |c| + if interpolated['ssl'] + c.ssl = interpolated['ssl'].to_sym + c.ca_file = interpolated['ca_file'] + c.cert_file = interpolated['cert_file'] + c.key_file = interpolated['key_file'] end - end + } end def receive(incoming_events) @@ -114,7 +111,6 @@ def receive(incoming_events) end end - def check last_message = memory['last_message'] mqtt_client.connect @@ -131,7 +127,7 @@ def check # A lot of services generate JSON, so try that. begin payload = JSON.parse(payload) - rescue + rescue StandardError end create_event payload: { @@ -151,6 +147,5 @@ def check self.memory['last_message'] = last_message save! end - end end diff --git a/app/models/agents/pdf_info_agent.rb b/app/models/agents/pdf_info_agent.rb index 6738bf4fa7..2129133bf9 100644 --- a/app/models/agents/pdf_info_agent.rb +++ b/app/models/agents/pdf_info_agent.rb @@ -3,13 +3,12 @@ module Agents class PdfInfoAgent < Agent - gem_dependency_check { defined?(HyPDF) } cannot_be_scheduled! no_bulk_receive! - description <<-MD + description <<~MD The PDF Info Agent returns the metadata contained within a given PDF file, using HyPDF. #{'## Include the `hypdf` gem in your `Gemfile` to use PDFInfo Agents.' if dependencies_missing?} @@ -19,24 +18,24 @@ class PdfInfoAgent < Agent It works by acting on events that contain a key `url` in their payload, and runs the [pdfinfo](https://devcenter.heroku.com/articles/hypdf#pdfinfo) command on them. MD - event_description <<-MD - This will change based on the metadata in the pdf. - - { "Title"=>"Everyday Rails Testing with RSpec", - "Author"=>"Aaron Sumner", - "Creator"=>"LaTeX with hyperref package", - "Producer"=>"xdvipdfmx (0.7.8)", - "CreationDate"=>"Fri Aug 2 05", - "32"=>"50 2013", - "Tagged"=>"no", - "Pages"=>"150", - "Encrypted"=>"no", - "Page size"=>"612 x 792 pts (letter)", - "Optimized"=>"no", - "PDF version"=>"1.5", - "url": "your url" - } - MD + event_description do + "This will change based on the metadata in the pdf.\n\n " + + Utils.pretty_print({ + "Title" => "Everyday Rails Testing with RSpec", + "Author" => "Aaron Sumner", + "Creator" => "LaTeX with hyperref package", + "Producer" => "xdvipdfmx (0.7.8)", + "CreationDate" => "Fri Aug 2 05", + "32" => "50 2013", + "Tagged" => "no", + "Pages" => "150", + "Encrypted" => "no", + "Page size" => "612 x 792 pts (letter)", + "Optimized" => "no", + "PDF version" => "1.5", + "url": "your url" + }) + end def working? !recent_error_logs? @@ -57,12 +56,12 @@ def receive(incoming_events) def check_url(in_url, payload) return unless in_url.present? + Array(in_url).each do |url| log "Fetching #{url}" info = HyPDF.pdfinfo(open(url)) - create_event :payload => info.merge(payload) + create_event payload: info.merge(payload) end end - end end diff --git a/app/models/agents/peak_detector_agent.rb b/app/models/agents/peak_detector_agent.rb index fff1ca8af7..ba8bbadfe0 100644 --- a/app/models/agents/peak_detector_agent.rb +++ b/app/models/agents/peak_detector_agent.rb @@ -1,12 +1,10 @@ -require 'pp' - module Agents class PeakDetectorAgent < Agent cannot_be_scheduled! DEFAULT_SEARCH_URL = 'https://twitter.com/search?q={q}' - description <<-MD + description <<~MD The Peak Detector Agent will watch for peaks in an event stream. When a peak is detected, the resulting Event will have a payload message of `message`. You can include extractions in the message, for example: `I saw a bar of: {{foo.bar}}`, have a look at the [Wiki](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) for details. The `value_path` value is a [JSONPath](http://goessner.net/articles/JsonPath/) to the value of interest. `group_by_path` is a JSONPath that will be used to group values, if present. @@ -20,7 +18,7 @@ class PeakDetectorAgent < Agent You may set `search_url` to point to something else than Twitter search, using the URI Template syntax defined in [RFC 6570](https://tools.ietf.org/html/rfc6570). Default value is `#{DEFAULT_SEARCH_URL}` where `{q}` will be replaced with group name. MD - event_description <<-MD + event_description <<~MD Events look like: { @@ -37,7 +35,7 @@ def validate_options end begin tmpl = search_url - rescue => e + rescue StandardError => e errors.add(:base, "search_url must be a valid URI template: #{e.message}") else unless tmpl.keys.include?('q') @@ -81,13 +79,18 @@ def check_for_peak(group, event) return if memory['data'][group].length <= options['min_events'].to_i if memory['peaks'][group].empty? || memory['peaks'][group].last < event.created_at.to_i - peak_spacing - average_value, standard_deviation = stats_for(group, :skip_last => 1) + average_value, standard_deviation = stats_for(group, skip_last: 1) newest_value, newest_time = memory['data'][group][-1].map(&:to_f) if newest_value > average_value + std_multiple * standard_deviation memory['peaks'][group] << newest_time memory['peaks'][group].reject! { |p| p <= newest_time - window_duration } - create_event :payload => { 'message' => interpolated(event)['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s } + create_event payload: { + 'message' => interpolated(event)['message'], + 'peak' => newest_value, + 'peak_time' => newest_time, + 'grouped_by' => group.to_s + } end end end @@ -132,19 +135,20 @@ def peak_spacing end def group_for(event) - ((interpolated['group_by_path'].present? && Utils.value_at(event.payload, interpolated['group_by_path'])) || 'no_group') + group_by_path = interpolated['group_by_path'].presence + (group_by_path && Utils.value_at(event.payload, group_by_path)) || 'no_group' end def remember(group, event) memory['data'] ||= {} memory['data'][group] ||= [] - memory['data'][group] << [ Utils.value_at(event.payload, interpolated['value_path']).to_f, event.created_at.to_i ] + memory['data'][group] << [Utils.value_at(event.payload, interpolated['value_path']).to_f, event.created_at.to_i] cleanup group end def cleanup(group) newest_time = memory['data'][group].last.last - memory['data'][group].reject! { |value, time| time <= newest_time - window_duration } + memory['data'][group].reject! { |_value, time| time <= newest_time - window_duration } end end end diff --git a/app/models/agents/phantom_js_cloud_agent.rb b/app/models/agents/phantom_js_cloud_agent.rb index 1f1903bebc..10005feece 100644 --- a/app/models/agents/phantom_js_cloud_agent.rb +++ b/app/models/agents/phantom_js_cloud_agent.rb @@ -11,7 +11,7 @@ class PhantomJsCloudAgent < Agent default_schedule 'every_12h' - description <<-MD + description <<~MD This Agent generates [PhantomJs Cloud](https://phantomjscloud.com/) URLs that can be used to render JavaScript-heavy webpages for content extraction. URLs generated by this Agent are formulated in accordance with the [PhantomJs Cloud API](https://phantomjscloud.com/docs/index.html). @@ -37,8 +37,9 @@ class PhantomJsCloudAgent < Agent As this agent only provides a limited subset of the most commonly used options, you can follow [this guide](https://github.com/huginn/huginn/wiki/Browser-Emulation-Using-PhantomJS-Cloud) to make full use of additional options PhantomJsCloud provides. MD - event_description <<-MD + event_description <<~MD Events look like this: + { "url": "..." } diff --git a/app/models/agents/post_agent.rb b/app/models/agents/post_agent.rb index 662b24a915..06b4d646d7 100644 --- a/app/models/agents/post_agent.rb +++ b/app/models/agents/post_agent.rb @@ -13,7 +13,7 @@ class PostAgent < Agent default_schedule "never" description do - <<-MD + <<~MD A Post Agent receives events from other agents (or runs periodically), merges those events with the [Liquid-interpolated](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) contents of `payload`, and sends the results as POST (or GET) requests to a specified url. To skip merging in the incoming event, but still send the interpolated payload, set `no_merge` to `true`. The `post_url` field must specify where you would like to send requests. Please include the URI scheme (`http` or `https`). @@ -56,16 +56,17 @@ class PostAgent < Agent MD end - event_description <<-MD + event_description <<~MD Events look like this: - { - "status": 200, - "headers": { - "Content-Type": "text/html", - ... - }, - "body": "Some data..." - } + + { + "status": 200, + "headers": { + "Content-Type": "text/html", + ... + }, + "body": "Some data..." + } Original event contents will be merged when `output_mode` is set to `merge`. MD @@ -89,7 +90,7 @@ def default_options def working? return false if recent_error_logs? - + if interpolated['expected_receive_period_in_days'].present? return false unless last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago end @@ -106,7 +107,8 @@ def validate_options errors.add(:base, "post_url is a required field") end - if options['payload'].present? && %w[get delete].include?(method) && !(options['payload'].is_a?(Hash) || options['payload'].is_a?(Array)) + if options['payload'].present? && %w[get + delete].include?(method) && !(options['payload'].is_a?(Hash) || options['payload'].is_a?(Array)) errors.add(:base, "if provided, payload must be a hash or an array") end @@ -134,11 +136,11 @@ def validate_options errors.add(:base, "method must be 'post', 'get', 'put', 'delete', or 'patch'") end - if options['no_merge'].present? && !%[true false].include?(options['no_merge'].to_s) + if options['no_merge'].present? && !%(true false).include?(options['no_merge'].to_s) errors.add(:base, "if provided, no_merge must be 'true' or 'false'") end - if options['output_mode'].present? && !options['output_mode'].to_s.include?('{') && !%[clean merge].include?(options['output_mode'].to_s) + if options['output_mode'].present? && !options['output_mode'].to_s.include?('{') && !%(clean merge).include?(options['output_mode'].to_s) errors.add(:base, "if provided, output_mode must be 'clean' or 'merge'") end @@ -173,7 +175,8 @@ def handle(data, event = Event.new, headers) case method when 'get', 'delete' - params, body = data, nil + params = data + body = nil when 'post', 'put', 'patch' params = nil diff --git a/app/models/agents/public_transport_agent.rb b/app/models/agents/public_transport_agent.rb index b4f69a7238..4ebbb85da9 100644 --- a/app/models/agents/public_transport_agent.rb +++ b/app/models/agents/public_transport_agent.rb @@ -6,7 +6,7 @@ class PublicTransportAgent < Agent default_schedule "every_2m" - description <<-MD + description <<~MD The Public Transport Request Agent generates Events based on NextBus GPS transit predictions. Specify the following user settings: @@ -17,7 +17,7 @@ class PublicTransportAgent < Agent First, select an agency by visiting [http://www.nextbus.com/predictor/adaAgency.jsp](http://www.nextbus.com/predictor/adaAgency.jsp) and finding your transit system. Once you find it, copy the part of the URL after `?a=`. For example, for the San Francisco MUNI system, you would end up on [http://www.nextbus.com/predictor/adaDirection.jsp?a=**sf-muni**](http://www.nextbus.com/predictor/adaDirection.jsp?a=sf-muni) and copy "sf-muni". Put that into this Agent's agency setting. - Next, find the stop tags that you care about. + Next, find the stop tags that you care about. Select your destination and lets use the n-judah route. The link should be [http://www.nextbus.com/predictor/adaStop.jsp?a=sf-muni&r=N](http://www.nextbus.com/predictor/adaStop.jsp?a=sf-muni&r=N) Once you find it, copy the part of the URL after `r=`. @@ -42,18 +42,22 @@ class PublicTransportAgent < Agent alert_window_in_minutes: 5 MD - event_description <<-MD - Events look like this: - { "routeTitle":"N-Judah", - "stopTag":"5215", - "prediction": - {"epochTime":"1389622846689", - "seconds":"3454","minutes":"57","isDeparture":"false", - "affectedByLayover":"true","dirTag":"N__OB4KJU","vehicle":"1489", - "block":"9709","tripTag":"5840086" - } - } - MD + event_description "Events look like this:\n\n " + + Utils.pretty_print({ + "routeTitle": "N-Judah", + "stopTag": "5215", + "prediction": { + "epochTime": "1389622846689", + "seconds": "3454", + "minutes": "57", + "isDeparture": "false", + "affectedByLayover": "true", + "dirTag": "N__OB4KJU", + "vehicle": "1489", + "block": "9709", + "tripTag": "5840086" + } + }) def check_url query = URI.encode_www_form([ @@ -65,27 +69,27 @@ def check_url end def stops - interpolated["stops"].collect{|a| a.split("|").last} + interpolated["stops"].collect { |a| a.split("|").last } end def check hydra = Typhoeus::Hydra.new - request = Typhoeus::Request.new(check_url, :followlocation => true) + request = Typhoeus::Request.new(check_url, followlocation: true) request.on_success do |response| page = Nokogiri::XML response.body predictions = page.css("//prediction") predictions.each do |pr| parent = pr.parent.parent - vals = {"routeTitle" => parent["routeTitle"], "stopTag" => parent["stopTag"]} - if pr["minutes"] && pr["minutes"].to_i < interpolated["alert_window_in_minutes"].to_i - vals = vals.merge Hash.from_xml(pr.to_xml) - if not_already_in_memory?(vals) - create_event(:payload => vals) - log "creating event..." - update_memory(vals) - else - log "not creating event since already in memory" - end + vals = { "routeTitle" => parent["routeTitle"], "stopTag" => parent["stopTag"] } + next unless pr["minutes"] && pr["minutes"].to_i < interpolated["alert_window_in_minutes"].to_i + + vals = vals.merge Hash.from_xml(pr.to_xml) + if not_already_in_memory?(vals) + create_event(payload: vals) + log "creating event..." + update_memory(vals) + else + log "not creating event since already in memory" end end end @@ -100,20 +104,26 @@ def update_memory(vals) def cleanup_old_memory self.memory["existing_routes"] ||= [] - self.memory["existing_routes"].reject!{|h| h["currentTime"].to_time <= (Time.now - 2.hours)} + time = 2.hours.ago + self.memory["existing_routes"].reject! { |h| h["currentTime"].to_time <= time } end def add_to_memory(vals) - self.memory["existing_routes"] ||= [] - self.memory["existing_routes"] << {"stopTag" => vals["stopTag"], "tripTag" => vals["prediction"]["tripTag"], "epochTime" => vals["prediction"]["epochTime"], "currentTime" => Time.now} + (self.memory["existing_routes"] ||= []) << { + "stopTag" => vals["stopTag"], + "tripTag" => vals["prediction"]["tripTag"], + "epochTime" => vals["prediction"]["epochTime"], + "currentTime" => Time.now + } end def not_already_in_memory?(vals) m = self.memory["existing_routes"] || [] - m.select{|h| h['stopTag'] == vals["stopTag"] && - h['tripTag'] == vals["prediction"]["tripTag"] && - h['epochTime'] == vals["prediction"]["epochTime"] - }.count == 0 + m.select { |h| + h['stopTag'] == vals["stopTag"] && + h['tripTag'] == vals["prediction"]["tripTag"] && + h['epochTime'] == vals["prediction"]["epochTime"] + }.count == 0 end def default_options diff --git a/app/models/agents/pushbullet_agent.rb b/app/models/agents/pushbullet_agent.rb index 07b2460615..f30f5e239e 100644 --- a/app/models/agents/pushbullet_agent.rb +++ b/app/models/agents/pushbullet_agent.rb @@ -10,13 +10,13 @@ class PushbulletAgent < Agent API_BASE = 'https://api.pushbullet.com/v2/' TYPE_TO_ATTRIBUTES = { - 'note' => [:title, :body], - 'link' => [:title, :body, :url], - 'address' => [:name, :address] + 'note' => [:title, :body], + 'link' => [:title, :body, :url], + 'address' => [:name, :address] } class Unauthorized < StandardError; end - description <<-MD + description <<~MD The Pushbullet agent sends pushes to a pushbullet device To authenticate you need to either the `api_key` or create a `pushbullet_api_key` credential, you can find yours at your account page: @@ -60,9 +60,11 @@ def default_options def validate_options errors.add(:base, "you need to specify a pushbullet api_key") if options['api_key'].blank? errors.add(:base, "you need to specify a device_id") if options['device_id'].blank? - errors.add(:base, "you need to specify a valid message type") if options['type'].blank? or not ['note', 'link', 'address'].include?(options['type']) + errors.add(:base, "you need to specify a valid message type") if options['type'].blank? || + !['note', 'link', 'address'].include?(options['type']) TYPE_TO_ATTRIBUTES[options['type']].each do |attr| - errors.add(:base, "you need to specify '#{attr.to_s}' for the type '#{options['type']}'") if options[attr].blank? + errors.add(:base, + "you need to specify '#{attr}' for the type '#{options['type']}'") if options[attr].blank? end end @@ -75,7 +77,7 @@ def validate_api_key def complete_device_id devices - .map { |d| {text: d['nickname'], id: d['iden']} } + .map { |d| { text: d['nickname'], id: d['iden'] } } .unshift(text: 'All Devices', id: '__ALL__') end @@ -92,6 +94,7 @@ def receive(incoming_events) end private + def safely yield rescue Unauthorized => e @@ -101,6 +104,7 @@ def safely def request(http_method, method, options) response = JSON.parse(HTTParty.send(http_method, API_BASE + method, options).body) raise Unauthorized, response['error']['message'] if response['error'].present? + response end @@ -113,20 +117,21 @@ def devices def create_device return if options['device_id'].present? + safely do - response = request(:post, 'devices', basic_auth.merge(body: {nickname: 'Huginn', type: 'stream'})) + response = request(:post, 'devices', basic_auth.merge(body: { nickname: 'Huginn', type: 'stream' })) self.options[:device_id] = response['iden'] end end def basic_auth - {basic_auth: {username: interpolated[:api_key].presence || credential('pushbullet_api_key'), password: ''}} + { basic_auth: { username: interpolated[:api_key].presence || credential('pushbullet_api_key'), password: '' } } end def query_options(event) mo = interpolated(event) dev_ident = mo[:device_id] == "__ALL__" ? '' : mo[:device_id] - basic_auth.merge(body: {device_iden: dev_ident, type: mo[:type]}.merge(payload(mo))) + basic_auth.merge(body: { device_iden: dev_ident, type: mo[:type] }.merge(payload(mo))) end def payload(mo) diff --git a/app/models/agents/pushover_agent.rb b/app/models/agents/pushover_agent.rb index acffcb5ff6..6d85f7f600 100644 --- a/app/models/agents/pushover_agent.rb +++ b/app/models/agents/pushover_agent.rb @@ -5,10 +5,9 @@ class PushoverAgent < Agent cannot_create_events! no_bulk_receive! - API_URL = 'https://api.pushover.net/1/messages.json' - description <<-MD + description <<~MD The Pushover Agent receives and collects events and sends them via push notification to a user/group. **You need a Pushover API Token:** [https://pushover.net/apps/build](https://pushover.net/apps/build) @@ -88,15 +87,15 @@ def receive(incoming_events) retry expire ].each do |key| - if value = String.try_convert(interpolated[key].presence) - case key - when 'url' - value.slice!(512..-1) - when 'url_title' - value.slice!(100..-1) - end - post_params[key] = value + value = String.try_convert(interpolated[key].presence) or next + + case key + when 'url' + value.slice!(512..-1) + when 'url_title' + value.slice!(100..-1) end + post_params[key] = value end # html is special because String.try_convert(true) gives nil (not even "nil", just nil) if value = interpolated['html'].presence diff --git a/app/models/agents/read_file_agent.rb b/app/models/agents/read_file_agent.rb index a7903408c7..ec87c50f83 100644 --- a/app/models/agents/read_file_agent.rb +++ b/app/models/agents/read_file_agent.rb @@ -13,7 +13,7 @@ def default_options end description do - <<-MD + <<~MD The ReadFileAgent takes events from `FileHandling` agents, reads the file, and emits the contents as a string. `data_key` specifies the key of the emitted event which contains the file contents. @@ -22,10 +22,12 @@ def default_options MD end - event_description <<-MD - { - "data" => '...' - } + event_description <<~MD + Events look like: + + { + "data" => '...' + } MD form_configurable :data_key, type: :string @@ -43,6 +45,7 @@ def working? def receive(incoming_events) incoming_events.each do |event| next unless io = get_io(event) + create_event payload: { interpolated['data_key'] => io.read } end end diff --git a/app/models/agents/rss_agent.rb b/app/models/agents/rss_agent.rb index 9a3ac794ee..1bb7174b14 100644 --- a/app/models/agents/rss_agent.rb +++ b/app/models/agents/rss_agent.rb @@ -11,7 +11,7 @@ class RssAgent < Agent DEFAULT_EVENTS_ORDER = [['{{date_published}}', 'time'], ['{{last_updated}}', 'time']] description do - <<-MD + <<~MD The RSS Agent consumes RSS feeds and emits events when they change. This agent, using [Feedjira](https://github.com/feedjira/feedjira) as a base, can parse various types of RSS and Atom feeds and has some special handlers for FeedBurner, iTunes RSS, and so on. However, supported fields are limited by its general and abstract nature. For complex feeds with additional field types, we recommend using a WebsiteAgent. See [this example](https://github.com/huginn/huginn/wiki/Agent-configuration-examples#itunes-trailers). @@ -49,7 +49,7 @@ def default_options } end - event_description <<-MD + event_description <<~MD Events look like: { @@ -131,11 +131,13 @@ def validate_options errors.add(:base, "url is required") unless options['url'].present? unless options['expected_update_period_in_days'].present? && options['expected_update_period_in_days'].to_i > 0 - errors.add(:base, "Please provide 'expected_update_period_in_days' to indicate how many days can pass without an update before this Agent is considered to not be working") + errors.add(:base, + "Please provide 'expected_update_period_in_days' to indicate how many days can pass without an update before this Agent is considered to not be working") end if options['remembered_id_count'].present? && options['remembered_id_count'].to_i < 1 - errors.add(:base, "Please provide 'remembered_id_count' as a number bigger than 0 indicating how many IDs should be saved to distinguish between new and old IDs in RSS feeds. Delete option to use default (500).") + errors.add(:base, + "Please provide 'remembered_id_count' as a number bigger than 0 indicating how many IDs should be saved to distinguish between new and old IDs in RSS feeds. Delete option to use default (500).") end validate_web_request_options! @@ -161,17 +163,15 @@ def check_urls(urls) max_events = (interpolated['max_events_per_run'].presence || 0).to_i urls.each do |url| - begin - response = faraday.get(url) - if response.success? - feed = Feedjira.parse(preprocessed_body(response)) - new_events.concat feed_to_events(feed) - else - error "Failed to fetch #{url}: #{response.inspect}" - end - rescue => e - error "Failed to fetch #{url} with message '#{e.message}': #{e.backtrace}" + response = faraday.get(url) + if response.success? + feed = Feedjira.parse(preprocessed_body(response)) + new_events.concat feed_to_events(feed) + else + error "Failed to fetch #{url}: #{response.inspect}" end + rescue StandardError => e + error "Failed to fetch #{url} with message '#{e.message}': #{e.backtrace}" end events = sort_events(new_events).select.with_index { |event, index| @@ -210,7 +210,8 @@ def preprocessed_body(response) else # Encoding is already known, so do not let the parser detect # it from the XML declaration in the content. - body.sub!(/(?\A\u{FEFF}?\s*<\?xml(?:\s+\w+(?\s*=\s*(?:'[^']*'|"[^"]*")))*?)\s+encoding\g/, '\\k') + body.sub!(/(?\A\u{FEFF}?\s*<\?xml(?:\s+\w+(?\s*=\s*(?:'[^']*'|"[^"]*")))*?)\s+encoding\g/, + '\\k') end body end @@ -226,7 +227,7 @@ def feed_data(feed) { id: feed.feed_id, - type: type, + type:, url: feed.url, links: feed.links, title: feed.title, @@ -257,15 +258,15 @@ def itunes_feed_data(feed) itunes_summary language ].each { |attr| - if value = feed.try(attr).presence - data[attr] = - case attr - when :itunes_summary - clean_fragment(value) - else - value - end - end + next unless value = feed.try(attr).presence + + data[attr] = + case attr + when :itunes_summary + clean_fragment(value) + else + value + end } end data diff --git a/app/models/agents/s3_agent.rb b/app/models/agents/s3_agent.rb index bf3291ba61..34db21cd06 100644 --- a/app/models/agents/s3_agent.rb +++ b/app/models/agents/s3_agent.rb @@ -11,7 +11,7 @@ class S3Agent < Agent gem_dependency_check { defined?(Aws::S3) } description do - <<-MD + <<~MD The S3Agent can watch a bucket for changes or emit an event for every file in that bucket. When receiving events, it writes the data into a file on S3. #{'## Include `aws-sdk-core` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -41,22 +41,23 @@ class S3Agent < Agent end event_description do - "Events will looks like this:\n\n %s" % if boolify(interpolated['watch']) - Utils.pretty_print({ - "file_pointer" => { - "file" => "filename", - "agent_id" => id - }, - "event_type" => "modified/added/removed" - }) - else - Utils.pretty_print({ - "file_pointer" => { - "file" => "filename", - "agent_id" => id - } - }) - end + "Events will looks like this:\n\n " + + if boolify(interpolated['watch']) + Utils.pretty_print({ + "file_pointer" => { + "file" => "filename", + "agent_id" => id + }, + "event_type" => "modified/added/removed" + }) + else + Utils.pretty_print({ + "file_pointer" => { + "file" => "filename", + "agent_id" => id + } + }) + end end def default_options @@ -70,11 +71,12 @@ def default_options } end - form_configurable :mode, type: :array, values: %w(read write) + form_configurable :mode, type: :array, values: %w[read write] form_configurable :access_key_id, roles: :validatable form_configurable :access_key_secret, roles: :validatable - form_configurable :region, type: :array, values: %w(us-east-1 us-west-1 us-west-2 eu-west-1 eu-central-1 ap-southeast-1 ap-southeast-2 ap-northeast-1 ap-northeast-2 sa-east-1) - form_configurable :watch, type: :array, values: %w(true false) + form_configurable :region, type: :array, + values: %w[us-east-1 us-west-1 us-west-2 eu-west-1 eu-central-1 ap-southeast-1 ap-southeast-2 ap-northeast-1 ap-northeast-2 sa-east-1] + form_configurable :watch, type: :array, values: %w[true false] form_configurable :bucket, roles: :completable form_configurable :filename form_configurable :data @@ -114,7 +116,7 @@ def validate_access_key_secret end def complete_bucket - (buckets || []).collect { |room| {text: room.name, id: room.name} } + (buckets || []).collect { |room| { text: room.name, id: room.name } } end def working? @@ -123,9 +125,10 @@ def working? def check return if interpolated['mode'] != 'read' + contents = safely do - get_bucket_contents - end + get_bucket_contents + end if boolify(interpolated['watch']) watch(contents) else @@ -141,6 +144,7 @@ def get_io(file) def receive(incoming_events) return if interpolated['mode'] != 'write' + incoming_events.each do |event| safely do mo = interpolated(event) @@ -155,7 +159,7 @@ def safely yield rescue Aws::S3::Errors::AccessDenied => e error("Could not access '#{interpolated['bucket']}' #{e.class} #{e.message}") - rescue Aws::S3::Errors::ServiceError =>e + rescue Aws::S3::Errors::ServiceError => e error("#{e.class}: #{e.message}") end @@ -175,7 +179,7 @@ def watch(contents) end contents.delete(key) end - contents.each do |key, etag| + contents.each do |key, _etag| create_event payload: get_file_pointer(key).merge(event_type: :added) end diff --git a/app/models/agents/scheduler_agent.rb b/app/models/agents/scheduler_agent.rb index 3d06ae1bea..3d1e824d4a 100644 --- a/app/models/agents/scheduler_agent.rb +++ b/app/models/agents/scheduler_agent.rb @@ -12,7 +12,7 @@ class SchedulerAgent < Agent cattr_reader :second_precision_enabled - description <<-MD + description <<~MD The Scheduler Agent periodically takes an action on target Agents according to a user-defined schedule. # Action types diff --git a/app/models/agents/sentiment_agent.rb b/app/models/agents/sentiment_agent.rb index ad48dd0c17..78fe12e897 100644 --- a/app/models/agents/sentiment_agent.rb +++ b/app/models/agents/sentiment_agent.rb @@ -6,7 +6,7 @@ class SentimentAgent < Agent cannot_be_scheduled! - description <<-MD + description <<~MD The Sentiment Agent generates `good-bad` (psychological valence or happiness index), `active-passive` (arousal), and `strong-weak` (dominance) score. It will output a value between 1 and 9. It will only work on English content. Make sure the content this agent is analyzing is of sufficient length to get respectable results. @@ -14,7 +14,7 @@ class SentimentAgent < Agent Provide a JSONPath in `content` field where content is residing and set `expected_receive_period_in_days` to the maximum number of days you would allow to be passed between events being received by this agent. MD - event_description <<-MD + event_description <<~MD Events look like: { @@ -41,17 +41,22 @@ def receive(incoming_events) incoming_events.each do |event| Utils.values_at(event.payload, interpolated['content']).each do |content| sent_values = sentiment_values anew, content - create_event :payload => { 'content' => content, - 'valence' => sent_values[0], - 'arousal' => sent_values[1], - 'dominance' => sent_values[2], - 'original_event' => event.payload } + create_event payload: { + 'content' => content, + 'valence' => sent_values[0], + 'arousal' => sent_values[1], + 'dominance' => sent_values[2], + 'original_event' => event.payload + } end end end def validate_options - errors.add(:base, "content and expected_receive_period_in_days must be present") unless options['content'].present? && options['expected_receive_period_in_days'].present? + errors.add( + :base, + "content and expected_receive_period_in_days must be present" + ) unless options['content'].present? && options['expected_receive_period_in_days'].present? end def self.sentiment_hash @@ -67,18 +72,18 @@ def self.sentiment_hash def sentiment_values(anew, text) valence, arousal, dominance, freq = [0] * 4 text.downcase.strip.gsub(/[^a-z ]/, "").split.each do |word| - if anew.has_key? word - valence += anew[word][0] - arousal += anew[word][1] - dominance += anew[word][2] - freq += 1 - end + next unless anew.has_key? word + + valence += anew[word][0] + arousal += anew[word][1] + dominance += anew[word][2] + freq += 1 end if valence != 0 - [valence/freq, arousal/freq, dominance/freq] + [valence / freq, arousal / freq, dominance / freq] else ["Insufficient data for meaningful answer"] * 3 end end end -end \ No newline at end of file +end diff --git a/app/models/agents/shell_command_agent.rb b/app/models/agents/shell_command_agent.rb index 672dd9b48a..7e827cb3d4 100644 --- a/app/models/agents/shell_command_agent.rb +++ b/app/models/agents/shell_command_agent.rb @@ -5,12 +5,11 @@ class ShellCommandAgent < Agent can_dry_run! no_bulk_receive! - def self.should_run? ENV['ENABLE_INSECURE_AGENTS'] == "true" end - description <<-MD + description <<~MD The Shell Command Agent will execute commands on your local system, returning the output. `command` specifies the command (either a shell command line string or an array of command line arguments) to be executed, and `path` will tell ShellCommandAgent in what directory to run this command. The content of `stdin` will be fed to the command via the standard input. @@ -33,26 +32,26 @@ def self.should_run? You can enable this Agent in your .env file by setting `ENABLE_INSECURE_AGENTS` to `true`. MD - event_description <<-MD - Events look like this: + event_description <<~MD + Events look like this: - { - "command": "pwd", - "path": "/home/Huginn", - "exit_status": 0, - "errors": "", - "output": "/home/Huginn" - } + { + "command": "pwd", + "path": "/home/Huginn", + "exit_status": 0, + "errors": "", + "output": "/home/Huginn" + } MD def default_options { - 'path' => "/", - 'command' => "pwd", - 'unbundle' => false, - 'suppress_on_failure' => false, - 'suppress_on_empty_output' => false, - 'expected_update_period_in_days' => 1 + 'path' => "/", + 'command' => "pwd", + 'unbundle' => false, + 'suppress_on_failure' => false, + 'suppress_on_empty_output' => false, + 'expected_update_period_in_days' => 1 } end @@ -98,7 +97,7 @@ def handle(opts, event = nil) path = opts['path'] stdin = opts['stdin'] - result, errors, exit_status = run_command(path, command, stdin, interpolated.slice(:unbundle).symbolize_keys) + result, errors, exit_status = run_command(path, command, stdin, **interpolated.slice(:unbundle).symbolize_keys) payload = { 'command' => command, @@ -109,7 +108,7 @@ def handle(opts, event = nil) } unless suppress_event?(payload) - created_event = create_event payload: payload + created_event = create_event(payload:) end log("Ran '#{command}' under '#{path}'", outbound_event: created_event, inbound_event: event) @@ -146,7 +145,7 @@ def run_command(path, command, stdin, unbundle: false) _, status = Process.wait2(pid) exit_status = status.exitstatus - rescue => e + rescue StandardError => e errors = e.to_s result = ''.freeze exit_status = nil diff --git a/app/models/agents/slack_agent.rb b/app/models/agents/slack_agent.rb index 7c6b18505d..04ad3b573d 100644 --- a/app/models/agents/slack_agent.rb +++ b/app/models/agents/slack_agent.rb @@ -10,7 +10,7 @@ class SlackAgent < Agent gem_dependency_check { defined?(Slack) } - description <<-MD + description <<~MD The Slack Agent lets you receive events and send notifications to [Slack](https://slack.com/). #{'## Include `slack-notifier` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -38,7 +38,7 @@ def default_options def validate_options unless options['webhook_url'].present? || - (options['auth_token'].present? && options['team_name'].present?) # compatibility + (options['auth_token'].present? && options['team_name'].present?) # compatibility errors.add(:base, "webhook_url is required") end @@ -65,11 +65,11 @@ def username end def slack_notifier - @slack_notifier ||= Slack::Notifier.new(webhook_url, username: username) + @slack_notifier ||= Slack::Notifier.new(webhook_url, username:) end def filter_options(opts) - opts.select { |key, value| ALLOWED_PARAMS.include? key }.symbolize_keys + opts.select { |key, _value| ALLOWED_PARAMS.include? key }.symbolize_keys end def receive(incoming_events) diff --git a/app/models/agents/stubhub_agent.rb b/app/models/agents/stubhub_agent.rb index f0ba562be3..c4693541a5 100644 --- a/app/models/agents/stubhub_agent.rb +++ b/app/models/agents/stubhub_agent.rb @@ -2,24 +2,25 @@ module Agents class StubhubAgent < Agent cannot_receive_events! - description <<-MD + description <<~MD The StubHub Agent creates an event for a given StubHub Event. It can be used to track how many tickets are available for the event and the minimum and maximum price. All that is required is that you paste in the url from the actual event, e.g. https://www.stubhub.com/outside-lands-music-festival-tickets/outside-lands-music-festival-3-day-pass-san-francisco-golden-gate-park-polo-fields-8-8-2014-9020701/ MD - event_description <<-MD + event_description <<~MD Events looks like this: - { - "url": "https://stubhub.com/valid-event-url" - "name": "Event Name" - "date": "2014-08-01" - "max_price": "999.99" - "min_price": "100.99" - "total_postings": "50" - "total_tickets": "150" - "venue_name": "Venue Name" - } + + { + "url": "https://stubhub.com/valid-event-url" + "name": "Event Name" + "date": "2014-08-01" + "max_price": "999.99" + "min_price": "100.99" + "total_postings": "50" + "total_tickets": "150" + "venue_name": "Venue Name" + } MD default_schedule "every_1d" @@ -29,7 +30,7 @@ def working? end def default_options - { 'url' => 'https://stubhub.com/enter-your-event-here' } + { 'url' => 'https://stubhub.com/enter-your-event-here' } end def validate_options @@ -41,7 +42,7 @@ def url end def check - create_event :payload => fetch_stubhub_data(url) + create_event payload: fetch_stubhub_data(url) end def fetch_stubhub_data(url) @@ -49,7 +50,6 @@ def fetch_stubhub_data(url) end class StubhubFetcher - def self.call(url) new(url).fields end @@ -63,7 +63,7 @@ def event_id end def base_url - 'https://www.stubhub.com/listingCatalog/select/?q=' + 'https://www.stubhub.com/listingCatalog/select/?q=' end def build_url @@ -96,7 +96,6 @@ def fields private attr_reader :url - end end end diff --git a/app/models/agents/telegram_agent.rb b/app/models/agents/telegram_agent.rb index d1a12ecfa8..da65d7d3d4 100644 --- a/app/models/agents/telegram_agent.rb +++ b/app/models/agents/telegram_agent.rb @@ -9,14 +9,14 @@ class TelegramAgent < Agent no_bulk_receive! can_dry_run! - description <<-MD + description <<~MD The Telegram Agent receives and collects events and sends them via [Telegram](https://telegram.org/). It is assumed that events have either a `text`, `photo`, `audio`, `document`, `video` or `group` key. You can use the EventFormattingAgent if your event does not provide these keys. The value of `text` key is sent as a plain text message. You can also tell Telegram how to parse the message with `parse_mode`, set to either `html`, `markdown` or `markdownv2`. The value of `photo`, `audio`, `document` and `video` keys should be a url whose contents will be sent to you. - The value of `group` key should be a list and must consist of 2-10 objects representing an [InputMedia](https://core.telegram.org/bots/api#inputmedia) from the [Telegram Bot API](https://core.telegram.org/bots/api#inputmedia). Be careful: the `caption` field is not covered by the "long message" setting. + The value of `group` key should be a list and must consist of 2-10 objects representing an [InputMedia](https://core.telegram.org/bots/api#inputmedia) from the [Telegram Bot API](https://core.telegram.org/bots/api#inputmedia). Be careful: the `caption` field is not covered by the "long message" setting.#{' '} **Setup** @@ -63,17 +63,31 @@ def validate_auth_token def complete_chat_id response = HTTMultiParty.post(telegram_bot_uri('getUpdates')) return [] unless response['ok'] + response['result'].map { |update| update_to_complete(update) }.uniq end def validate_options errors.add(:base, 'auth_token is required') unless options['auth_token'].present? errors.add(:base, 'chat_id is required') unless options['chat_id'].present? - errors.add(:base, 'caption should be 1024 characters or less') if interpolated['caption'].present? && interpolated['caption'].length > 1024 && (!interpolated['long_message'].present? || interpolated['long_message'] != 'split') - errors.add(:base, "disable_notification has invalid value: should be 'true' or 'false'") if interpolated['disable_notification'].present? && !%w(true false).include?(interpolated['disable_notification']) - errors.add(:base, "disable_web_page_preview has invalid value: should be 'true' or 'false'") if interpolated['disable_web_page_preview'].present? && !%w(true false).include?(interpolated['disable_web_page_preview']) - errors.add(:base, "long_message has invalid value: should be 'split' or 'truncate'") if interpolated['long_message'].present? && !%w(split truncate).include?(interpolated['long_message']) - errors.add(:base, "parse_mode has invalid value: should be 'html', 'markdown' or 'markdownv2'") if interpolated['parse_mode'].present? && !%w(html markdown markdownv2).include?(interpolated['parse_mode']) + errors.add(:base, + 'caption should be 1024 characters or less') if interpolated['caption'].present? && interpolated['caption'].length > 1024 && (!interpolated['long_message'].present? || interpolated['long_message'] != 'split') + errors.add(:base, + "disable_notification has invalid value: should be 'true' or 'false'") if interpolated['disable_notification'].present? && !%w[ + true false + ].include?(interpolated['disable_notification']) + errors.add(:base, + "disable_web_page_preview has invalid value: should be 'true' or 'false'") if interpolated['disable_web_page_preview'].present? && !%w[ + true false + ].include?(interpolated['disable_web_page_preview']) + errors.add(:base, + "long_message has invalid value: should be 'split' or 'truncate'") if interpolated['long_message'].present? && !%w[ + split truncate + ].include?(interpolated['long_message']) + errors.add(:base, + "parse_mode has invalid value: should be 'html', 'markdown' or 'markdownv2'") if interpolated['parse_mode'].present? && !%w[ + html markdown markdownv2 + ].include?(interpolated['parse_mode']) end def working? @@ -99,11 +113,13 @@ def receive(incoming_events) def configure_params(params) params[:chat_id] = interpolated['chat_id'] - params[:disable_notification] = interpolated['disable_notification'] if interpolated['disable_notification'].present? + params[:disable_notification] = + interpolated['disable_notification'] if interpolated['disable_notification'].present? if params.has_key?(:text) - params[:disable_web_page_preview] = interpolated['disable_web_page_preview'] if interpolated['disable_web_page_preview'].present? + params[:disable_web_page_preview] = + interpolated['disable_web_page_preview'] if interpolated['disable_web_page_preview'].present? params[:parse_mode] = interpolated['parse_mode'] if interpolated['parse_mode'].present? - elsif not params.has_key?(:media) + elsif !params.has_key?(:media) params[:caption] = interpolated['caption'] if interpolated['caption'].present? end @@ -115,8 +131,9 @@ def receive_event(event) messages_send = TELEGRAM_ACTIONS.count do |field, _method| payload = event.payload[field] next unless payload.present? + if field == :group - send_telegram_messages field, configure_params(:media => payload) + send_telegram_messages field, configure_params(media: payload) else send_telegram_messages field, configure_params(field => payload) end @@ -163,7 +180,7 @@ def telegram_bot_uri(method) def update_to_complete(update) chat = (update['message'] || update.fetch('channel_post', {})).fetch('chat', {}) - {id: chat['id'], text: chat['title'] || "#{chat['first_name']} #{chat['last_name']}"} + { id: chat['id'], text: chat['title'] || "#{chat['first_name']} #{chat['last_name']}" } end end end diff --git a/app/models/agents/trigger_agent.rb b/app/models/agents/trigger_agent.rb index 9757beceb0..bf6ee7796d 100644 --- a/app/models/agents/trigger_agent.rb +++ b/app/models/agents/trigger_agent.rb @@ -3,9 +3,19 @@ class TriggerAgent < Agent cannot_be_scheduled! can_dry_run! - VALID_COMPARISON_TYPES = %w[regex !regex field=value field>value not\ in] - - description <<-MD + VALID_COMPARISON_TYPES = %w[ + regex + !regex + field=value + field>value + not\ in + ] + + description <<~MD The Trigger Agent will watch for a specific value in an Event payload. The `rules` array contains a mixture of strings and hashes. @@ -32,7 +42,7 @@ class TriggerAgent < Agent Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent. MD - event_description <<-MD + event_description <<~MD Events look like this: { "message": "Your message" } @@ -52,14 +62,19 @@ class TriggerAgent < Agent def validate_options unless options['expected_receive_period_in_days'].present? && - options['rules'].present? && - options['rules'].all? { |rule| valid_rule?(rule) } - errors.add(:base, "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required") + options['rules'].present? && + options['rules'].all? { |rule| valid_rule?(rule) } + errors.add(:base, + "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required") end - errors.add(:base, "message is required unless 'keep_event' is 'true'") unless options['message'].present? || keep_event? + errors.add(:base, + "message is required unless 'keep_event' is 'true'") unless options['message'].present? || keep_event? - errors.add(:base, "keep_event, when present, must be 'true' or 'false'") unless options['keep_event'].blank? || %w[true false].include?(options['keep_event']) + errors.add(:base, + "keep_event, when present, must be 'true' or 'false'") unless options['keep_event'].blank? || %w[ + true false + ].include?(options['keep_event']) if options['must_match'].present? if options['must_match'].to_i < 1 @@ -75,10 +90,10 @@ def default_options 'expected_receive_period_in_days' => "2", 'keep_event' => 'false', 'rules' => [{ - 'type' => "regex", - 'value' => "foo\\d+bar", - 'path' => "topkey.subkey.subkey.goal", - }], + 'type' => "regex", + 'value' => "foo\\d+bar", + 'path' => "topkey.subkey.subkey.goal", + }], 'message' => "Looks like your pattern matched in '{{value}}'!" } end @@ -89,7 +104,6 @@ def working? def receive(incoming_events) incoming_events.each do |event| - opts = interpolated(event) match_results = opts['rules'].map do |rule| @@ -129,16 +143,16 @@ def receive(incoming_events) end end - if matches?(match_results) - if keep_event? - payload = event.payload.dup - payload['message'] = opts['message'] if opts['message'].present? - else - payload = { 'message' => opts['message'] } - end + next unless matches?(match_results) - create_event :payload => payload + if keep_event? + payload = event.payload.dup + payload['message'] = opts['message'] if opts['message'].present? + else + payload = { 'message' => opts['message'] } end + + create_event(payload:) end end diff --git a/app/models/agents/tumblr_likes_agent.rb b/app/models/agents/tumblr_likes_agent.rb index 317d31e8d0..e3b96cd650 100644 --- a/app/models/agents/tumblr_likes_agent.rb +++ b/app/models/agents/tumblr_likes_agent.rb @@ -4,7 +4,7 @@ class TumblrLikesAgent < Agent gem_dependency_check { defined?(Tumblr::Client) } - description <<-MD + description <<~MD The Tumblr Likes Agent checks for liked Tumblr posts from a specific blog. #{'## Include `tumblr_client` and `omniauth-tumblr` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -23,7 +23,8 @@ class TumblrLikesAgent < Agent def validate_options errors.add(:base, 'blog_name is required') unless options['blog_name'].present? - errors.add(:base, 'expected_update_period_in_days is required') unless options['expected_update_period_in_days'].present? + errors.add(:base, + 'expected_update_period_in_days is required') unless options['expected_update_period_in_days'].present? end def working? @@ -47,11 +48,11 @@ def check if liked['liked_posts'] # Loop over all liked posts which came back from Tumblr, add to memory, and create events. liked['liked_posts'].each do |post| - unless memory[:ids].include?(post['id']) - memory[:ids].push(post['id']) - memory[:last_liked] = post['liked_timestamp'] if post['liked_timestamp'] > memory[:last_liked] - create_event(payload: post) - end + next if memory[:ids].include?(post['id']) + + memory[:ids].push(post['id']) + memory[:last_liked] = post['liked_timestamp'] if post['liked_timestamp'] > memory[:last_liked] + create_event(payload: post) end elsif liked['status'] && liked['msg'] # If there was a problem fetching likes (like 403 Forbidden or 404 Not Found) create an error message. diff --git a/app/models/agents/tumblr_publish_agent.rb b/app/models/agents/tumblr_publish_agent.rb index c72f879aa4..1dfdb21438 100644 --- a/app/models/agents/tumblr_publish_agent.rb +++ b/app/models/agents/tumblr_publish_agent.rb @@ -6,7 +6,7 @@ class TumblrPublishAgent < Agent gem_dependency_check { defined?(Tumblr::Client) } - description <<-MD + description <<~MD The Tumblr Publish Agent publishes Tumblr posts from the events it receives. #{'## Include `tumblr_client` and `omniauth-tumblr` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -59,7 +59,8 @@ class TumblrPublishAgent < Agent MD def validate_options - errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present? + errors.add(:base, + "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present? end def working? @@ -112,9 +113,9 @@ def receive(incoming_events) return end expanded_post = get_post(blog_name, post["id"]) - create_event :payload => { + create_event payload: { 'success' => true, - 'published_post' => "["+blog_name+"] "+post_type, + 'published_post' => "[" + blog_name + "] " + post_type, 'post_id' => post["id"], 'agent_id' => event.agent_id, 'event_id' => event.id, @@ -126,13 +127,13 @@ def receive(incoming_events) def publish_post(blog_name, post_type, options) options_obj = { - :state => options['state'], - :tags => options['tags'], - :tweet => options['tweet'], - :date => options['date'], - :format => options['format'], - :slug => options['slug'], - } + state: options['state'], + tags: options['tags'], + tweet: options['tweet'], + date: options['date'], + format: options['format'], + slug: options['slug'], + } case post_type when "text" @@ -174,9 +175,7 @@ def publish_post(blog_name, post_type, options) end def get_post(blog_name, id) - obj = tumblr.posts(blog_name, { - :id => id - }) + obj = tumblr.posts(blog_name, { id: }) obj["posts"].first end end diff --git a/app/models/agents/twilio_agent.rb b/app/models/agents/twilio_agent.rb index ad444228a0..f55d9c41c9 100644 --- a/app/models/agents/twilio_agent.rb +++ b/app/models/agents/twilio_agent.rb @@ -8,7 +8,7 @@ class TwilioAgent < Agent gem_dependency_check { defined?(Twilio) } - description <<-MD + description <<~MD The Twilio Agent receives and collects events and sends them via text message (up to 160 characters) or gives you a call when scheduled. #{'## Include `twilio-ruby` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -29,16 +29,17 @@ def default_options 'auth_token' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'sender_cell' => 'xxxxxxxxxx', 'receiver_cell' => 'xxxxxxxxxx', - 'server_url' => 'http://somename.com:3000', - 'receive_text' => 'true', - 'receive_call' => 'false', + 'server_url' => 'http://somename.com:3000', + 'receive_text' => 'true', + 'receive_call' => 'false', 'expected_receive_period_in_days' => '1' } end def validate_options unless options['account_sid'].present? && options['auth_token'].present? && options['sender_cell'].present? && options['receiver_cell'].present? && options['expected_receive_period_in_days'].present? && options['receive_call'].present? && options['receive_text'].present? - errors.add(:base, 'account_sid, auth_token, sender_cell, receiver_cell, receive_text, receive_call and expected_receive_period_in_days are all required') + errors.add(:base, + 'account_sid, auth_token, sender_cell, receiver_cell, receive_text, receive_call and expected_receive_period_in_days are all required') end end @@ -66,15 +67,15 @@ def working? end def send_message(message) - client.messages.create :from => interpolated['sender_cell'], - :to => interpolated['receiver_cell'], - :body => message + client.messages.create from: interpolated['sender_cell'], + to: interpolated['receiver_cell'], + body: message end def make_call(secret) - client.calls.create :from => interpolated['sender_cell'], - :to => interpolated['receiver_cell'], - :url => post_url(interpolated['server_url'], secret) + client.calls.create from: interpolated['sender_cell'], + to: interpolated['receiver_cell'], + url: post_url(interpolated['server_url'], secret) end def post_url(server_url, secret) @@ -83,7 +84,9 @@ def post_url(server_url, secret) def receive_web_request(params, method, format) if memory['pending_calls'].has_key? params['secret'] - response = Twilio::TwiML::VoiceResponse.new {|r| r.say( message: memory['pending_calls'][params['secret']], voice: 'woman')} + response = Twilio::TwiML::VoiceResponse.new { |r| + r.say(message: memory['pending_calls'][params['secret']], voice: 'woman') + } memory['pending_calls'].delete params['secret'] [response.to_s, 200] end diff --git a/app/models/agents/twilio_receive_text_agent.rb b/app/models/agents/twilio_receive_text_agent.rb index f22e980a10..5eb16b7694 100644 --- a/app/models/agents/twilio_receive_text_agent.rb +++ b/app/models/agents/twilio_receive_text_agent.rb @@ -5,20 +5,21 @@ class TwilioReceiveTextAgent < Agent gem_dependency_check { defined?(Twilio) } - description do <<-MD - The Twilio Receive Text Agent receives text messages from Twilio and emits them as events. + description do + <<~MD + The Twilio Receive Text Agent receives text messages from Twilio and emits them as events. - #{'## Include `twilio-ruby` in your Gemfile to use this Agent!' if dependencies_missing?} + #{'## Include `twilio-ruby` in your Gemfile to use this Agent!' if dependencies_missing?} - In order to create events with this agent, configure Twilio to send POST requests to: + In order to create events with this agent, configure Twilio to send POST requests to: - ``` - #{post_url} - ``` + ``` + #{post_url} + ``` - #{'The placeholder symbols above will be replaced by their values once the agent is saved.' unless id} + #{'The placeholder symbols above will be replaced by their values once the agent is saved.' unless id} - Options: + Options: * `server_url` must be set to the URL of your Huginn installation (probably "https://#{ENV['DOMAIN']}"), which must be web-accessible. Be sure to set http/https correctly. @@ -35,8 +36,8 @@ def default_options { 'account_sid' => 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'auth_token' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', - 'server_url' => "https://#{ENV['DOMAIN'].presence || 'example.com'}", - 'reply_text' => '', + 'server_url' => "https://#{ENV['DOMAIN'].presence || 'example.com'}", + 'reply_text' => '', "expected_receive_period_in_days" => 1 } end @@ -73,11 +74,10 @@ def receive_web_request(request) # validate from twilio @validator ||= Twilio::Security::RequestValidator.new interpolated['auth_token'] if !@validator.validate(post_url, params, signature) - error("Twilio Signature Failed to Validate\n\n"+ - "URL: #{post_url}\n\n"+ - "POST params: #{params.inspect}\n\n"+ - "Signature: #{signature}" - ) + error("Twilio Signature Failed to Validate\n\n" + + "URL: #{post_url}\n\n" + + "POST params: #{params.inspect}\n\n" + + "Signature: #{signature}") return ["Not authorized", 401] end @@ -87,9 +87,9 @@ def receive_web_request(request) r.message(body: interpolated['reply_text']) end end - return [response.to_s, 200, "text/xml"] + [response.to_s, 200, "text/xml"] else - return ["Bad request", 400] + ["Bad request", 400] end end end diff --git a/app/models/agents/twitter_action_agent.rb b/app/models/agents/twitter_action_agent.rb index 176a80563e..a5f958cd59 100644 --- a/app/models/agents/twitter_action_agent.rb +++ b/app/models/agents/twitter_action_agent.rb @@ -4,7 +4,7 @@ class TwitterActionAgent < Agent cannot_be_scheduled! - description <<-MD + description <<~MD The Twitter Action Agent is able to retweet or favorite tweets from the events it receives. #{twitter_dependencies_missing if dependencies_missing?} diff --git a/app/models/agents/twitter_favorites.rb b/app/models/agents/twitter_favorites.rb index 1dae8a8dbb..ac7f797445 100644 --- a/app/models/agents/twitter_favorites.rb +++ b/app/models/agents/twitter_favorites.rb @@ -5,7 +5,7 @@ class TwitterFavorites < Agent can_dry_run! cannot_receive_events! - description <<-MD + description <<~MD The Twitter Favorites List Agent follows the favorites list of a specified Twitter user. #{twitter_dependencies_missing if dependencies_missing?} diff --git a/app/models/agents/twitter_publish_agent.rb b/app/models/agents/twitter_publish_agent.rb index 566f2c3575..2f6ddfe6c9 100644 --- a/app/models/agents/twitter_publish_agent.rb +++ b/app/models/agents/twitter_publish_agent.rb @@ -4,7 +4,7 @@ class TwitterPublishAgent < Agent cannot_be_scheduled! - description <<-MD + description <<~MD The Twitter Publish Agent publishes tweets from the events it receives. #{twitter_dependencies_missing if dependencies_missing?} @@ -19,32 +19,34 @@ class TwitterPublishAgent < Agent If `output_mode` is set to `merge`, the emitted Event will be merged into the original contents of the received Event. MD - event_description <<-MD + event_description <<~MD Events look like this: - { - "success": true, - "published_tweet": "...", - "tweet_id": ..., - "tweet_url": "...", - "agent_id": ..., - "event_id": ... - } - - { - "success": false, - "error": "...", - "failed_tweet": "...", - "agent_id": ..., - "event_id": ... - } + + { + "success": true, + "published_tweet": "...", + "tweet_id": ..., + "tweet_url": "...", + "agent_id": ..., + "event_id": ... + } + + { + "success": false, + "error": "...", + "failed_tweet": "...", + "agent_id": ..., + "event_id": ... + } Original event contents will be merged when `output_mode` is set to `merge`. MD def validate_options - errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present? + errors.add(:base, + "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present? - if options['output_mode'].present? && !options['output_mode'].to_s.include?('{') && !%[clean merge].include?(options['output_mode'].to_s) + if options['output_mode'].present? && !options['output_mode'].to_s.include?('{') && !%(clean merge).include?(options['output_mode'].to_s) errors.add(:base, "if provided, output_mode must be 'clean' or 'merge'") end end diff --git a/app/models/agents/twitter_search_agent.rb b/app/models/agents/twitter_search_agent.rb index 4c95629eed..60ed9b9581 100644 --- a/app/models/agents/twitter_search_agent.rb +++ b/app/models/agents/twitter_search_agent.rb @@ -5,7 +5,7 @@ class TwitterSearchAgent < Agent can_dry_run! cannot_receive_events! - description <<-MD + description <<~MD The Twitter Search Agent performs and emits the results of a specified Twitter search. #{twitter_dependencies_missing if dependencies_missing?} diff --git a/app/models/agents/twitter_stream_agent.rb b/app/models/agents/twitter_stream_agent.rb index b496adad80..3722accdca 100644 --- a/app/models/agents/twitter_stream_agent.rb +++ b/app/models/agents/twitter_stream_agent.rb @@ -5,7 +5,7 @@ class TwitterStreamAgent < Agent cannot_receive_events! - description <<-MD + description <<~MD The Twitter Stream Agent follows the Twitter stream in real time, watching for certain keywords, or filters, that you provide. #{twitter_dependencies_missing if dependencies_missing?} @@ -147,7 +147,7 @@ def self.setup_worker config_hash.push(oauth_token) Worker.new(id: agents.first.worker_id(config_hash), - config: { filter_to_agent_map: filter_to_agent_map }, + config: { filter_to_agent_map: }, agent: agents.first) end end @@ -155,7 +155,7 @@ def self.setup_worker class Worker < LongRunnable::Worker RELOAD_TIMEOUT = 60.minutes DUPLICATE_DETECTION_LENGTH = 1000 - SEPARATOR = /[^\w_-]+/ + SEPARATOR = /[^\w-]+/ def setup require 'twitter/json_stream' @@ -187,13 +187,13 @@ def stream!(filters, agent, &block) path = if track.present? - "/1.1/statuses/filter.json?#{{ track: track }.to_param}" + "/1.1/statuses/filter.json?#{{ track: }.to_param}" else "/1.1/statuses/sample.json" end stream = Twitter::JSONStream.connect( - path: path, + path:, ssl: true, oauth: { consumer_key: agent.twitter_consumer_key, diff --git a/app/models/agents/twitter_user_agent.rb b/app/models/agents/twitter_user_agent.rb index bd2cb74689..b350bbdc9a 100644 --- a/app/models/agents/twitter_user_agent.rb +++ b/app/models/agents/twitter_user_agent.rb @@ -5,7 +5,7 @@ class TwitterUserAgent < Agent can_dry_run! cannot_receive_events! - description <<-MD + description <<~MD The Twitter User Agent either follows the timeline of a specific Twitter user or follows your own home timeline including both your tweets and tweets from people whom you are following. #{twitter_dependencies_missing if dependencies_missing?} diff --git a/app/models/agents/user_location_agent.rb b/app/models/agents/user_location_agent.rb index 753c3a9b7b..5152fba76b 100644 --- a/app/models/agents/user_location_agent.rb +++ b/app/models/agents/user_location_agent.rb @@ -6,20 +6,21 @@ class UserLocationAgent < Agent gem_dependency_check { defined?(Haversine) } - description do <<-MD - The User Location Agent creates events based on WebHook POSTS that contain a `latitude` and `longitude`. You can use the [POSTLocation](https://github.com/cantino/post_location) or [PostGPS](https://github.com/chriseidhof/PostGPS) iOS app to post your location to `https://#{ENV['DOMAIN']}/users/#{user.id}/update_location/:secret` where `:secret` is specified in your options. + description do + <<~MD + The User Location Agent creates events based on WebHook POSTS that contain a `latitude` and `longitude`. You can use the [POSTLocation](https://github.com/cantino/post_location) or [PostGPS](https://github.com/chriseidhof/PostGPS) iOS app to post your location to `https://#{ENV['DOMAIN']}/users/#{user.id}/update_location/:secret` where `:secret` is specified in your options. - #{'## Include `haversine` in your Gemfile to use this Agent!' if dependencies_missing?} + #{'## Include `haversine` in your Gemfile to use this Agent!' if dependencies_missing?} - If you want to only keep more precise locations, set `max_accuracy` to the upper bound, in meters. The default name for this field is `accuracy`, but you can change this by setting a value for `accuracy_field`. + If you want to only keep more precise locations, set `max_accuracy` to the upper bound, in meters. The default name for this field is `accuracy`, but you can change this by setting a value for `accuracy_field`. - If you want to require a certain distance traveled, set `min_distance` to the minimum distance, in meters. Note that GPS readings and the measurement itself aren't exact, so don't rely on this for precision filtering. + If you want to require a certain distance traveled, set `min_distance` to the minimum distance, in meters. Note that GPS readings and the measurement itself aren't exact, so don't rely on this for precision filtering. - To view the locations on a map, set `api_key` to your [Google Maps JavaScript API key](https://developers.google.com/maps/documentation/javascript/get-api-key#key). - MD + To view the locations on a map, set `api_key` to your [Google Maps JavaScript API key](https://developers.google.com/maps/documentation/javascript/get-api-key#key). + MD end - event_description <<-MD + event_description <<~MD Assuming you're using the iOS application, events look like this: { @@ -49,7 +50,8 @@ def default_options end def validate_options - errors.add(:base, "secret is required and must be longer than 4 characters") unless options['secret'].present? && options['secret'].length > 4 + errors.add(:base, + "secret is required and must be longer than 4 characters") unless options['secret'].present? && options['secret'].length > 4 end def receive(incoming_events) @@ -71,7 +73,7 @@ def receive_web_request(params, method, format) handle_payload params.except(:secret) - return ['ok', 200] + ['ok', 200] end private @@ -87,7 +89,12 @@ def accurate_enough?(payload, accuracy_field) def far_enough?(payload) if memory['last_location'].present? - travel = Haversine.distance(memory['last_location']['latitude'].to_i, memory['last_location']['longitude'].to_i, payload['latitude'].to_i, payload['longitude'].to_i).to_meters + travel = Haversine.distance( + memory['last_location']['latitude'].to_i, + memory['last_location']['longitude'].to_i, + payload['latitude'].to_i, + payload['longitude'].to_i + ).to_meters !interpolated[:min_distance].present? || travel > interpolated[:min_distance].to_i else # for the first run, before "last_location" exists true @@ -98,7 +105,7 @@ def far_enough?(payload) if interpolated[:max_accuracy].present? && !payload[accuracy_field].present? log "Accuracy field missing; all locations will be kept" end - create_event payload: payload, location: location + create_event(payload:, location:) memory["last_location"] = payload end end diff --git a/app/models/agents/weather_agent.rb b/app/models/agents/weather_agent.rb index 177f5174ea..e68fd62b26 100644 --- a/app/models/agents/weather_agent.rb +++ b/app/models/agents/weather_agent.rb @@ -7,24 +7,23 @@ class WeatherAgent < Agent gem_dependency_check { defined?(ForecastIO) } - description <<-MD + description <<~MD The Weather Agent creates an event for the day's weather at a given `location`. #{'## Include `forecast_io` in your Gemfile to use this Agent!' if dependencies_missing?} You also must select when you would like to get the weather forecast for using the `which_day` option, where the number 1 represents today, 2 represents tomorrow and so on. Weather forecast inforation is only returned for at most one week at a time. - The weather forecast information is provided by Dark Sky. + The weather forecast information is provided by Dark Sky. The `location` must be a comma-separated string of map co-ordinates (longitude, latitude). For example, San Francisco would be `37.7771,-122.4196`. You must set up an [API key for Dark Sky](https://darksky.net/dev/) in order to use this Agent. 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. - MD - event_description <<-MD + event_description <<~MD Events look like this: { @@ -71,7 +70,7 @@ def default_options def check if key_setup? - create_event :payload => model(which_day).merge('location' => location) + create_event payload: model(which_day).merge('location' => location) end end @@ -93,7 +92,7 @@ def language interpolated["language"].presence || "en" end - def wunderground? + def wunderground? interpolated["service"].presence && interpolated["service"].presence.downcase == "wunderground" end @@ -112,12 +111,14 @@ def validate_location :base, "Location #{location} is malformed. Location for " + 'Dark Sky must be in the format "-00.000,-00.00000". The ' + - "number of decimal places does not matter.") + "number of decimal places does not matter." + ) end end def validate_options - errors.add(:base, "The Weather Underground API has been disabled since Jan 1st 2018, please switch to DarkSky") if wunderground? + errors.add(:base, + "The Weather Underground API has been disabled since Jan 1st 2018, please switch to DarkSky") if wunderground? validate_location errors.add(:base, "api_key is required") unless interpolated['api_key'].present? errors.add(:base, "which_day selection is required") unless which_day.present? @@ -127,7 +128,7 @@ def dark_sky if key_setup? ForecastIO.api_key = interpolated['api_key'] lat, lng = coordinates - ForecastIO.forecast(lat, lng, params: {lang: language.downcase})['daily']['data'] + ForecastIO.forecast(lat, lng, params: { lang: language.downcase })['daily']['data'] end end @@ -135,7 +136,7 @@ def model(which_day) value = dark_sky[which_day - 1] if value timestamp = Time.at(value.time) - day = { + { 'date' => { 'epoch' => value.time.to_s, 'pretty' => timestamp.strftime("%l:%M %p %Z on %B %d, %Y"), @@ -146,7 +147,7 @@ def model(which_day) 'hour' => timestamp.hour, 'min' => timestamp.strftime("%M"), 'sec' => timestamp.sec, - 'isdst' => timestamp.isdst ? 1 : 0 , + 'isdst' => timestamp.isdst ? 1 : 0, 'monthname' => timestamp.strftime("%B"), 'monthname_short' => timestamp.strftime("%b"), 'weekday_short' => timestamp.strftime("%a"), @@ -156,18 +157,18 @@ def model(which_day) }, 'period' => which_day.to_i, 'high' => { - 'fahrenheit' => value.temperatureMax.round().to_s, + 'fahrenheit' => value.temperatureMax.round.to_s, 'epoch' => value.temperatureMaxTime.to_s, - 'fahrenheit_apparent' => value.apparentTemperatureMax.round().to_s, + 'fahrenheit_apparent' => value.apparentTemperatureMax.round.to_s, 'epoch_apparent' => value.apparentTemperatureMaxTime.to_s, - 'celsius' => ((5*(Float(value.temperatureMax) - 32))/9).round().to_s + 'celsius' => ((5 * (Float(value.temperatureMax) - 32)) / 9).round.to_s }, 'low' => { - 'fahrenheit' => value.temperatureMin.round().to_s, + 'fahrenheit' => value.temperatureMin.round.to_s, 'epoch' => value.temperatureMinTime.to_s, - 'fahrenheit_apparent' => value.apparentTemperatureMin.round().to_s, + 'fahrenheit_apparent' => value.apparentTemperatureMin.round.to_s, 'epoch_apparent' => value.apparentTemperatureMinTime.to_s, - 'celsius' => ((5*(Float(value.temperatureMin) - 32))/9).round().to_s + 'celsius' => ((5 * (Float(value.temperatureMin) - 32)) / 9).round.to_s }, 'conditions' => value.summary, 'icon' => value.icon, @@ -184,8 +185,8 @@ def model(which_day) }, 'dewPoint' => value.dewPoint.to_s, 'avewind' => { - 'mph' => value.windSpeed.round().to_s, - 'kph' => (Float(value.windSpeed) * 1.609344).round().to_s, + 'mph' => value.windSpeed.round.to_s, + 'kph' => (Float(value.windSpeed) * 1.609344).round.to_s, 'degrees' => value.windBearing.to_s }, 'visibility' => value.visibility.to_s, @@ -193,7 +194,6 @@ def model(which_day) 'pressure' => value.pressure.to_s, 'ozone' => value.ozone.to_s } - return day end end end diff --git a/app/models/agents/webhook_agent.rb b/app/models/agents/webhook_agent.rb index 325ecb86cb..e4ad8b0ccc 100644 --- a/app/models/agents/webhook_agent.rb +++ b/app/models/agents/webhook_agent.rb @@ -6,16 +6,17 @@ class WebhookAgent < Agent cannot_be_scheduled! cannot_receive_events! - description do <<-MD - The Webhook Agent will create events by receiving webhooks from any source. In order to create events with this agent, make a POST request to: + description do + <<~MD + The Webhook Agent will create events by receiving webhooks from any source. In order to create events with this agent, make a POST request to: - ``` - https://#{ENV['DOMAIN']}/users/#{user.id}/web_requests/#{id || ':id'}/#{options['secret'] || ':secret'} - ``` + ``` + https://#{ENV['DOMAIN']}/users/#{user.id}/web_requests/#{id || ':id'}/#{options['secret'] || ':secret'} + ``` - #{'The placeholder symbols above will be replaced by their values once the agent is saved.' unless id} + #{'The placeholder symbols above will be replaced by their values once the agent is saved.' unless id} - Options: + Options: * `secret` - A token that the host will provide for authentication. * `expected_receive_period_in_days` - How often you expect to receive @@ -38,14 +39,15 @@ class WebhookAgent < Agent end event_description do - <<-MD + <<~MD The event payload is based on the value of the `payload_path` option, which is set to `#{interpolated['payload_path']}`. MD end def default_options - { "secret" => "supersecretstring", + { + "secret" => "supersecretstring", "expected_receive_period_in_days" => 1, "payload_path" => "some_key", "event_headers" => "", @@ -97,7 +99,7 @@ def receive_web_request(request) begin response = faraday.post('https://www.google.com/recaptcha/api/siteverify', parameters) - rescue => e + rescue StandardError => e error "Verification failed: #{e.message}" return ["Not Authorized", 401] end @@ -117,9 +119,17 @@ def receive_web_request(request) end if interpolated['response_headers'].presence - [interpolated(params)['response'] || 'Event Created', code, "text/plain", interpolated['response_headers'].presence] + [ + interpolated(params)['response'] || 'Event Created', + code, + "text/plain", + interpolated['response_headers'].presence + ] else - [interpolated(params)['response'] || 'Event Created', code] + [ + interpolated(params)['response'] || 'Event Created', + code + ] end end diff --git a/app/models/agents/website_agent.rb b/app/models/agents/website_agent.rb index fe057b47de..3784975456 100644 --- a/app/models/agents/website_agent.rb +++ b/app/models/agents/website_agent.rb @@ -14,7 +14,7 @@ class WebsiteAgent < Agent UNIQUENESS_LOOK_BACK = 200 UNIQUENESS_FACTOR = 3 - description <<-MD + description <<~MD The Website Agent scrapes a website, XML document, or JSON feed and creates Events based on the results. Specify a `url` and select a `mode` for when to create Events based on the scraped data, either `all`, `on_change`, or `merge` (if fetching based on an Event, see below). @@ -224,37 +224,42 @@ def working? def default_options { - 'expected_update_period_in_days' => "2", - 'url' => "https://xkcd.com", - 'type' => "html", - 'mode' => "on_change", - 'extract' => { - 'url' => { 'css' => "#comic img", 'value' => "@src" }, - 'title' => { 'css' => "#comic img", 'value' => "@alt" }, - 'hovertext' => { 'css' => "#comic img", 'value' => "@title" } - } + 'expected_update_period_in_days' => "2", + 'url' => "https://xkcd.com", + 'type' => "html", + 'mode' => "on_change", + 'extract' => { + 'url' => { 'css' => "#comic img", 'value' => "@src" }, + 'title' => { 'css' => "#comic img", 'value' => "@alt" }, + 'hovertext' => { 'css' => "#comic img", 'value' => "@title" } + } } end def validate_options # Check for required fields - errors.add(:base, "either url, url_from_event, or data_from_event are required") unless options['url'].present? || options['url_from_event'].present? || options['data_from_event'].present? - errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present? + errors.add(:base, + "either url, url_from_event, or data_from_event are required") unless options['url'].present? || options['url_from_event'].present? || options['data_from_event'].present? + errors.add(:base, + "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present? validate_extract_options! validate_template_options! validate_http_success_codes! # Check for optional fields if options['mode'].present? - errors.add(:base, "mode must be set to on_change, all or merge") unless %w[on_change all merge].include?(options['mode']) + errors.add(:base, "mode must be set to on_change, all or merge") unless %w[on_change all + merge].include?(options['mode']) end if options['expected_update_period_in_days'].present? - errors.add(:base, "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days']) + errors.add(:base, + "Invalid expected_update_period_in_days format") unless is_positive_integer?(options['expected_update_period_in_days']) end if options['uniqueness_look_back'].present? - errors.add(:base, "Invalid uniqueness_look_back format") unless is_positive_integer?(options['uniqueness_look_back']) + errors.add(:base, + "Invalid uniqueness_look_back format") unless is_positive_integer?(options['uniqueness_look_back']) end validate_web_request_options! @@ -264,23 +269,24 @@ def validate_http_success_codes! consider_success = options["http_success_codes"] if consider_success.present? - if (consider_success.class != Array) + if consider_success.class != Array errors.add(:http_success_codes, "must be an array and specify at least one status code") - else - if consider_success.uniq.count != consider_success.count - errors.add(:http_success_codes, "duplicate http code found") - else - if consider_success.any?{|e| e.to_s !~ /^\d+$/ } - errors.add(:http_success_codes, "please make sure to use only numeric values for code, ex 404, or \"404\"") - end - end + elsif consider_success.uniq.count != consider_success.count + errors.add(:http_success_codes, "duplicate http code found") + elsif consider_success.any? { |e| e.to_s !~ /^\d+$/ } + errors.add(:http_success_codes, + "please make sure to use only numeric values for code, ex 404, or \"404\"") end end end def validate_extract_options! - extraction_type = (extraction_type() rescue extraction_type(options)) + extraction_type = begin + extraction_type() + rescue StandardError + extraction_type(options) + end case extract = options['extract'] when Hash if extract.each_value.any? { |value| !value.is_a?(Hash) } @@ -297,7 +303,8 @@ def validate_extract_options! when String # ok when nil - errors.add(:base, "When type is html or xml, all extractions must have a css or xpath attribute (bad extraction details for #{name.inspect})") + errors.add(:base, + "When type is html or xml, all extractions must have a css or xpath attribute (bad extraction details for #{name.inspect})") else errors.add(:base, "Wrong type of \"xpath\" value in extraction details for #{name.inspect}") end @@ -318,7 +325,8 @@ def validate_extract_options! when String # ok when nil - errors.add(:base, "When type is json, all extractions must have a path attribute (bad extraction details for #{name.inspect})") + errors.add(:base, + "When type is json, all extractions must have a path attribute (bad extraction details for #{name.inspect})") else errors.add(:base, "Wrong type of \"path\" value in extraction details for #{name.inspect}") end @@ -329,11 +337,12 @@ def validate_extract_options! when String begin re = Regexp.new(regexp) - rescue => e + rescue StandardError => e errors.add(:base, "invalid regexp for #{name.inspect}: #{e.message}") end when nil - errors.add(:base, "When type is text, all extractions must have a regexp attribute (bad extraction details for #{name.inspect})") + errors.add(:base, + "When type is text, all extractions must have a regexp attribute (bad extraction details for #{name.inspect})") else errors.add(:base, "Wrong type of \"regexp\" value in extraction details for #{name.inspect}") end @@ -346,7 +355,8 @@ def validate_extract_options! errors.add(:base, "no named capture #{index.inspect} found in regexp for #{name.inspect})") end when nil - errors.add(:base, "When type is text, all extractions must have an index attribute (bad extraction details for #{name.inspect})") + errors.add(:base, + "When type is text, all extractions must have an index attribute (bad extraction details for #{name.inspect})") else errors.add(:base, "Wrong type of \"index\" value in extraction details for #{name.inspect}") end @@ -369,8 +379,7 @@ def validate_extract_options! def validate_template_options! template = options['template'].presence or return - unless Hash === template && - template.each_pair.all? { |key, value| String === value } + unless Hash === template && template.each_key.all?(String) errors.add(:base, 'template must be a hash of strings.') end end @@ -403,7 +412,7 @@ def check_url(url, existing_payload = {}) interpolation_context['_response_'] = ResponseDrop.new(response) handle_data(response.body, response.env[:url], existing_payload) } - rescue => e + rescue StandardError => e error "Error when fetching url: #{e.message}\n#{e.backtrace.join("\n")}" end @@ -432,12 +441,12 @@ def handle_data(body, url, existing_payload) output = case extraction_type - when 'json' - extract_json(doc) - when 'text' - extract_text(doc) - else - extract_xml(doc) + when 'json' + extract_json(doc) + when 'text' + extract_text(doc) + else + extract_xml(doc) end num_tuples = output.size or @@ -485,6 +494,7 @@ def receive(incoming_events) end private + def consider_response_successful?(response) response.success? || begin consider_success = options["http_success_codes"] @@ -497,7 +507,7 @@ def handle_event_data(data, event, existing_payload) interpolation_context['_response_'] = ResponseFromEventDrop.new(event) handle_data(data, event.payload['url'].presence, existing_payload) } - rescue => e + rescue StandardError => e error "Error when handling event data: #{e.message}\n#{e.backtrace.join("\n")}" end @@ -557,7 +567,7 @@ def use_namespaces? if interpolated.key?('use_namespaces') boolify(interpolated['use_namespaces']) else - interpolated['extract'].none? { |name, extraction_details| + interpolated['extract'].none? { |_name, extraction_details| extraction_details.key?('xpath') } end @@ -620,7 +630,7 @@ def extract_xml(doc) log "Extracting #{extraction_type} at #{xpath || css}" case nodes when Nokogiri::XML::NodeSet - stringified_nodes = nodes.map do |node| + stringified_nodes = nodes.map do |node| case value = node.xpath(extraction_details['value'] || '.') when Float # Node#xpath() returns any numeric value as float; @@ -677,6 +687,7 @@ def []=(key, value) if @size && @size != size raise UnevenSizeError, 'got an uneven size' end + @size = size end @@ -684,7 +695,7 @@ def []=(key, value) end def each - @size.times.zip(*@hash.values) do |index, *values| + @size.times.zip(*@hash.values) do |_index, *values| yield @hash.each_key.lazy.zip(values).to_h end end @@ -734,14 +745,20 @@ def url class ResponseFromEventDrop < LiquidDroppable::Drop def headers - headers = Faraday::Utils::Headers.from(@object.payload[:headers]) rescue {} + headers = begin + Faraday::Utils::Headers.from(@object.payload[:headers]) + rescue StandardError + {} + end HeaderDrop.new(headers) end # Integer value of HTTP status def status - Integer(@object.payload[:status]) rescue nil + Integer(@object.payload[:status]) + rescue StandardError + nil end # The URL diff --git a/app/models/agents/weibo_publish_agent.rb b/app/models/agents/weibo_publish_agent.rb index 073a511fdc..f0320d0006 100644 --- a/app/models/agents/weibo_publish_agent.rb +++ b/app/models/agents/weibo_publish_agent.rb @@ -1,12 +1,10 @@ -# encoding: utf-8 - module Agents class WeiboPublishAgent < Agent include WeiboConcern cannot_be_scheduled! - description <<-MD + description <<~MD The Weibo Publish Agent publishes tweets from the events it receives. #{'## Include `weibo_2` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -24,7 +22,7 @@ class WeiboPublishAgent < Agent def validate_options unless options['uid'].present? && - options['expected_update_period_in_days'].present? + options['expected_update_period_in_days'].present? errors.add(:base, "expected_update_period_in_days and uid are required") end end @@ -62,7 +60,7 @@ def receive(incoming_events) else publish_tweet tweet_text end - create_event :payload => { + create_event payload: { 'success' => true, 'published_tweet' => tweet_text, 'published_pic' => pic_url, @@ -70,7 +68,7 @@ def receive(incoming_events) 'event_id' => event.id } rescue OAuth2::Error => e - create_event :payload => { + create_event payload: { 'success' => false, 'error' => e.message, 'failed_tweet' => tweet_text, @@ -84,29 +82,27 @@ def receive(incoming_events) end end - def publish_tweet text + def publish_tweet(text) weibo_client.statuses.update text end - def publish_tweet_with_pic text, pic + def publish_tweet_with_pic(text, pic) weibo_client.statuses.upload text, open(pic) end def valid_image?(url) - begin - url = URI.parse(url) - http = Net::HTTP.new(url.host, url.port) - http.use_ssl = (url.scheme == "https") - http.start do |http| - # images supported #http://open.weibo.com/wiki/2/statuses/upload - return ['image/gif', 'image/jpeg', 'image/png'].include? http.head(url.request_uri)['Content-Type'] - end - rescue => e - return false + url = URI.parse(url) + http = Net::HTTP.new(url.host, url.port) + http.use_ssl = (url.scheme == "https") + http.start do |http| + # images supported #http://open.weibo.com/wiki/2/statuses/upload + return ['image/gif', 'image/jpeg', 'image/png'].include? http.head(url.request_uri)['Content-Type'] end + rescue StandardError => e + false end - def unwrap_tco_urls text, tweet_json + def unwrap_tco_urls(text, tweet_json) tweet_json[:entities][:urls].each do |url| text.gsub! url[:url], url[:expanded_url] end diff --git a/app/models/agents/weibo_user_agent.rb b/app/models/agents/weibo_user_agent.rb index 295dd14a71..0b1756b4bd 100644 --- a/app/models/agents/weibo_user_agent.rb +++ b/app/models/agents/weibo_user_agent.rb @@ -1,12 +1,10 @@ -# encoding: utf-8 - module Agents class WeiboUserAgent < Agent include WeiboConcern cannot_receive_events! - description <<-MD + description <<~MD The Weibo User Agent follows the timeline of a specified Weibo user. It uses this endpoint: http://open.weibo.com/wiki/2/statuses/user_timeline/en #{'## Include `weibo_2` in your Gemfile to use this Agent!' if dependencies_missing?} @@ -18,7 +16,7 @@ class WeiboUserAgent < Agent 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. MD - event_description <<-MD + event_description <<~MD Events are the raw JSON provided by the Weibo API. Should look something like: { @@ -72,7 +70,7 @@ class WeiboUserAgent < Agent def validate_options unless options['uid'].present? && - options['expected_update_period_in_days'].present? + options['expected_update_period_in_days'].present? errors.add(:base, "expected_update_period_in_days and uid are required") end end @@ -93,22 +91,21 @@ def default_options def check since_id = memory['since_id'] || nil - opts = {:uid => interpolated['uid'].to_i} - opts.merge! :since_id => since_id unless since_id.nil? + opts = { uid: interpolated['uid'].to_i } + opts.merge! since_id: since_id unless since_id.nil? # http://open.weibo.com/wiki/2/statuses/user_timeline/en resp = weibo_client.statuses.user_timeline opts if resp[:statuses] - resp[:statuses].each do |status| memory['since_id'] = status.id if !memory['since_id'] || (status.id > memory['since_id']) - create_event :payload => status.as_json + create_event payload: status.as_json end end save! end end -end \ No newline at end of file +end diff --git a/app/models/agents/witai_agent.rb b/app/models/agents/witai_agent.rb index bff5b8b0db..b5da15c350 100644 --- a/app/models/agents/witai_agent.rb +++ b/app/models/agents/witai_agent.rb @@ -3,43 +3,42 @@ class WitaiAgent < Agent cannot_be_scheduled! no_bulk_receive! - description <<-MD + description <<~MD The `wit.ai` agent receives events, sends a text query to your `wit.ai` instance and generates outcome events. Fill in `Server Access Token` of your `wit.ai` instance. Use [Liquid](https://github.com/huginn/huginn/wiki/Formatting-Events-using-Liquid) to fill query field. - + `expected_receive_period_in_days` is the expected number of days by which agent should receive events. It helps in determining if the agent is working. MD - event_description <<-MD - - Every event have `outcomes` key with your payload as value. Sample event: + event_description <<~MD + Every event have `outcomes` key with your payload as value. Sample event: - {"outcome" : [ - {"_text" : "set temperature to 34 degrees at 11 PM", - "intent" : "get_temperature", - "entities" : { - "temperature" : [ - { - "type" : "value", - "value" : 34, - "unit" : "degree" - }], - "datetime" : [ - { - "grain" : "hour", - "type" : "value", - "value" : "2015-03-26T21:00:00.000-07:00" - }]}, - "confidence" : 0.556 - }]} + {"outcome" : [ + {"_text" : "set temperature to 34 degrees at 11 PM", + "intent" : "get_temperature", + "entities" : { + "temperature" : [ + { + "type" : "value", + "value" : 34, + "unit" : "degree" + }], + "datetime" : [ + { + "grain" : "hour", + "type" : "value", + "value" : "2015-03-26T21:00:00.000-07:00" + }]}, + "confidence" : 0.556 + }]} MD def default_options { - 'server_access_token' => 'xxxxx', - 'expected_receive_period_in_days' => 2, - 'query' => '{{xxxx}}' + 'server_access_token' => 'xxxxx', + 'expected_receive_period_in_days' => 2, + 'query' => '{{xxxx}}' } end @@ -64,17 +63,18 @@ def receive(incoming_events) end private - def api_endpoint - 'https://api.wit.ai/message?v=20141022' - end - def query_url(query) - api_endpoint + { q: query }.to_query - end + def api_endpoint + 'https://api.wit.ai/message?v=20141022' + end - def headers - #oauth - {:headers => {'Authorization' => 'Bearer ' + interpolated[:server_access_token]}} - end + def query_url(query) + api_endpoint + { q: query }.to_query + end + + def headers + # oauth + { headers: { 'Authorization' => 'Bearer ' + interpolated[:server_access_token] } } + end end end diff --git a/app/models/event.rb b/app/models/event.rb index 1b9735ef12..0148ad9908 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -12,10 +12,12 @@ class Event < ActiveRecord::Base json_serialize :payload belongs_to :user, optional: true - belongs_to :agent, :counter_cache => true + belongs_to :agent, counter_cache: true - has_many :agent_logs_as_inbound_event, :class_name => "AgentLog", :foreign_key => :inbound_event_id, :dependent => :nullify - has_many :agent_logs_as_outbound_event, :class_name => "AgentLog", :foreign_key => :outbound_event_id, :dependent => :nullify + has_many :agent_logs_as_inbound_event, class_name: "AgentLog", foreign_key: :inbound_event_id, + dependent: :nullify + has_many :agent_logs_as_outbound_event, class_name: "AgentLog", foreign_key: :outbound_event_id, + dependent: :nullify scope :recent, lambda { |timespan = 12.hours.ago| where("events.created_at > ?", timespan) @@ -44,20 +46,18 @@ class Event < ActiveRecord::Base def location @location ||= Location.new( # lat and lng are BigDecimal, but converted to Float by the Location class - lat: lat, - lng: lng, + lat:, + lng:, radius: - begin - h = payload[:horizontal_accuracy].presence - v = payload[:vertical_accuracy].presence - if h && v - (h.to_f + v.to_f) / 2 - else - (h || v || payload[:accuracy]).to_f - end + if (h = payload[:horizontal_accuracy].presence) && + (v = payload[:vertical_accuracy].presence) + (h.to_f + v.to_f) / 2 + else + (h || v || payload[:accuracy]).to_f end, course: payload[:course], - speed: payload[:speed].presence) + speed: payload[:speed].presence + ) end def location=(location) @@ -69,13 +69,14 @@ def location=(location) else location = Location.new(location) end - self.lat, self.lng = location.lat, location.lng + self.lat = location.lat + self.lng = location.lng location end # Emit this event again, as a new Event. def reemit! - agent.create_event :payload => payload, :lat => lat, :lng => lng + agent.create_event(payload:, lat:, lng:) end # Look for Events whose `expires_at` is present and in the past. Remove those events and then update affected Agents' @@ -95,43 +96,52 @@ def update_agent_last_event_at end def possibly_propagate - #immediately schedule agents that want immediate updates - propagate_ids = agent.receivers.where(:propagate_immediately => true).pluck(:id) - Agent.receive!(:only_receivers => propagate_ids) unless propagate_ids.empty? + # immediately schedule agents that want immediate updates + propagate_ids = agent.receivers.where(propagate_immediately: true).pluck(:id) + Agent.receive!(only_receivers: propagate_ids) unless propagate_ids.empty? end -end -class EventDrop - def initialize(object) - @payload = object.payload - super + public def to_liquid + Drop.new(self) end - def liquid_method_missing(key) - @payload[key] - end + class Drop < LiquidDroppable::Drop + def initialize(object) + @payload = object.payload + super + end - def each(&block) - @payload.each(&block) - end + def liquid_method_missing(key) + @payload[key] + end - def agent - @payload.fetch(__method__) { - @object.agent - } - end + def each(&block) + @payload.each(&block) + end - def created_at - @payload.fetch(__method__) { - @object.created_at - } - end + def agent + @payload.fetch(__method__) { + @object.agent + } + end - def _location_ - @object.location - end + def created_at + @payload.fetch(__method__) { + @object.created_at + } + end - def as_json - {location: _location_.as_json, agent: @object.agent.to_liquid.as_json, payload: @payload.as_json, created_at: created_at.as_json} + def _location_ + @object.location + end + + def as_json + { + location: _location_.as_json, + agent: @object.agent.to_liquid.as_json, + payload: @payload.as_json, + created_at: created_at.as_json + } + end end end diff --git a/app/models/link.rb b/app/models/link.rb index 0cda8cb1c4..d202b0c403 100644 --- a/app/models/link.rb +++ b/app/models/link.rb @@ -1,7 +1,7 @@ # A Link connects Agents in a directed Event flow from the `source` to the `receiver`. class Link < ActiveRecord::Base - belongs_to :source, :class_name => "Agent", :inverse_of => :links_as_source - belongs_to :receiver, :class_name => "Agent", :inverse_of => :links_as_receiver + belongs_to :source, class_name: "Agent", inverse_of: :links_as_source + belongs_to :receiver, class_name: "Agent", inverse_of: :links_as_receiver before_create :store_event_id_at_creation diff --git a/app/models/scenario.rb b/app/models/scenario.rb index c6a053870d..2da90f2003 100644 --- a/app/models/scenario.rb +++ b/app/models/scenario.rb @@ -1,16 +1,16 @@ class Scenario < ActiveRecord::Base include HasGuid - belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios - has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario - has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios + belongs_to :user, counter_cache: :scenario_count, inverse_of: :scenarios + has_many :scenario_memberships, dependent: :destroy, inverse_of: :scenario + has_many :agents, through: :scenario_memberships, inverse_of: :scenarios validates_presence_of :name, :user validates_format_of :tag_fg_color, :tag_bg_color, - # Regex adapted from: http://stackoverflow.com/a/1636354/3130625 - :with => /\A#(?:[0-9a-fA-F]{3}){1,2}\z/, :allow_nil => true, - :message => "must be a valid hex color." + # Regex adapted from: http://stackoverflow.com/a/1636354/3130625 + with: /\A#(?:[0-9a-fA-F]{3}){1,2}\z/, allow_nil: true, + message: "must be a valid hex color." validate :agents_are_owned @@ -26,18 +26,16 @@ def destroy_with_mode(mode) end def self.icons - @icons ||= begin - YAML.load_file(Rails.root.join('config/icons.yml')) - end + @icons ||= YAML.load_file(Rails.root.join('config/icons.yml')) end private def unique_agent_ids agents.joins(:scenario_memberships) - .group('scenario_memberships.agent_id') - .having('count(scenario_memberships.agent_id) = 1') - .pluck('scenario_memberships.agent_id') + .group('scenario_memberships.agent_id') + .having('count(scenario_memberships.agent_id) = 1') + .pluck('scenario_memberships.agent_id') end def agents_are_owned diff --git a/app/models/scenario_membership.rb b/app/models/scenario_membership.rb index a9ea3048fb..03019caa12 100644 --- a/app/models/scenario_membership.rb +++ b/app/models/scenario_membership.rb @@ -1,4 +1,4 @@ class ScenarioMembership < ActiveRecord::Base - belongs_to :agent, :inverse_of => :scenario_memberships - belongs_to :scenario, :inverse_of => :scenario_memberships + belongs_to :agent, inverse_of: :scenario_memberships + belongs_to :scenario, inverse_of: :scenario_memberships end diff --git a/app/models/service.rb b/app/models/service.rb index 84df77ffa0..48bfb8b83a 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -1,8 +1,8 @@ class Service < ActiveRecord::Base serialize :options, Hash - belongs_to :user, :inverse_of => :services - has_many :agents, :inverse_of => :service + belongs_to :user, inverse_of: :services + has_many :agents, inverse_of: :service validates_presence_of :user_id, :provider, :name, :token @@ -20,7 +20,7 @@ def disable_agents(conditions = {}) end def toggle_availability! - disable_agents(where_not: {user_id: self.user_id}) if global + disable_agents(where_not: { user_id: self.user_id }) if global self.global = !self.global self.save! end @@ -34,20 +34,21 @@ def prepare_request def refresh_token_parameters { grant_type: 'refresh_token', - client_id: oauth_key, + client_id: oauth_key, client_secret: oauth_secret, - refresh_token: refresh_token + refresh_token: } end def refresh_token! response = HTTParty.post(endpoint, query: refresh_token_parameters) data = JSON.parse(response.body) - update(expires_at: Time.now + data['expires_in'], token: data['access_token'], refresh_token: data['refresh_token'].presence || refresh_token) + update(expires_at: Time.now + data['expires_in'], token: data['access_token'], + refresh_token: data['refresh_token'].presence || refresh_token) end def endpoint - client_options = Devise.omniauth_configs[provider.to_sym].strategy_class.default_options['client_options'] + client_options = Devise.omniauth_configs[provider.to_sym].strategy_class.default_options['client_options'] URI.join(client_options['site'], client_options['token_url']) end @@ -63,12 +64,14 @@ def self.initialize_or_update_via_omniauth(omniauth) options = get_options(omniauth) find_or_initialize_by(provider: omniauth['provider'], uid: omniauth['uid'].to_s).tap do |service| - service.assign_attributes token: omniauth['credentials']['token'], - secret: omniauth['credentials']['secret'], - name: options[:name], - refresh_token: omniauth['credentials']['refresh_token'], - expires_at: omniauth['credentials']['expires_at'] && Time.at(omniauth['credentials']['expires_at']), - options: options + service.attributes = { + token: omniauth['credentials']['token'], + secret: omniauth['credentials']['secret'], + name: options[:name], + refresh_token: omniauth['credentials']['refresh_token'], + expires_at: omniauth['credentials']['expires_at'] && Time.at(omniauth['credentials']['expires_at']), + options: + } end end @@ -80,12 +83,11 @@ def self.get_options(omniauth) option_providers.fetch(omniauth['provider'], option_providers['default']).call(omniauth) end - private @@option_providers = HashWithIndifferentAccess.new cattr_reader :option_providers register_options_provider('default') do |omniauth| - {name: omniauth['info']['nickname'] || omniauth['info']['name']} + { name: omniauth['info']['nickname'] || omniauth['info']['name'] } end register_options_provider('google') do |omniauth| diff --git a/app/models/user.rb b/app/models/user.rb index 256b689736..f83924ee93 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,10 +1,9 @@ # Huginn is designed to be a multi-User system. Users have many Agents (and Events created by those Agents). class User < ActiveRecord::Base - DEVISE_MODULES = [:database_authenticatable, :registerable, - :recoverable, :rememberable, :trackable, - :validatable, :lockable, :omniauthable, - (ENV['REQUIRE_CONFIRMED_EMAIL'] == 'true' ? :confirmable : nil)].compact - devise *DEVISE_MODULES + devise :database_authenticatable, :registerable, + :recoverable, :rememberable, :trackable, + :validatable, :lockable, :omniauthable, + *(:confirmable if ENV['REQUIRE_CONFIRMED_EMAIL'] == 'true') INVITATION_CODES = [ENV['INVITATION_CODE'] || 'try-huginn'] @@ -12,17 +11,29 @@ class User < ActiveRecord::Base # This is in addition to a real persisted field like 'username' attr_accessor :login - validates_presence_of :username - validates :username, uniqueness: { case_sensitive: false } - validates_format_of :username, :with => /\A[a-zA-Z0-9_-]{3,190}\Z/, :message => "can only contain letters, numbers, underscores, and dashes, and must be between 3 and 190 characters in length." - validates_inclusion_of :invitation_code, :on => :create, :in => INVITATION_CODES, :message => "is not valid", if: -> { !requires_no_invitation_code? && User.using_invitation_code? } - - has_many :user_credentials, :dependent => :destroy, :inverse_of => :user - has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user - has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user - has_many :logs, :through => :agents, :class_name => "AgentLog" - has_many :scenarios, :inverse_of => :user, :dependent => :destroy - has_many :services, -> { by_name('asc') }, :dependent => :destroy + validates :username, + presence: true, + uniqueness: { case_sensitive: false }, + format: { + with: /\A[a-zA-Z0-9_-]{3,190}\Z/, + message: "can only contain letters, numbers, underscores, and dashes, and must be between 3 and 190 characters in length." + } + validates :invitation_code, + inclusion: { + in: INVITATION_CODES, + message: "is not valid", + }, + if: -> { + !requires_no_invitation_code? && User.using_invitation_code? + }, + on: :create + + has_many :user_credentials, dependent: :destroy, inverse_of: :user + has_many :events, -> { order("events.created_at desc") }, dependent: :delete_all, inverse_of: :user + has_many :agents, -> { order("agents.created_at desc") }, dependent: :destroy, inverse_of: :user + has_many :logs, through: :agents, class_name: "AgentLog" + has_many :scenarios, inverse_of: :user, dependent: :destroy + has_many :services, -> { by_name('asc') }, dependent: :destroy def available_services Service.available_to_user(self).by_name @@ -32,7 +43,7 @@ def available_services def self.find_first_by_auth_conditions(warden_conditions) conditions = warden_conditions.dup if login = conditions.delete(:login) - where(conditions).where(["lower(username) = :value OR lower(email) = :value", { :value => login.downcase }]).first + where(conditions).where(["lower(username) = :value OR lower(email) = :value", { value: login.downcase }]).first else where(conditions).first end @@ -78,12 +89,10 @@ def requires_no_invitation_code? def undefined_agent_types agents.reorder('').group(:type).pluck(:type).select do |type| - begin - type.constantize - false - rescue NameError - true - end + type.constantize + false + rescue NameError + true end end diff --git a/app/models/user_credential.rb b/app/models/user_credential.rb index ffa3c38729..a507b992cf 100644 --- a/app/models/user_credential.rb +++ b/app/models/user_credential.rb @@ -3,7 +3,7 @@ class UserCredential < ActiveRecord::Base belongs_to :user - validates :credential_name, presence: true, uniqueness: { case_sensitive: true, scope: :user_id} + validates :credential_name, presence: true, uniqueness: { case_sensitive: true, scope: :user_id } validates :credential_value, presence: true validates :mode, inclusion: { in: MODES } validates :user_id, presence: true diff --git a/app/presenters/form_configurable_agent_presenter.rb b/app/presenters/form_configurable_agent_presenter.rb index 4e8f983004..6d40d63fe1 100644 --- a/app/presenters/form_configurable_agent_presenter.rb +++ b/app/presenters/form_configurable_agent_presenter.rb @@ -16,14 +16,15 @@ def initialize(agent, view) def option_field_for(attribute) data = @agent.form_configurable_fields[attribute] value = @agent.options[attribute.to_s] || @agent.default_options[attribute.to_s] - html_options = {role: (data[:roles] + ['form-configurable']).join(' '), data: {attribute: attribute}} + html_options = { role: (data[:roles] + ['form-configurable']).join(' '), data: { attribute: } } case data[:type] when :text @view.content_tag 'div' do - @view.concat @view.text_area_tag("agent[options][#{attribute}]", value, html_options.merge(class: 'form-control', rows: 3)) + @view.concat @view.text_area_tag("agent[options][#{attribute}]", value, + html_options.merge(class: 'form-control', rows: 3)) if data[:ace].present? - ace_options = { source: "[name='agent[options][#{attribute}]']", mode: '', theme: ''}.deep_symbolize_keys! + ace_options = { source: "[name='agent[options][#{attribute}]']", mode: '', theme: '' }.deep_symbolize_keys! ace_options.deep_merge!(data[:ace].deep_symbolize_keys) if data[:ace].is_a?(Hash) @view.concat @view.content_tag('div', '', class: 'ace-editor', data: ace_options) end @@ -31,21 +32,30 @@ def option_field_for(attribute) when :boolean @view.content_tag 'div' do @view.concat(@view.content_tag('label', class: 'radio-inline') do - @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'true', @agent.send(:boolify, value) == true, html_options + @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'true', + @agent.send(:boolify, value) == true, html_options @view.concat "True" end) @view.concat(@view.content_tag('label', class: 'radio-inline') do - @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'false', @agent.send(:boolify, value) == false, html_options + @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'false', + @agent.send(:boolify, value) == false, html_options @view.concat "False" end) @view.concat(@view.content_tag('label', class: 'radio-inline') do - @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'manual', @agent.send(:boolify, value) == nil, html_options + @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'manual', + @agent.send(:boolify, value).nil?, html_options @view.concat "Manual Input" end) - @view.concat(@view.text_field_tag "agent[options][#{attribute}]", value, html_options.merge(:class => "form-control #{@agent.send(:boolify, value) != nil ? 'hidden' : ''}")) + @view.concat(@view.text_field_tag("agent[options][#{attribute}]", value, + html_options.merge(class: "form-control #{@agent.send(:boolify, value) != nil ? 'hidden' : ''}"))) end - when :array, :string - @view.text_field_tag "agent[options][#{attribute}]", value, html_options.deep_merge(:class => 'form-control', data: {cache_response: data[:cache_response] != false}) + when :array + @view.select_tag "agent[options][#{attribute}]", nil, + html_options.deep_merge(class: 'form-control', + data: { value:, cache_response: data[:cache_response] != false }) + when :string + @view.text_field_tag "agent[options][#{attribute}]", value, + html_options.deep_merge(class: 'form-control', data: { cache_response: data[:cache_response] != false }) end end end diff --git a/config/environments/production.rb b/config/environments/production.rb index f2475bb530..290d52f824 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -30,18 +30,18 @@ config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? if ENV["RAILS_LOG_TO_STDOUT"].present? || - ENV['ON_HEROKU'] || - ENV['HEROKU_POSTGRESQL_ROSE_URL'] || - ENV['HEROKU_POSTGRESQL_GOLD_URL'] || - File.read(File.join(File.dirname(__FILE__), '../../Procfile')) =~ /intended for Heroku/ + ENV['ON_HEROKU'] || + ENV['HEROKU_POSTGRESQL_ROSE_URL'] || + ENV['HEROKU_POSTGRESQL_GOLD_URL'] || + File.read(File.join(File.dirname(__FILE__), '../../Procfile')) =~ /intended for Heroku/ logger = ActiveSupport::Logger.new(STDOUT) logger.formatter = config.log_formatter config.logger = ActiveSupport::TaggedLogging.new(logger) end # Compress JavaScripts and CSS - config.assets.js_compressor = :uglifier - config.assets.css_compressor = :sass + config.assets.js_compressor = :terser + config.assets.css_compressor = :scss # Do not fallback to assets pipeline if a precompiled asset is missed. config.assets.compile = false @@ -69,7 +69,7 @@ config.log_level = :info # Prepend all log lines with the following tags. - config.log_tags = [ :request_id ] + config.log_tags = [:request_id] # Use a different cache store in production. config.cache_store = :memory_store @@ -86,7 +86,7 @@ # Ignore bad email addresses and do not raise email delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false - config.action_mailer.default_url_options = { :host => ENV['DOMAIN'] } + config.action_mailer.default_url_options = { host: ENV['DOMAIN'] } config.action_mailer.asset_host = ENV['DOMAIN'] if ENV['ASSET_HOST'].present? config.action_mailer.asset_host = ENV['ASSET_HOST'] diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index a8ed9e5f0e..62da61a9a0 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -8,11 +8,3 @@ # Add additional assets to the asset load path. # Rails.application.config.assets.paths << Emoji.images_path - -# Precompile additional assets. -# application.js, application.css, and all non-JS/CSS in the app/assets -# folder are already added. -Rails.application.config.assets.precompile += %w( diagram.js graphing.js map_marker.js ace.js tweets.js ) - -Rails.application.config.assets.precompile += %w(*.png *.jpg *.jpeg *.gif) -Rails.application.config.assets.precompile += %w(*.woff *.eot *.svg *.ttf) # Bootstrap fonts diff --git a/db/migrate/20140813110107_set_charset_for_mysql.rb b/db/migrate/20140813110107_set_charset_for_mysql.rb index 35feda3ea6..6c1fb33568 100644 --- a/db/migrate/20140813110107_set_charset_for_mysql.rb +++ b/db/migrate/20140813110107_set_charset_for_mysql.rb @@ -29,7 +29,7 @@ def change type = column.type limit = column.limit options = { - limit: limit, + limit:, null: column.null, default: column.default, } @@ -53,7 +53,7 @@ def change next end - change_column table_name, name, type, options + change_column table_name, name, type, **options } execute 'ALTER TABLE %s CHARACTER SET utf8 COLLATE utf8_unicode_ci' % table_name diff --git a/db/migrate/20170731191002_migrate_growl_agent_to_liquid.rb b/db/migrate/20170731191002_migrate_growl_agent_to_liquid.rb index 2a75e0ba16..87df9c91cf 100644 --- a/db/migrate/20170731191002_migrate_growl_agent_to_liquid.rb +++ b/db/migrate/20170731191002_migrate_growl_agent_to_liquid.rb @@ -1,18 +1,5 @@ class MigrateGrowlAgentToLiquid < ActiveRecord::Migration[5.1] - def up - Agents::GrowlAgent.find_each do |agent| - agent.options['subject'] = '{{subject}}' if agent.options['subject'].blank? - agent.options['message'] = '{{ message | default: text }}' if agent.options['message'].blank? - agent.save(validate: false) - end - end - - def down - Agents::GrowlAgent.find_each do |agent| - %w(subject message sticky priority).each do |key| - agent.options.delete(key) - end - agent.save(validate: false) - end + def change + # Agents::GrowlAgent is no more end end diff --git a/doc/manual/installation.md b/doc/manual/installation.md index a55fba05f8..a392d46e9e 100644 --- a/doc/manual/installation.md +++ b/doc/manual/installation.md @@ -46,7 +46,7 @@ up-to-date and install it. Install the required packages (needed to compile Ruby and native extensions to Ruby gems): - sudo apt-get install -y runit build-essential git zlib1g-dev libyaml-dev libssl-dev libgdbm-dev libreadline-dev libncurses5-dev libffi-dev curl openssh-server checkinstall libxml2-dev libxslt-dev libcurl4-openssl-dev libicu-dev logrotate python-docutils pkg-config cmake nodejs graphviz jq shared-mime-info + sudo apt-get install -y runit build-essential git zlib1g-dev libyaml-dev libssl-dev libgdbm-dev libreadline-dev libncurses5-dev libffi-dev curl openssh-server checkinstall libxml2-dev libxslt-dev libcurl4-openssl-dev libicu-dev logrotate pkg-config cmake nodejs graphviz jq shared-mime-info ### Debian Stretch diff --git a/docker/multi-process/Dockerfile b/docker/multi-process/Dockerfile index baa71c80e6..55993b734d 100644 --- a/docker/multi-process/Dockerfile +++ b/docker/multi-process/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 +FROM rubylang/ruby:3.2-jammy COPY docker/scripts/prepare /scripts/ RUN /scripts/prepare diff --git a/docker/multi-process/scripts/standalone-packages b/docker/multi-process/scripts/standalone-packages index cb9dc1f542..120397c024 100755 --- a/docker/multi-process/scripts/standalone-packages +++ b/docker/multi-process/scripts/standalone-packages @@ -1,9 +1,18 @@ +set -e + export DEBIAN_FRONTEND=noninteractive + apt-get update -apt-get install -y python2.7 python-docutils mysql-server \ - supervisor python-pip && \ +apt-get install -y gnupg + +echo "deb http://repo.mysql.com/apt/ubuntu/ bionic mysql-5.7" > /etc/apt/sources.list.d/mysql.list +apt-key adv --keyserver pgp.mit.edu --recv-keys 3A79BD29 +apt-get update + +apt-get install -y --allow-downgrades python3-pip mysql-server supervisor \ + mysql-server=5.7.42-1ubuntu18.04 mysql-client=5.7.42-1ubuntu18.04 libmysqlclient-dev=5.7.42-1ubuntu18.04 && \ apt-get -y clean -pip install supervisor-stdout +pip install git+https://github.com/coderanger/supervisor-stdout rm -rf /var/lib/apt/lists/* rm -rf /usr/share/doc/ rm -rf /usr/share/man/ @@ -14,10 +23,13 @@ mkdir -p /var/log/supervisor /var/log/mysql chgrp -R 0 /etc/supervisor /var/lib/mysql /var/log/supervisor /var/log/mysql chmod -R g=u /etc/supervisor /var/lib/mysql /var/log/supervisor /var/log/mysql sed -r -i /etc/mysql/mysql.conf.d/mysqld.cnf \ - -e 's/^ *user *.+/user=1001/' \ -e 's#/var/run/mysqld/mysqld.sock#/app/tmp/sockets/mysqld.sock#' \ -e 's#/var/run/mysqld/mysqld.pid#/app/tmp/pids/mysqld.pid#' -sed -r -i /etc/mysql/debian.cnf \ - -e 's#/var/run/mysqld/mysqld.sock#/app/tmp/sockets/mysqld.sock#' -cp /etc/mysql/debian.cnf /etc/mysql/mysql.conf.d/client.cnf -chmod 644 /etc/mysql/mysql.conf.d/client.cnf +echo "user=1001" >> /etc/mysql/mysql.conf.d/mysqld.cnf +cat >> /etc/mysql/conf.d/mysql.cnf << EOF +[client] +socket = /app/tmp/sockets/mysqld.sock +[mysql_upgrade] +socket = /app/tmp/sockets/mysqld.sock +find /etc/mysql/ +EOF diff --git a/docker/scripts/prepare b/docker/scripts/prepare index 23257202f3..ce1712085e 100755 --- a/docker/scripts/prepare +++ b/docker/scripts/prepare @@ -23,13 +23,11 @@ minimal_apt_get_install='apt-get install -y --no-install-recommends' apt-get update apt-get dist-upgrade -y --no-install-recommends $minimal_apt_get_install software-properties-common -add-apt-repository -y ppa:brightbox/ruby-ng-experimental -apt-get update $minimal_apt_get_install build-essential checkinstall git-core \ zlib1g-dev libyaml-dev libssl-dev libgdbm-dev libreadline-dev \ libncurses5-dev libffi-dev libxml2-dev libxslt-dev curl libcurl4-openssl-dev libicu-dev \ graphviz libmysqlclient-dev libpq-dev libsqlite3-dev \ - ruby2.7 ruby2.7-dev locales tzdata shared-mime-info iputils-ping + locales tzdata shared-mime-info iputils-ping locale-gen en_US.UTF-8 update-locale LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 # Specific version 3.3.20: 3.3.21 changes platform support, breaks our build diff --git a/docker/single-process/Dockerfile b/docker/single-process/Dockerfile index d040de283f..efe4cf2037 100644 --- a/docker/single-process/Dockerfile +++ b/docker/single-process/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 +FROM rubylang/ruby:3.2-jammy COPY docker/scripts/prepare /scripts/ RUN /scripts/prepare diff --git a/lib/location.rb b/lib/location.rb index e6db847659..7eb45b4f2c 100644 --- a/lib/location.rb +++ b/lib/location.rb @@ -13,6 +13,7 @@ def initialize(data = {}) case data when Array raise ArgumentError, 'unsupported location data' unless data.size == 2 + self.lat, self.lng = data when Hash, Location data.each { |key, value| @@ -91,7 +92,7 @@ def latlng def floatify(value) case value when nil, '' - return nil + nil else float = Float(value) if block_given? @@ -101,14 +102,18 @@ def floatify(value) end end end -end -class LocationDrop - KEYS = Location.members.map(&:to_s).concat(%w[latitude longitude latlng]) + public def to_liquid + Drop.new(self) + end - def liquid_method_missing(key) - if KEYS.include?(key) - @object.__send__(key) + class Drop < LiquidDroppable::Drop + KEYS = Location.members.map(&:to_s).concat(%w[latitude longitude latlng]) + + def liquid_method_missing(key) + if KEYS.include?(key) + @object.__send__(key) + end end end end diff --git a/spec/capybara_helper.rb b/spec/capybara_helper.rb index 3551f4c91f..81aeb085d8 100644 --- a/spec/capybara_helper.rb +++ b/spec/capybara_helper.rb @@ -1,20 +1,12 @@ require 'rails_helper' require 'capybara/rails' -require 'capybara/poltergeist' -require 'capybara-screenshot/rspec' require 'capybara-select-2' CAPYBARA_TIMEOUT = ENV['CI'] == 'true' ? 60 : 5 -Capybara.register_driver :poltergeist do |app| - Capybara::Poltergeist::Driver.new(app, timeout: CAPYBARA_TIMEOUT) -end - -Capybara.javascript_driver = :poltergeist +Capybara.javascript_driver = ENV['USE_HEADED_CHROME'] ? :selenium_chrome : :selenium_chrome_headless Capybara.default_max_wait_time = CAPYBARA_TIMEOUT -Capybara::Screenshot.prune_strategy = { keep: 3 } - RSpec.configure do |config| config.include Warden::Test::Helpers config.include AlertConfirmer, type: :feature diff --git a/spec/concerns/liquid_droppable_spec.rb b/spec/concerns/liquid_droppable_spec.rb deleted file mode 100644 index 64dc384876..0000000000 --- a/spec/concerns/liquid_droppable_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'rails_helper' - -describe LiquidDroppable do - before do - class DroppableTest - include LiquidDroppable - - def initialize(value) - @value = value - end - - attr_reader :value - - def to_s - "[value:#{value}]" - end - end - - class DroppableTestDrop - def value - @object.value - end - end - end - - describe 'test class' do - it 'should be droppable' do - five = DroppableTest.new(5) - expect(five.to_liquid.class).to eq(DroppableTestDrop) - expect(Liquid::Template.parse('{{ x.value | plus:3 }}').render('x' => five)).to eq('8') - expect(Liquid::Template.parse('{{ x }}').render('x' => five)).to eq('[value:5]') - end - end -end diff --git a/spec/concerns/liquid_interpolatable_spec.rb b/spec/concerns/liquid_interpolatable_spec.rb index 127a43f0ed..084e17b471 100644 --- a/spec/concerns/liquid_interpolatable_spec.rb +++ b/spec/concerns/liquid_interpolatable_spec.rb @@ -23,7 +23,7 @@ class Agents::InterpolatableAgent < Agent include LiquidInterpolatable def check - create_event :payload => {} + create_event payload: {} end def validate_options @@ -52,7 +52,7 @@ def validate_options let(:agent) { Agents::InterpolatableAgent.new(name: "test") } it 'serializes data to json' do - agent.interpolation_context['something'] = {foo: 'bar'} + agent.interpolation_context['something'] = { foo: 'bar' } agent.options['cleaned'] = '{{ something | json }}' expect(agent.interpolated['cleaned']).to eq('{"foo":"bar"}') end @@ -67,8 +67,8 @@ def @filter.to_xpath_roundtrip(string) it 'should escape a string for use in XPath expression' do [ - %q{abc}.freeze, - %q{'a"bc'dfa""fds''fa}.freeze, + 'abc'.freeze, + %q('a"bc'dfa""fds''fa).freeze, ].each { |string| expect(@filter.to_xpath_roundtrip(string)).to eq(string) } @@ -82,7 +82,8 @@ def @filter.to_xpath_roundtrip(string) describe 'to_uri' do before do - @agent = Agents::InterpolatableAgent.new(name: "test", options: { 'foo' => '{% assign u = s | to_uri %}{{ u.path }}' }) + @agent = Agents::InterpolatableAgent.new(name: "test", + options: { 'foo' => '{% assign u = s | to_uri %}{{ u.path }}' }) @agent.interpolation_context['s'] = 'http://example.com/dir/1?q=test' end @@ -132,21 +133,21 @@ def @filter.to_xpath_roundtrip(string) describe 'uri_expand' do before do - stub_request(:head, 'https://t.co.x/aaaa'). - to_return(status: 301, headers: { Location: 'https://bit.ly.x/bbbb' }) - stub_request(:head, 'https://bit.ly.x/bbbb'). - to_return(status: 301, headers: { Location: 'http://tinyurl.com.x/cccc' }) - stub_request(:head, 'http://tinyurl.com.x/cccc'). - to_return(status: 301, headers: { Location: 'http://www.example.com/welcome' }) - stub_request(:head, 'http://www.example.com/welcome'). - to_return(status: 200) + stub_request(:head, 'https://t.co.x/aaaa') + .to_return(status: 301, headers: { Location: 'https://bit.ly.x/bbbb' }) + stub_request(:head, 'https://bit.ly.x/bbbb') + .to_return(status: 301, headers: { Location: 'http://tinyurl.com.x/cccc' }) + stub_request(:head, 'http://tinyurl.com.x/cccc') + .to_return(status: 301, headers: { Location: 'http://www.example.com/welcome' }) + stub_request(:head, 'http://www.example.com/welcome') + .to_return(status: 200) (1..5).each do |i| - stub_request(:head, "http://2many.x/#{i}"). - to_return(status: 301, headers: { Location: "http://2many.x/#{i+1}" }) + stub_request(:head, "http://2many.x/#{i}") + .to_return(status: 301, headers: { Location: "http://2many.x/#{i + 1}" }) end - stub_request(:head, 'http://2many.x/6'). - to_return(status: 301, headers: { 'Content-Length' => '5' }) + stub_request(:head, 'http://2many.x/6') + .to_return(status: 301, headers: { 'Content-Length' => '5' }) end it 'should handle inaccessible URIs' do @@ -171,20 +172,20 @@ def @filter.to_xpath_roundtrip(string) end it 'should detect a redirect loop' do - stub_request(:head, 'http://bad.x/aaaa'). - to_return(status: 301, headers: { Location: 'http://bad.x/bbbb' }) - stub_request(:head, 'http://bad.x/bbbb'). - to_return(status: 301, headers: { Location: 'http://bad.x/aaaa' }) + stub_request(:head, 'http://bad.x/aaaa') + .to_return(status: 301, headers: { Location: 'http://bad.x/bbbb' }) + stub_request(:head, 'http://bad.x/bbbb') + .to_return(status: 301, headers: { Location: 'http://bad.x/aaaa' }) expect(@filter.uri_expand('http://bad.x/aaaa')).to eq('http://bad.x/aaaa') end it 'should be able to handle an FTP URL' do - stub_request(:head, 'http://downloads.x/aaaa'). - to_return(status: 301, headers: { Location: 'http://downloads.x/download?file=aaaa.zip' }) - stub_request(:head, 'http://downloads.x/download'). - with(query: { file: 'aaaa.zip' }). - to_return(status: 301, headers: { Location: 'ftp://downloads.x/pub/aaaa.zip' }) + stub_request(:head, 'http://downloads.x/aaaa') + .to_return(status: 301, headers: { Location: 'http://downloads.x/download?file=aaaa.zip' }) + stub_request(:head, 'http://downloads.x/download') + .with(query: { file: 'aaaa.zip' }) + .to_return(status: 301, headers: { Location: 'ftp://downloads.x/pub/aaaa.zip' }) expect(@filter.uri_expand('http://downloads.x/aaaa')).to eq('ftp://downloads.x/pub/aaaa.zip') end @@ -223,7 +224,8 @@ def @filter.to_xpath_roundtrip(string) it 'should support escaped characters' do agent.interpolation_context['something'] = "foo\\1\n\nfoo\\bar\n\nfoo\\baz" - agent.options['test'] = "{{ something | regex_replace_first: '\\\\(\\w{2,})', '\\1\\\\' | regex_replace_first: '\\n+', '\\n' }}" + agent.options['test'] = + "{{ something | regex_replace_first: '\\\\(\\w{2,})', '\\1\\\\' | regex_replace_first: '\\n+', '\\n' }}" expect(agent.interpolated['test']).to eq("foo\\1\nfoobar\\\n\nfoo\\baz") end end @@ -233,13 +235,15 @@ def @filter.to_xpath_roundtrip(string) it 'should extract the matched part' do agent.interpolation_context['something'] = "foo BAR BAZ" - agent.options['test'] = "{{ something | regex_extract: '[A-Z]+' }} / {{ something | regex_extract: '[A-Z]([A-Z]+)', 1 }} / {{ something | regex_extract: '(?.)AZ', 'x' }}" + agent.options['test'] = + "{{ something | regex_extract: '[A-Z]+' }} / {{ something | regex_extract: '[A-Z]([A-Z]+)', 1 }} / {{ something | regex_extract: '(?.)AZ', 'x' }}" expect(agent.interpolated['test']).to eq("BAR / AR / B") end it 'should return nil if not matched' do agent.interpolation_context['something'] = "foo BAR BAZ" - agent.options['test'] = "{% assign var = something | regex_extract: '[A-Z][a-z]+' %}{% if var == nil %}nil{% else %}non-nil{% endif %}" + agent.options['test'] = + "{% assign var = something | regex_extract: '[A-Z][a-z]+' %}{% if var == nil %}nil{% else %}non-nil{% endif %}" expect(agent.interpolated['test']).to eq("nil") end end @@ -255,7 +259,8 @@ def @filter.to_xpath_roundtrip(string) it 'should support escaped characters' do agent.interpolation_context['something'] = "foo\\1\n\nfoo\\bar\n\nfoo\\baz" - agent.options['test'] = "{{ something | regex_replace: '\\\\(\\w{2,})', '\\1\\\\' | regex_replace: '\\n+', '\\n' }}" + agent.options['test'] = + "{{ something | regex_replace: '\\\\(\\w{2,})', '\\1\\\\' | regex_replace: '\\n+', '\\n' }}" expect(agent.interpolated['test']).to eq("foo\\1\nfoobar\\\nfoobaz\\") end end @@ -265,21 +270,24 @@ def @filter.to_xpath_roundtrip(string) it 'should replace the first occurrence of a string using regex' do agent.interpolation_context['something'] = 'foobar zoobar' - agent.options['cleaned'] = '{% regex_replace_first "(?\S+)(?bar)" in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace_first %}' + agent.options['cleaned'] = + '{% regex_replace_first "(?\S+)(?bar)" in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace_first %}' expect(agent.interpolated['cleaned']).to eq('FOObar zoobar') end it 'should be able to take a pattern in a variable' do agent.interpolation_context['something'] = 'foobar zoobar' agent.interpolation_context['pattern'] = "(?\\S+)(?bar)" - agent.options['cleaned'] = '{% regex_replace_first pattern in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace_first %}' + agent.options['cleaned'] = + '{% regex_replace_first pattern in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace_first %}' expect(agent.interpolated['cleaned']).to eq('FOObar zoobar') end it 'should define a variable named "match" in a "with" block' do agent.interpolation_context['something'] = 'foobar zoobar' agent.interpolation_context['pattern'] = "(?\\S+)(?bar)" - agent.options['cleaned'] = '{% regex_replace_first pattern in %}{{ something }}{% with %}{{ match.word | upcase }}{{ match["suffix"] }}{% endregex_replace_first %}' + agent.options['cleaned'] = + '{% regex_replace_first pattern in %}{{ something }}{% with %}{{ match.word | upcase }}{{ match["suffix"] }}{% endregex_replace_first %}' expect(agent.interpolated['cleaned']).to eq('FOObar zoobar') end end @@ -289,7 +297,8 @@ def @filter.to_xpath_roundtrip(string) it 'should replace the all occurrences of a string using regex' do agent.interpolation_context['something'] = 'foobar zoobar' - agent.options['cleaned'] = '{% regex_replace "(?\S+)(?bar)" in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace %}' + agent.options['cleaned'] = + '{% regex_replace "(?\S+)(?bar)" in %}{{ something }}{% with %}{{ word | upcase }}{{ suffix }}{% endregex_replace %}' expect(agent.interpolated['cleaned']).to eq('FOObar ZOObar') end end @@ -304,9 +313,9 @@ def @filter.to_xpath_roundtrip(string) end it 'returns an object that was not modified in liquid' do - agent.interpolation_context['something'] = {'nested' => {'abc' => 'test'}} + agent.interpolation_context['something'] = { 'nested' => { 'abc' => 'test' } } agent.options['object'] = "{{something.nested | as_object}}" - expect(agent.interpolated['object']).to eq({"abc" => 'test'}) + expect(agent.interpolated['object']).to eq({ "abc" => 'test' }) end context 'as_json' do @@ -315,19 +324,19 @@ def ensure_safety(obj) end it 'it converts "complex" objects' do - agent.interpolation_context['something'] = {'nested' => Service.new} + agent.interpolation_context['something'] = { 'nested' => Service.new } agent.options['object'] = "{{something | as_object}}" - expect(agent.interpolated['object']).to eq({'nested'=> ensure_safety(Service.new.as_json)}) + expect(agent.interpolated['object']).to eq({ 'nested' => ensure_safety(Service.new.as_json) }) end - it 'works with AgentDrops' do + it 'works with Agent::Drops' do agent.interpolation_context['something'] = agent agent.options['object'] = "{{something | as_object}}" expect(agent.interpolated['object']).to eq(ensure_safety(agent.to_liquid.as_json.stringify_keys)) end - it 'works with EventDrops' do - event = Event.new(payload: {some: 'payload'}, agent: agent, created_at: Time.now) + it 'works with Event::Drops' do + event = Event.new(payload: { some: 'payload' }, agent:, created_at: Time.now) agent.interpolation_context['something'] = event agent.options['object'] = "{{something | as_object}}" expect(agent.interpolated['object']).to eq(ensure_safety(event.to_liquid.as_json.stringify_keys)) @@ -352,33 +361,33 @@ def ensure_safety(obj) describe 'rebase_hrefs' do let(:agent) { Agents::InterpolatableAgent.new(name: "test") } - let(:fragment) { < -
  • - file1 -
  • -
  • - file2 -
  • -
  • - file3 -
  • - -HTML - - let(:replaced_fragment) { < -
  • - file1 -
  • -
  • - file2 -
  • -
  • - file3 -
  • - -HTML + let(:fragment) { <<~HTML } + + HTML + + let(:replaced_fragment) { <<~HTML } + + HTML it 'rebases relative URLs in a fragment' do agent.interpolation_context['content'] = fragment diff --git a/spec/concerns/twitter_concern_spec.rb b/spec/concerns/twitter_concern_spec.rb index d6eb98c8ec..fe705177f3 100644 --- a/spec/concerns/twitter_concern_spec.rb +++ b/spec/concerns/twitter_concern_spec.rb @@ -107,6 +107,7 @@ class TestTwitterAgent < Agent context "when a Twitter::Tweet object is given" do let(:input) { Twitter::Tweet.new(tweet_hash) } + let(:expected) { super().then { |attrs| attrs.update(text: attrs[:full_text]) } } it "formats a tweet" do expect(subject).to eq(expected) end diff --git a/spec/controllers/agents/dry_runs_controller_spec.rb b/spec/controllers/agents/dry_runs_controller_spec.rb index 77550b775b..6e6aa28cc4 100644 --- a/spec/controllers/agents/dry_runs_controller_spec.rb +++ b/spec/controllers/agents/dry_runs_controller_spec.rb @@ -16,7 +16,7 @@ def valid_attributes(options = {}) describe "GET index" do it "does not load any events without specifing sources" do - get :index, params: {type: 'Agents::WebsiteAgent', source_ids: []} + get :index, params: { type: 'Agents::WebsiteAgent', source_ids: [] } expect(assigns(:events)).to eq([]) end @@ -29,13 +29,13 @@ def valid_attributes(options = {}) end it "for new agents" do - get :index, params: {type: 'Agents::WebsiteAgent', source_ids: [@agent.id]} + get :index, params: { type: 'Agents::WebsiteAgent', source_ids: [@agent.id] } expect(assigns(:events)).to eq([]) end it "for existing agents" do expect(@agent.events.count).not_to be(0) - expect { get :index, params: {agent_id: @agent} }.to raise_error(NoMethodError) + expect { get :index, params: { agent_id: @agent } }.to raise_error(NoMethodError) end end @@ -47,12 +47,12 @@ def valid_attributes(options = {}) end it "load the most recent events when providing source ids" do - get :index, params: {type: 'Agents::WebsiteAgent', source_ids: [@agent.id]} + get :index, params: { type: 'Agents::WebsiteAgent', source_ids: [@agent.id] } expect(assigns(:events)).to eq([@agent.events.first]) end it "loads the most recent events for a saved agent" do - get :index, params: {agent_id: @agent} + get :index, params: { agent_id: @agent } expect(assigns(:events)).to eq([@agent.events.first]) end end @@ -60,12 +60,13 @@ def valid_attributes(options = {}) describe "POST create" do before do - stub_request(:any, /xkcd/).to_return(body: File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), status: 200) + stub_request(:any, /xkcd/).to_return(body: File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), + status: 200) end it "does not actually create any agent, event or log" do expect { - post :create, params: {agent: valid_attributes} + post :create, params: { agent: valid_attributes } }.not_to change { [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count] } @@ -81,7 +82,7 @@ def valid_attributes(options = {}) it "does not actually update an agent" do agent = agents(:bob_weather_agent) expect { - post :create, params: {agent_id: agent, agent: valid_attributes(name: 'New Name')} + post :create, params: { agent_id: agent, agent: valid_attributes(name: 'New Name') } }.not_to change { [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count, agent.name, agent.updated_at] } @@ -93,7 +94,7 @@ def valid_attributes(options = {}) agent.save! url_from_event = "http://xkcd.com/?from_event=1".freeze expect { - post :create, params: {agent_id: agent, event: { url: url_from_event }.to_json} + post :create, params: { agent_id: agent.id, event: { url: url_from_event }.to_json } }.not_to change { [users(:bob).agents.count, users(:bob).events.count, users(:bob).logs.count, agent.name, agent.updated_at] } @@ -103,25 +104,25 @@ def valid_attributes(options = {}) it "uses the memory of an existing Agent" do valid_params = { - :name => "somename", - :options => { - :code => "Agent.check = function() { this.createEvent({ 'message': this.memory('fu') }); };", + name: "somename", + options: { + code: "Agent.check = function() { this.createEvent({ 'message': this.memory('fu') }); };", } } agent = Agents::JavaScriptAgent.new(valid_params) - agent.memory = {fu: "bar"} + agent.memory = { fu: "bar" } agent.user = users(:bob) agent.save! - post :create, params: {agent_id: agent, agent: valid_params} + post :create, params: { agent_id: agent, agent: valid_params } results = assigns(:results) - expect(results[:events][0]).to eql({"message" => "bar"}) + expect(results[:events][0]).to eql({ "message" => "bar" }) end it 'sets created_at of the dry-runned event' do agent = agents(:bob_formatting_agent) - agent.options['instructions'] = {'created_at' => '{{created_at | date: "%a, %b %d, %y"}}'} + agent.options['instructions'] = { 'created_at' => '{{created_at | date: "%a, %b %d, %y"}}' } agent.save - post :create, params: {agent_id: agent, event: {test: 1}.to_json} + post :create, params: { agent_id: agent, event: { test: 1 }.to_json } results = assigns(:results) expect(results[:events]).to be_a(Array) expect(results[:events].length).to eq(1) diff --git a/spec/features/create_an_agent_spec.rb b/spec/features/create_an_agent_spec.rb index 04eb297877..4196755d07 100644 --- a/spec/features/create_an_agent_spec.rb +++ b/spec/features/create_an_agent_spec.rb @@ -7,7 +7,7 @@ it "creates an agent" do visit "/" - page.find("a", text: "Agents").trigger(:mouseover) + page.find("a", text: "Agents").hover click_on("New Agent") select_agent_type("Trigger Agent") @@ -43,7 +43,7 @@ it "creates an agent with a source and a receiver" do visit "/" - page.find("a", text: "Agents").trigger(:mouseover) + page.find("a", text: "Agents").hover click_on("New Agent") select_agent_type("Trigger Agent") @@ -64,7 +64,7 @@ it "creates an agent with a control target" do visit "/" - page.find("a", text: "Agents").trigger(:mouseover) + page.find("a", text: "Agents").hover click_on("New Agent") select_agent_type("Scheduler Agent") @@ -83,7 +83,7 @@ it "creates an agent with a controller" do visit "/" - page.find("a", text: "Agents").trigger(:mouseover) + page.find("a", text: "Agents").hover click_on("New Agent") select_agent_type("Weather Agent") @@ -103,7 +103,7 @@ it "creates an alert if a new agent with invalid json is submitted" do visit "/" - page.find("a", text: "Agents").trigger(:mouseover) + page.find("a", text: "Agents").hover click_on("New Agent") select_agent_type("Trigger Agent") @@ -114,7 +114,9 @@ "expected_receive_period_in_days": "2" "keep_event": "false" }') - expect(get_alert_text_from { click_on "Save" }).to have_text("Sorry, there appears to be an error in your JSON input. Please fix it before continuing.") + expect(get_alert_text_from { + click_on "Save" + }).to have_text("Sorry, there appears to be an error in your JSON input. Please fix it before continuing.") end context "displaying the correct information" do diff --git a/spec/models/agent_spec.rb b/spec/models/agent_spec.rb index 2737f0df34..de8756769b 100644 --- a/spec/models/agent_spec.rb +++ b/spec/models/agent_spec.rb @@ -33,7 +33,7 @@ describe ".bulk_check" do before do - @weather_agent_count = Agents::WeatherAgent.where(:schedule => "midnight", :disabled => false).count + @weather_agent_count = Agents::WeatherAgent.where(schedule: "midnight", disabled: false).count end it "should run all Agents with the given schedule" do @@ -142,7 +142,7 @@ class Agents::SomethingSource < Agent default_schedule "2pm" def check - create_event :payload => {} + create_event payload: {} end def validate_options @@ -154,8 +154,8 @@ class Agents::CannotBeScheduled < Agent cannot_be_scheduled! def receive(events) - events.each do |event| - create_event :payload => { :events_received => 1 } + events.each do |_event| + create_event payload: { events_received: 1 } end end end @@ -167,7 +167,7 @@ def receive(events) describe Agents::SomethingSource do let(:new_instance) do - agent = Agents::SomethingSource.new(:name => "some agent") + agent = Agents::SomethingSource.new(name: "some agent") agent.user = users(:bob) agent end @@ -190,7 +190,7 @@ def receive(events) end it "sets the default on new instances, allows setting new schedules, and prevents invalid schedules" do - @checker = Agents::SomethingSource.new(:name => "something") + @checker = Agents::SomethingSource.new(name: "something") @checker.user = users(:bob) expect(@checker.schedule).to eq("2pm") @checker.save! @@ -205,7 +205,7 @@ def receive(events) end it "should have an empty schedule if it cannot_be_scheduled" do - @checker = Agents::CannotBeScheduled.new(:name => "something") + @checker = Agents::CannotBeScheduled.new(name: "something") @checker.user = users(:bob) expect(@checker.schedule).to be_nil expect(@checker).to be_valid @@ -221,7 +221,7 @@ def receive(events) describe "#create_event" do before do - @checker = Agents::SomethingSource.new(:name => "something") + @checker = Agents::SomethingSource.new(name: "something") @checker.user = users(:bob) @checker.save! end @@ -242,7 +242,7 @@ def receive(events) describe ".async_check" do before do - @checker = Agents::SomethingSource.new(:name => "something") + @checker = Agents::SomethingSource.new(name: "something") @checker.user = users(:bob) @checker.save! end @@ -283,7 +283,8 @@ def receive(events) describe ".receive!" do before do - stub_request(:any, /darksky/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/weather.json")), :status => 200) + stub_request(:any, /darksky/).to_return(body: File.read(Rails.root.join("spec/data_fixtures/weather.json")), + status: 200) end it "should use available events" do @@ -360,7 +361,7 @@ def receive(events) it "should group events" do count = 0 - allow_any_instance_of(Agents::TriggerAgent).to receive(:receive) { |agent, events| + allow_any_instance_of(Agents::TriggerAgent).to receive(:receive) { |_agent, events| count += 1 expect(events.map(&:user).map(&:username).uniq.length).to eq(1) } @@ -381,7 +382,7 @@ def receive(events) end it "should ignore events that were created before a particular Link" do - agent2 = Agents::SomethingSource.new(:name => "something") + agent2 = Agents::SomethingSource.new(name: "something") agent2.user = users(:bob) agent2.save! agent2.check @@ -436,14 +437,14 @@ def receive(events) describe "creating a new agent and then calling .receive!" do it "should not backfill events for a newly created agent" do Event.delete_all - sender = Agents::SomethingSource.new(:name => "Sending Agent") + sender = Agents::SomethingSource.new(name: "Sending Agent") sender.user = users(:bob) sender.save! - sender.create_event :payload => {} - sender.create_event :payload => {} + sender.create_event payload: {} + sender.create_event payload: {} expect(sender.events.count).to eq(2) - receiver = Agents::CannotBeScheduled.new(:name => "Receiving Agent") + receiver = Agents::CannotBeScheduled.new(name: "Receiving Agent") receiver.user = users(:bob) receiver.sources << sender receiver.save! @@ -451,7 +452,7 @@ def receive(events) expect(receiver.events.count).to eq(0) Agent.receive! expect(receiver.events.count).to eq(0) - sender.create_event :payload => {} + sender.create_event payload: {} Agent.receive! expect(receiver.events.count).to eq(1) end @@ -460,32 +461,32 @@ def receive(events) describe "creating agents with propagate_immediately = true" do it "should schedule subagent events immediately" do Event.delete_all - sender = Agents::SomethingSource.new(:name => "Sending Agent") + sender = Agents::SomethingSource.new(name: "Sending Agent") sender.user = users(:bob) sender.save! receiver = Agents::CannotBeScheduled.new( - :name => "Receiving Agent", + name: "Receiving Agent", ) receiver.propagate_immediately = true receiver.user = users(:bob) receiver.sources << sender receiver.save! - sender.create_event :payload => {"message" => "new payload"} + sender.create_event payload: { "message" => "new payload" } expect(sender.events.count).to eq(1) expect(receiver.events.count).to eq(1) - #should be true without calling Agent.receive! + # should be true without calling Agent.receive! end it "should only schedule receiving agents that are set to propagate_immediately" do Event.delete_all - sender = Agents::SomethingSource.new(:name => "Sending Agent") + sender = Agents::SomethingSource.new(name: "Sending Agent") sender.user = users(:bob) sender.save! im_receiver = Agents::CannotBeScheduled.new( - :name => "Immediate Receiving Agent", + name: "Immediate Receiving Agent", ) im_receiver.propagate_immediately = true im_receiver.user = users(:bob) @@ -493,20 +494,20 @@ def receive(events) im_receiver.save! slow_receiver = Agents::CannotBeScheduled.new( - :name => "Slow Receiving Agent", + name: "Slow Receiving Agent", ) slow_receiver.user = users(:bob) slow_receiver.sources << sender slow_receiver.save! - sender.create_event :payload => {"message" => "new payload"} + sender.create_event payload: { "message" => "new payload" } expect(sender.events.count).to eq(1) expect(im_receiver.events.count).to eq(1) - #we should get the quick one - #but not the slow one + # we should get the quick one + # but not the slow one expect(slow_receiver.events.count).to eq(0) Agent.receive! - #now we should have one in both + # now we should have one in both expect(im_receiver.events.count).to eq(1) expect(slow_receiver.events.count).to eq(1) end @@ -514,7 +515,7 @@ def receive(events) describe "validations" do it "calls validate_options" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.options[:bad] = true expect(agent).to have(1).error_on(:base) @@ -523,7 +524,7 @@ def receive(events) end it "makes options symbol-indifferent before validating" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.options["bad"] = true expect(agent).to have(1).error_on(:base) @@ -532,7 +533,7 @@ def receive(events) end it "makes memory symbol-indifferent before validating" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.memory["bad"] = 2 agent.save @@ -540,7 +541,7 @@ def receive(events) end it "should work when assigned a hash or JSON string" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.memory = {} expect(agent.memory).to eq({}) expect(agent.memory["foo"]).to be_nil @@ -578,11 +579,11 @@ def receive(events) agent.options = 5 expect(agent.options["hi"]).to eq(2) expect(agent).to have(1).errors_on(:options) - expect(agent.errors_on(:options)).to include("cannot be set to an instance of #{2.class}") # Integer (ruby >=2.4) or Fixnum (ruby <2.4) + expect(agent.errors_on(:options)).to include("cannot be set to an instance of #{2.class}") # Integer (ruby >=2.4) or Fixnum (ruby <2.4) end it "should not allow source agents owned by other people" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.source_ids = [agents(:bob_weather_agent).id] expect(agent).to have(0).errors_on(:sources) @@ -593,7 +594,7 @@ def receive(events) end it "should not allow target agents owned by other people" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.receiver_ids = [agents(:bob_weather_agent).id] expect(agent).to have(0).errors_on(:receivers) @@ -604,7 +605,7 @@ def receive(events) end it "should not allow controller agents owned by other people" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.controller_ids = [agents(:bob_weather_agent).id] expect(agent).to have(0).errors_on(:controllers) @@ -615,7 +616,7 @@ def receive(events) end it "should not allow control target agents owned by other people" do - agent = Agents::CannotBeScheduled.new(:name => "something") + agent = Agents::CannotBeScheduled.new(name: "something") agent.user = users(:bob) agent.control_target_ids = [agents(:bob_weather_agent).id] expect(agent).to have(0).errors_on(:control_targets) @@ -626,7 +627,7 @@ def receive(events) end it "should not allow scenarios owned by other people" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) agent.scenario_ids = [scenarios(:bob_weather).id] @@ -643,7 +644,7 @@ def receive(events) end it "validates keep_events_for" do - agent = Agents::SomethingSource.new(:name => "something") + agent = Agents::SomethingSource.new(name: "something") agent.user = users(:bob) expect(agent).to be_valid agent.keep_events_for = nil @@ -669,11 +670,11 @@ def receive(events) before do @time = "2014-01-01 01:00:00 +00:00" travel_to @time do - @agent = Agents::SomethingSource.new(:name => "something") + @agent = Agents::SomethingSource.new(name: "something") @agent.keep_events_for = 5.days @agent.user = users(:bob) @agent.save! - @event = @agent.create_event :payload => { "hello" => "world" } + @event = @agent.create_event payload: { "hello" => "world" } expect(@event.expires_at.to_i).to be_within(2).of(5.days.from_now.to_i) end end @@ -695,9 +696,9 @@ def receive(events) it "updates events' expires_at" do travel_to @time do expect { - @agent.options[:foo] = "bar1" - @agent.keep_events_for = 3.days - @agent.save! + @agent.options[:foo] = "bar1" + @agent.keep_events_for = 3.days + @agent.save! }.to change { @event.reload.expires_at } expect(@event.expires_at.to_i).to be_within(2).of(3.days.from_now.to_i) end @@ -731,18 +732,20 @@ def receive(events) @sender = Agents::SomethingSource.new( name: 'Agent (2)', options: { foo: 'bar2' }, - schedule: '5pm') + schedule: '5pm' + ) @sender.user = users(:bob) @sender.save! - @sender.create_event :payload => {} - @sender.create_event :payload => {} + @sender.create_event payload: {} + @sender.create_event payload: {} expect(@sender.events.count).to eq(2) @receiver = Agents::CannotBeScheduled.new( name: 'Agent', options: { foo: 'bar3' }, keep_events_for: 3.days, - propagate_immediately: true) + propagate_immediately: true + ) @receiver.user = users(:bob) @receiver.sources << @sender @receiver.memory[:test] = 1 @@ -752,19 +755,19 @@ def receive(events) it "should create a clone of a given agent for editing" do sender_clone = users(:bob).agents.build_clone(@sender) - expect(sender_clone.attributes).to eq(Agent.new.attributes. - update(@sender.slice(:user_id, :type, - :options, :schedule, :keep_events_for, :propagate_immediately)). - update('name' => 'Agent (2) (2)', 'options' => { 'foo' => 'bar2' })) + expect(sender_clone.attributes).to eq(Agent.new.attributes + .update(@sender.slice(:user_id, :type, + :options, :schedule, :keep_events_for, :propagate_immediately)) + .update('name' => 'Agent (2) (2)', 'options' => { 'foo' => 'bar2' })) expect(sender_clone.source_ids).to eq([]) receiver_clone = users(:bob).agents.build_clone(@receiver) - expect(receiver_clone.attributes).to eq(Agent.new.attributes. - update(@receiver.slice(:user_id, :type, - :options, :schedule, :keep_events_for, :propagate_immediately)). - update('name' => 'Agent (3)', 'options' => { 'foo' => 'bar3' })) + expect(receiver_clone.attributes).to eq(Agent.new.attributes + .update(@receiver.slice(:user_id, :type, + :options, :schedule, :keep_events_for, :propagate_immediately)) + .update('name' => 'Agent (3)', 'options' => { 'foo' => 'bar3' })) expect(receiver_clone.source_ids).to eq([@sender.id]) end @@ -782,7 +785,7 @@ class Agents::WebRequestReceiver < Agent context "when .receive_web_request is defined" do before do - @agent = Agents::WebRequestReceiver.new(:name => "something") + @agent = Agents::WebRequestReceiver.new(name: "something") @agent.user = users(:bob) @agent.save! @@ -794,46 +797,49 @@ def @agent.receive_web_request(params, method, format) it "calls the .receive_web_request hook, updates last_web_request_at, and saves" do request = ActionDispatch::Request.new({ - 'action_dispatch.request.request_parameters' => { :some_param => "some_value" }, + 'action_dispatch.request.request_parameters' => { some_param: "some_value" }, 'REQUEST_METHOD' => "POST", 'HTTP_ACCEPT' => 'text/html' }) @agent.trigger_web_request(request) - expect(@agent.reload.memory['last_request']).to eq([ { "some_param" => "some_value" }, "post", "text/html" ]) + expect(@agent.reload.memory['last_request']).to eq([{ "some_param" => "some_value" }, "post", "text/html"]) expect(@agent.last_web_request_at.to_i).to be_within(1).of(Time.now.to_i) end end context "when .receive_web_request is defined with just request" do before do - @agent = Agents::WebRequestReceiver.new(:name => "something") + @agent = Agents::WebRequestReceiver.new(name: "something") @agent.user = users(:bob) @agent.save! def @agent.receive_web_request(request) - memory['last_request'] = [request.params, request.method_symbol.to_s, request.format, {'HTTP_X_CUSTOM_HEADER' => request.headers['HTTP_X_CUSTOM_HEADER']}] + memory['last_request'] = + [request.params, request.method_symbol.to_s, request.format, + { 'HTTP_X_CUSTOM_HEADER' => request.headers['HTTP_X_CUSTOM_HEADER'] }] ['Ok!', 200] end end it "calls the .trigger_web_request with headers, and they get passed to .receive_web_request" do request = ActionDispatch::Request.new({ - 'action_dispatch.request.request_parameters' => { :some_param => "some_value" }, + 'action_dispatch.request.request_parameters' => { some_param: "some_value" }, 'REQUEST_METHOD' => "POST", 'HTTP_ACCEPT' => 'text/html', 'HTTP_X_CUSTOM_HEADER' => "foo" }) @agent.trigger_web_request(request) - expect(@agent.reload.memory['last_request']).to eq([ { "some_param" => "some_value" }, "post", "text/html", {'HTTP_X_CUSTOM_HEADER' => "foo"} ]) + expect(@agent.reload.memory['last_request']).to eq([{ "some_param" => "some_value" }, "post", "text/html", + { 'HTTP_X_CUSTOM_HEADER' => "foo" }]) expect(@agent.last_web_request_at.to_i).to be_within(1).of(Time.now.to_i) end end context "when .receive_webhook is defined" do before do - @agent = Agents::WebRequestReceiver.new(:name => "something") + @agent = Agents::WebRequestReceiver.new(name: "something") @agent.user = users(:bob) @agent.save! @@ -845,7 +851,7 @@ def @agent.receive_webhook(params) it "outputs a deprecation warning and calls .receive_webhook with the params" do request = ActionDispatch::Request.new({ - 'action_dispatch.request.request_parameters' => { :some_param => "some_value" }, + 'action_dispatch.request.request_parameters' => { some_param: "some_value" }, 'REQUEST_METHOD' => "POST", 'HTTP_ACCEPT' => 'text/html' }) @@ -890,7 +896,7 @@ def @agent.receive_webhook(params) end it "sets expires_at on created events" do - event = agents(:jane_weather_agent).create_event :payload => { 'hi' => 'there' } + event = agents(:jane_weather_agent).create_event payload: { 'hi' => 'there' } expect(event.expires_at.to_i).to be_within(5).of(agents(:jane_weather_agent).keep_events_for.seconds.from_now.to_i) end end @@ -901,7 +907,7 @@ def @agent.receive_webhook(params) end it "does not set expires_at on created events" do - event = agents(:jane_website_agent).create_event :payload => { 'hi' => 'there' } + event = agents(:jane_website_agent).create_event payload: { 'hi' => 'there' } expect(event.expires_at).to be_nil end end @@ -925,7 +931,8 @@ def @agent.receive_webhook(params) describe ".drop_pending_events" do before do - stub_request(:any, /darksky/).to_return(body: File.read(Rails.root.join("spec/data_fixtures/weather.json")), status: 200) + stub_request(:any, /darksky/).to_return(body: File.read(Rails.root.join("spec/data_fixtures/weather.json")), + status: 200) end it "should drop pending events while the agent was disabled when set to true" do @@ -960,7 +967,7 @@ def @agent.receive_webhook(params) end end -describe AgentDrop do +describe Agent::Drop do def interpolate(string, agent) agent.interpolate_string(string, "agent" => agent) end @@ -979,7 +986,8 @@ def interpolate(string, agent) }, }, schedule: 'every_1h', - keep_events_for: 2.days) + keep_events_for: 2.days + ) @wsa1.user = users(:bob) @wsa1.save! @@ -996,7 +1004,8 @@ def interpolate(string, agent) }, }, schedule: 'every_12h', - keep_events_for: 2.days) + keep_events_for: 2.days + ) @wsa2.user = users(:bob) @wsa2.save! @@ -1012,7 +1021,8 @@ def interpolate(string, agent) skip_created_at: 'false', }, keep_events_for: 2.days, - propagate_immediately: true) + propagate_immediately: true + ) @efa.user = users(:bob) @efa.sources << @wsa1 << @wsa2 @efa.memory[:test] = 1 @@ -1022,9 +1032,9 @@ def interpolate(string, agent) end it 'should be created via Agent#to_liquid' do - expect(@wsa1.to_liquid.class).to be(AgentDrop) - expect(@wsa2.to_liquid.class).to be(AgentDrop) - expect(@efa.to_liquid.class).to be(AgentDrop) + expect(@wsa1.to_liquid.class).to be(Agent::Drop) + expect(@wsa2.to_liquid.class).to be(Agent::Drop) + expect(@efa.to_liquid.class).to be(Agent::Drop) end it 'should have .id, .type and .name' do @@ -1039,7 +1049,7 @@ def interpolate(string, agent) expect(interpolate(t, @wsa1)).to eq('http://xkcd.com/') expect(interpolate(t, @wsa2)).to eq('http://dilbert.com/') expect(interpolate('{{agent.options.instructions.message}}', - @efa)).to eq('{{agent.name}}: {{title}} {{url}}') + @efa)).to eq('{{agent.name}}: {{title}} {{url}}') end it 'should have .sources' do diff --git a/spec/models/agents/growl_agent_spec.rb b/spec/models/agents/growl_agent_spec.rb deleted file mode 100644 index c8487243a5..0000000000 --- a/spec/models/agents/growl_agent_spec.rb +++ /dev/null @@ -1,119 +0,0 @@ -require 'rails_helper' - -describe Agents::GrowlAgent do - before do - @checker = Agents::GrowlAgent.new(:name => 'a growl agent', - :options => { :growl_server => 'localhost', - :growl_app_name => 'HuginnGrowlApp', - :growl_password => 'mypassword', - :growl_notification_name => 'Notification', - expected_receive_period_in_days: '1' , - message: '{{message}}', - subject: '{{subject}}'}) - @checker.user = users(:bob) - @checker.save! - - allow_any_instance_of(Growl::GNTP).to receive(:notify) - - @event = Event.new - @event.agent = agents(:bob_weather_agent) - @event.payload = { :subject => 'Weather Alert!', :message => 'Looks like its going to rain' } - @event.save! - end - - describe "#working?" do - it "checks if events have been received within the expected receive period" do - expect(@checker).not_to be_working # No events received - Agents::GrowlAgent.async_receive @checker.id, [@event.id] - expect(@checker.reload).to be_working # Just received events - two_days_from_now = 2.days.from_now - allow(Time).to receive(:now) { two_days_from_now } - expect(@checker.reload).not_to be_working # More time has passed than the expected receive period without any new events - end - end - - describe "validation" do - before do - expect(@checker).to be_valid - end - - it "should validate presence of of growl_server" do - @checker.options[:growl_server] = "" - expect(@checker).not_to be_valid - end - - it "should validate presence of expected_receive_period_in_days" do - @checker.options[:expected_receive_period_in_days] = "" - expect(@checker).not_to be_valid - end - end - - describe "register_growl" do - it "should set the password for the Growl connection from the agent options" do - @checker.register_growl - expect(@checker.growler.password).to eql(@checker.options[:growl_password]) - end - - it "should add a notification to the Growl connection" do - expect(Growl::GNTP).to receive(:new).and_wrap_original do |orig, *args| - orig.call(*args).tap { |obj| - expect(obj).to receive(:add_notification).with(@checker.options[:growl_notification_name]) - } - end - @checker.register_growl - end - end - - describe "notify_growl" do - it "should call Growl.notify with the correct notification name, subject, and message" do - message = "message" - subject = "subject" - expect(Growl::GNTP).to receive(:new).and_wrap_original do |orig, *args| - orig.call(*args).tap { |obj| - expect(obj).to receive(:notify).with(@checker.options[:growl_notification_name], subject, message, 0, false, nil, '') - } - end - @checker.register_growl - @checker.notify_growl(subject: subject, message: message, sticky: false, priority: 0, callback_url: '') - end - end - - describe "receive" do - def generate_events_array - events = [] - (2..rand(7)).each do - events << @event - end - return events - end - - it "should call register_growl once per received event" do - events = generate_events_array - expect(@checker).to receive(:register_growl).exactly(events.length).times.and_call_original - @checker.receive(events) - end - - it "should call notify_growl one time for each event received" do - events = generate_events_array - events.each do |event| - expect(@checker).to receive(:notify_growl).with(subject: event.payload['subject'], message: event.payload['message'], priority: 0, sticky: false, callback_url: nil) - end - @checker.receive(events) - end - - it "should not call notify_growl if message or subject are missing" do - event_without_a_subject = Event.new - event_without_a_subject.agent = agents(:bob_weather_agent) - event_without_a_subject.payload = { :message => 'Looks like its going to rain' } - event_without_a_subject.save! - - event_without_a_message = Event.new - event_without_a_message.agent = agents(:bob_weather_agent) - event_without_a_message.payload = { :subject => 'Weather Alert YO!' } - event_without_a_message.save! - - expect(@checker).not_to receive(:notify_growl) - @checker.receive([event_without_a_subject,event_without_a_message]) - end - end -end diff --git a/spec/models/agents/shell_command_agent_spec.rb b/spec/models/agents/shell_command_agent_spec.rb index 0b82a44fb8..800f2ad314 100644 --- a/spec/models/agents/shell_command_agent_spec.rb +++ b/spec/models/agents/shell_command_agent_spec.rb @@ -12,7 +12,8 @@ @valid_params2 = { path: @valid_path, - command: [RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"'], + command: [RbConfig.ruby, '-e', + 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"'], stdin: "{{name}}", expected_update_period_in_days: '1', } @@ -60,7 +61,7 @@ describe "#working?" do it "generating events as scheduled" do - allow(@checker).to receive(:run_command).with(@valid_path, 'pwd', nil, {}) { ["fake pwd output", "", 0] } + allow(@checker).to receive(:run_command).with(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] } expect(@checker).not_to be_working @checker.check @@ -74,13 +75,18 @@ describe "#check" do before do orig_run_command = @checker.method(:run_command) - allow(@checker).to receive(:run_command).with(@valid_path, 'pwd', nil, {}) { ["fake pwd output", "", 0] } - allow(@checker).to receive(:run_command).with(@valid_path, 'empty_output', nil, {}) { ["", "", 0] } - allow(@checker).to receive(:run_command).with(@valid_path, 'failure', nil, {}) { ["failed", "error message", 1] } - allow(@checker).to receive(:run_command).with(@valid_path, 'echo $BUNDLE_GEMFILE', nil, unbundle: true) { orig_run_command.(@valid_path, 'echo $BUNDLE_GEMFILE', nil, unbundle: true) } - [[], [{}], [{ unbundle: false }]].each do |rest| - allow(@checker).to receive(:run_command).with(@valid_path, 'echo $BUNDLE_GEMFILE', nil, *rest) { [ENV['BUNDLE_GEMFILE'].to_s, "", 0] } - end + allow(@checker).to receive(:run_command).with(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] } + allow(@checker).to receive(:run_command).with(@valid_path, 'empty_output', nil) { ["", "", 0] } + allow(@checker).to receive(:run_command).with(@valid_path, 'failure', nil) { ["failed", "error message", 1] } + allow(@checker).to receive(:run_command).with(@valid_path, 'echo $BUNDLE_GEMFILE', nil, unbundle: true) { + orig_run_command.call(@valid_path, 'echo $BUNDLE_GEMFILE', nil, unbundle: true) + } + allow(@checker).to receive(:run_command).with(@valid_path, 'echo $BUNDLE_GEMFILE', nil) { + [ENV['BUNDLE_GEMFILE'].to_s, "", 0] + } + allow(@checker).to receive(:run_command).with(@valid_path, 'echo $BUNDLE_GEMFILE', nil, unbundle: false) { + [ENV['BUNDLE_GEMFILE'].to_s, "", 0] + } end it "should create an event when checking" do @@ -93,7 +99,10 @@ it "should create an event when checking (unstubbed)" do expect { @checker2.check }.to change { Event.count }.by(1) expect(Event.last.payload[:path]).to eq(@valid_path) - expect(Event.last.payload[:command]).to eq([RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"']) + expect(Event.last.payload[:command]).to eq [ + RbConfig.ruby, '-e', + 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"' + ] expect(Event.last.payload[:output]).to eq('hello, world.') expect(Event.last.payload[:errors]).to eq('warning!') end @@ -169,7 +178,9 @@ describe "#receive" do before do - allow(@checker).to receive(:run_command).with(@valid_path, @event.payload[:cmd], nil, {}) { ["fake ls output", "", 0] } + allow(@checker).to receive(:run_command).with(@valid_path, @event.payload[:cmd], nil) { + ["fake ls output", "", 0] + } end it "creates events" do diff --git a/spec/models/agents/twitter_stream_agent_spec.rb b/spec/models/agents/twitter_stream_agent_spec.rb index 8073629ac6..b614bb8487 100644 --- a/spec/models/agents/twitter_stream_agent_spec.rb +++ b/spec/models/agents/twitter_stream_agent_spec.rb @@ -189,7 +189,7 @@ it "yields received tweets" do expect(@worker).to receive(:stream!).with(['agent'], @agent).and_yield('status' => 'hello') - expect(@worker).to receive(:handle_status).with('status' => 'hello') + expect(@worker).to receive(:handle_status).with({ 'status' => 'hello' }) expect(Thread).to receive(:stop) @worker.run end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index fea103371c..7287fce8a5 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -23,7 +23,8 @@ lng: nil, radius: 0.0, speed: nil, - course: nil)) + course: nil + )) end it "returns a hash containing location information" do @@ -41,7 +42,8 @@ lng: 3.0, radius: 0.0, speed: 0.5, - course: 90.0)) + course: 90.0 + )) end end @@ -63,10 +65,10 @@ describe ".cleanup_expired!" do it "removes any Events whose expired_at date is non-null and in the past, updating Agent counter caches" do - half_hour_event = agents(:jane_weather_agent).create_event :expires_at => 20.minutes.from_now - one_hour_event = agents(:bob_weather_agent).create_event :expires_at => 1.hours.from_now - two_hour_event = agents(:jane_weather_agent).create_event :expires_at => 2.hours.from_now - three_hour_event = agents(:jane_weather_agent).create_event :expires_at => 3.hours.from_now + half_hour_event = agents(:jane_weather_agent).create_event expires_at: 20.minutes.from_now + one_hour_event = agents(:bob_weather_agent).create_event expires_at: 1.hours.from_now + two_hour_event = agents(:jane_weather_agent).create_event expires_at: 2.hours.from_now + three_hour_event = agents(:jane_weather_agent).create_event expires_at: 3.hours.from_now non_expiring_event = agents(:bob_weather_agent).create_event({}) initial_bob_count = agents(:bob_weather_agent).reload.events_count @@ -150,20 +152,20 @@ describe "when an event is created" do it "updates a counter cache on agent" do expect { - agents(:jane_weather_agent).events.create!(:user => users(:jane)) + agents(:jane_weather_agent).events.create!(user: users(:jane)) }.to change { agents(:jane_weather_agent).reload.events_count }.by(1) end it "updates last_event_at on agent" do expect { - agents(:jane_weather_agent).events.create!(:user => users(:jane)) + agents(:jane_weather_agent).events.create!(user: users(:jane)) }.to change { agents(:jane_weather_agent).reload.last_event_at } end end describe "when an event is updated" do it "does not touch the last_event_at on the agent" do - event = agents(:jane_weather_agent).events.create!(:user => users(:jane)) + event = agents(:jane_weather_agent).events.create!(user: users(:jane)) agents(:jane_weather_agent).update_attribute :last_event_at, 2.days.ago @@ -175,7 +177,7 @@ end end -describe EventDrop do +describe Event::Drop do def interpolate(string, event) event.agent.interpolate_string(string, event.to_liquid) end @@ -194,7 +196,7 @@ def interpolate(string, event) end it 'should be created via Agent#to_liquid' do - expect(@event.to_liquid.class).to be(EventDrop) + expect(@event.to_liquid.class).to be(Event::Drop) end it 'should have attributes of its payload' do diff --git a/spec/presenters/form_configurable_agent_presenter_spec.rb b/spec/presenters/form_configurable_agent_presenter_spec.rb index 6f7e39a584..64ece21ed3 100644 --- a/spec/presenters/form_configurable_agent_presenter_spec.rb +++ b/spec/presenters/form_configurable_agent_presenter_spec.rb @@ -12,30 +12,61 @@ class FormConfigurableAgentPresenterAgent < Agent end before(:all) do - @presenter = FormConfigurableAgentPresenter.new(FormConfigurableAgentPresenterAgent.new, ActionController::Base.new.view_context) + @presenter = FormConfigurableAgentPresenter.new(FormConfigurableAgentPresenterAgent.new, + ActionController::Base.new.view_context) end it "works for the type :string" do expect(@presenter.option_field_for(:string)).to( - have_tag('input', with: {:'data-attribute' => 'string', role: 'validatable form-configurable', type: 'text', name: 'agent[options][string]'}) + have_tag( + 'input', + with: { + 'data-attribute': 'string', + role: 'validatable form-configurable', + type: 'text', + name: 'agent[options][string]' + } + ) ) end it "works for the type :text" do expect(@presenter.option_field_for(:text)).to( - have_tag('textarea', with: {:'data-attribute' => 'text', role: 'completable form-configurable', name: 'agent[options][text]'}) + have_tag( + 'textarea', + with: { + 'data-attribute': 'text', + role: 'completable form-configurable', + name: 'agent[options][text]' + } + ) ) end it "works for the type :boolean" do expect(@presenter.option_field_for(:boolean)).to( - have_tag('input', with: {:'data-attribute' => 'boolean', role: 'form-configurable', name: 'agent[options][boolean_radio]', type: 'radio'}) + have_tag( + 'input', + with: { + 'data-attribute': 'boolean', + role: 'form-configurable', + name: 'agent[options][boolean_radio]', + type: 'radio' + } + ) ) end it "works for the type :array" do expect(@presenter.option_field_for(:array)).to( - have_tag('input', with: {:'data-attribute' => 'array', role: 'completable form-configurable', type: 'text', name: 'agent[options][array]'}) + have_tag( + 'select', + with: { + 'data-attribute': 'array', + role: 'completable form-configurable', + name: 'agent[options][array]' + } + ) ) end -end \ No newline at end of file +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 22c342a8ad..f821104eb2 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -4,11 +4,19 @@ require 'simplecov' SimpleCov.start 'rails' elsif ENV['CI'] == 'true' - require 'coveralls' - Coveralls.wear!('rails') + require 'simplecov' + SimpleCov.start 'rails' do + require 'simplecov-lcov' + SimpleCov::Formatter::LcovFormatter.config do |c| + c.report_with_single_file = true + c.single_report_path = 'coverage/lcov.info' + end + formatter SimpleCov::Formatter::LcovFormatter + add_filter %w[version.rb initializer.rb] + end end -require File.expand_path("../../config/environment", __FILE__) +require File.expand_path('../config/environment', __dir__) require 'rspec/rails' require 'webmock/rspec' diff --git a/spec/support/feature_helpers.rb b/spec/support/feature_helpers.rb index 836586dfa9..f8a649c9d8 100644 --- a/spec/support/feature_helpers.rb +++ b/spec/support/feature_helpers.rb @@ -1,6 +1,7 @@ module FeatureHelpers - def select_agent_type(type) - select2(type, from: "Type") + def select_agent_type(search) + agent_name = search[/\A.*?Agent\b/] || search + select2(agent_name, search:, from: "Type") # Wait for all parts of the Agent form to load: expect(page).to have_css("div.function_buttons") # Options editor