Browse Source

initial commit

Andrew Cantino 12 years ago
commit
620acffa5a
100 changed files with 4355 additions and 0 deletions
  1. 17 0
      .gitignore
  2. 1 0
      .rvmrc
  3. 2 0
      Capfile
  4. 50 0
      Gemfile
  5. 311 0
      Gemfile.lock
  6. 21 0
      LICENSE
  7. 65 0
      README.md
  8. 9 0
      Rakefile
  9. BIN
      app/assets/images/odin.jpg
  10. BIN
      app/assets/images/spinner-arrows.gif
  11. 94 0
      app/assets/javascripts/application.js.coffee.erb
  12. 60 0
      app/assets/javascripts/graphing.js.coffee
  13. 16 0
      app/assets/javascripts/worker-checker.js.coffee
  14. 67 0
      app/assets/stylesheets/agent_views/peak_detector_agent/show.css.scss
  15. 92 0
      app/assets/stylesheets/application.css.scss.erb
  16. 111 0
      app/controllers/agents_controller.rb
  17. 7 0
      app/controllers/application_controller.rb
  18. 34 0
      app/controllers/events_controller.rb
  19. 9 0
      app/controllers/home_controller.rb
  20. 18 0
      app/controllers/user_location_updates_controller.rb
  21. 13 0
      app/controllers/worker_status_controller.rb
  22. 12 0
      app/helpers/agent_helper.rb
  23. 17 0
      app/helpers/application_helper.rb
  24. 9 0
      app/mailers/system_mailer.rb
  25. 221 0
      app/models/agent.rb
  26. 66 0
      app/models/agents/digest_email_agent.rb
  27. 124 0
      app/models/agents/peak_detector_agent.rb
  28. 84 0
      app/models/agents/trigger_agent.rb
  29. 91 0
      app/models/agents/twitter_stream_agent.rb
  30. 44 0
      app/models/agents/user_location_agent.rb
  31. 68 0
      app/models/agents/weather_agent.rb
  32. 98 0
      app/models/agents/website_agent.rb
  33. 12 0
      app/models/contact.rb
  34. 20 0
      app/models/event.rb
  35. 6 0
      app/models/link.rb
  36. 36 0
      app/models/user.rb
  37. 71 0
      app/views/agents/_form.html.erb
  38. 33 0
      app/views/agents/agent_views/peak_detector_agent/_show.html.erb
  39. 52 0
      app/views/agents/agent_views/twitter_stream_agent/_show.html.erb
  40. 61 0
      app/views/agents/agent_views/user_location_agent/_map_marker.html.erb
  41. 26 0
      app/views/agents/agent_views/user_location_agent/_show.html.erb
  42. 27 0
      app/views/agents/diagram.html.erb
  43. 16 0
      app/views/agents/edit.html.erb
  44. 73 0
      app/views/agents/index.html.erb
  45. 15 0
      app/views/agents/new.html.erb
  46. 105 0
      app/views/agents/show.html.erb
  47. 27 0
      app/views/devise/confirmations/new.html.erb
  48. 5 0
      app/views/devise/mailer/confirmation_instructions.html.erb
  49. 8 0
      app/views/devise/mailer/reset_password_instructions.html.erb
  50. 7 0
      app/views/devise/mailer/unlock_instructions.html.erb
  51. 34 0
      app/views/devise/passwords/edit.html.erb
  52. 27 0
      app/views/devise/passwords/new.html.erb
  53. 61 0
      app/views/devise/registrations/edit.html.erb
  54. 55 0
      app/views/devise/registrations/new.html.erb
  55. 42 0
      app/views/devise/sessions/new.html.erb
  56. 25 0
      app/views/devise/shared/_links.erb
  57. 14 0
      app/views/devise/unlocks/new.html.erb
  58. 46 0
      app/views/events/index.html.erb
  59. 45 0
      app/views/events/show.html.erb
  60. 46 0
      app/views/home/_signed_in_index.html.erb
  61. 13 0
      app/views/home/_signed_out_index.html.erb
  62. 9 0
      app/views/home/about.html.erb
  63. 5 0
      app/views/home/index.html.erb
  64. 6 0
      app/views/layouts/_messages.html.erb
  65. 50 0
      app/views/layouts/_navigation.html.erb
  66. 36 0
      app/views/layouts/application.html.erb
  67. 16 0
      app/views/system_mailer/send_message.html.erb
  68. 5 0
      app/views/system_mailer/send_message.text.erb
  69. 10 0
      bin/decrypt_backup.rb
  70. 62 0
      bin/schedule.rb
  71. 120 0
      bin/twitter_stream.rb
  72. 11 0
      config.ru
  73. 62 0
      config/application.rb
  74. 6 0
      config/boot.rb
  75. 32 0
      config/database.yml
  76. 51 0
      config/deploy.rb
  77. 8 0
      config/environment.rb
  78. 51 0
      config/environments/development.rb
  79. 81 0
      config/environments/production.rb
  80. 44 0
      config/environments/test.rb
  81. 7 0
      config/initializers/backtrace_silencers.rb
  82. 8 0
      config/initializers/delayed_job.rb
  83. 236 0
      config/initializers/devise.rb
  84. 15 0
      config/initializers/inflections.rb
  85. 5 0
      config/initializers/mime_types.rb
  86. 129 0
      config/initializers/multi_xml_patch.rb
  87. 121 0
      config/initializers/rails_admin.rb
  88. 7 0
      config/initializers/recursively_symbolize_keys.rb
  89. 1 0
      config/initializers/requires.rb
  90. 7 0
      config/initializers/secret_token.rb
  91. 8 0
      config/initializers/session_store.rb
  92. 14 0
      config/initializers/wrap_parameters.rb
  93. 58 0
      config/locales/devise.en.yml
  94. 40 0
      config/locales/en.yml
  95. 67 0
      config/nginx/production.conf
  96. 26 0
      config/routes.rb
  97. 33 0
      config/unicorn/production.rb
  98. 46 0
      db/migrate/20120728210244_devise_create_users.rb
  99. 18 0
      db/migrate/20120728212820_create_rails_admin_histories_table.rb
  100. 5 0
      db/migrate/20120728215449_add_admin_to_users.rb

+ 17 - 0
.gitignore

@@ -0,0 +1,17 @@
+*.rbc
+*.sassc
+.sass-cache
+capybara-*.html
+.rspec
+/.bundle
+/vendor/bundle
+/log/*
+/tmp/*
+/db/*.sqlite3
+/public/system/*
+/coverage/
+/spec/tmp/*
+**.orig
+rerun.txt
+pickle-email-*.html
+.idea/

+ 1 - 0
.rvmrc

@@ -0,0 +1 @@
+rvm use 1.9.3-p374@huginn --create

+ 2 - 0
Capfile

@@ -0,0 +1,2 @@
+load 'deploy'
+load 'config/deploy'

+ 50 - 0
Gemfile

@@ -0,0 +1,50 @@
+source 'https://rubygems.org'
+
+gem 'rails'
+gem 'mysql2'
+gem 'devise'
+gem 'rails_admin'
+gem 'kaminari'
+gem 'bootstrap-kaminari-views'
+gem "rufus-scheduler", :require => false
+gem 'json', '>= 1.7.7'
+
+gem 'delayed_job_active_record', "~> 0.3.3" # newer was giving a strange MySQL error
+gem "daemons"
+# gem "delayed_job_web"
+
+group :assets do
+  gem 'sass-rails',   '~> 3.2.3'
+  gem 'coffee-rails', '~> 3.2.1'
+  gem 'uglifier', '>= 1.0.3'
+  gem 'select2-rails'
+  gem 'jquery-rails'
+end
+
+gem 'geokit-rails3'
+gem 'kramdown'
+gem "typhoeus"
+gem 'nokogiri'
+gem 'wunderground'
+
+gem "twitter"
+gem 'twitter-stream', '>=0.1.16'
+gem 'em-http-request'
+
+gem 'unicorn'
+gem 'backup', :require => false
+gem 'fog', '~> 1.4.0', :require => false
+
+group :development do
+  gem 'capistrano'
+  gem 'capistrano-unicorn', :require => false
+  gem 'rvm-capistrano'
+  gem 'pry'
+end
+
+group :development, :test do
+  gem 'rspec-rails'
+  gem 'rspec'
+  gem 'rr'
+  gem 'webmock', :require => false
+end

+ 311 - 0
Gemfile.lock

@@ -0,0 +1,311 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    actionmailer (3.2.12)
+      actionpack (= 3.2.12)
+      mail (~> 2.4.4)
+    actionpack (3.2.12)
+      activemodel (= 3.2.12)
+      activesupport (= 3.2.12)
+      builder (~> 3.0.0)
+      erubis (~> 2.7.0)
+      journey (~> 1.0.4)
+      rack (~> 1.4.5)
+      rack-cache (~> 1.2)
+      rack-test (~> 0.6.1)
+      sprockets (~> 2.2.1)
+    activemodel (3.2.12)
+      activesupport (= 3.2.12)
+      builder (~> 3.0.0)
+    activerecord (3.2.12)
+      activemodel (= 3.2.12)
+      activesupport (= 3.2.12)
+      arel (~> 3.0.2)
+      tzinfo (~> 0.3.29)
+    activeresource (3.2.12)
+      activemodel (= 3.2.12)
+      activesupport (= 3.2.12)
+    activesupport (3.2.12)
+      i18n (~> 0.6)
+      multi_json (~> 1.0)
+    addressable (2.3.3)
+    arel (3.0.2)
+    backup (3.1.2)
+      open4 (~> 1.3.0)
+      thor (>= 0.15.4, < 2)
+    bcrypt-ruby (3.0.1)
+    bootstrap-kaminari-views (0.0.2)
+      kaminari (>= 0.13)
+      rails (>= 3.1)
+    bootstrap-sass (2.3.0.1)
+      sass (~> 3.2)
+    builder (3.0.4)
+    capistrano (2.14.2)
+      highline
+      net-scp (>= 1.0.0)
+      net-sftp (>= 2.0.0)
+      net-ssh (>= 2.0.14)
+      net-ssh-gateway (>= 1.1.0)
+    capistrano-unicorn (0.1.6)
+      capistrano
+    coderay (1.0.9)
+    coffee-rails (3.2.2)
+      coffee-script (>= 2.2.0)
+      railties (~> 3.2.0)
+    coffee-script (2.2.0)
+      coffee-script-source
+      execjs
+    coffee-script-source (1.6.1)
+    cookiejar (0.3.0)
+    crack (0.3.2)
+    daemons (1.1.9)
+    delayed_job (3.0.5)
+      activesupport (~> 3.0)
+    delayed_job_active_record (0.3.3)
+      activerecord (>= 2.1.0, < 4)
+      delayed_job (~> 3.0)
+    devise (2.2.3)
+      bcrypt-ruby (~> 3.0)
+      orm_adapter (~> 0.1)
+      railties (~> 3.1)
+      warden (~> 1.2.1)
+    diff-lcs (1.2.1)
+    em-http-request (1.0.3)
+      addressable (>= 2.2.3)
+      cookiejar
+      em-socksify
+      eventmachine (>= 1.0.0.beta.4)
+      http_parser.rb (>= 0.5.3)
+    em-socksify (0.2.1)
+      eventmachine (>= 1.0.0.beta.4)
+    erubis (2.7.0)
+    ethon (0.5.10)
+      ffi (~> 1.3.0)
+      mime-types (~> 1.18)
+    eventmachine (1.0.3)
+    excon (0.14.3)
+    execjs (1.4.0)
+      multi_json (~> 1.0)
+    faraday (0.8.6)
+      multipart-post (~> 1.1)
+    ffi (1.3.1)
+    fog (1.4.0)
+      builder
+      excon (~> 0.14.0)
+      formatador (~> 0.2.0)
+      mime-types
+      multi_json (~> 1.0)
+      net-scp (~> 1.0.4)
+      net-ssh (>= 2.1.3)
+      nokogiri (~> 1.5.0)
+      ruby-hmac
+    font-awesome-sass-rails (3.0.2.2)
+      railties (>= 3.1.1)
+      sass-rails (>= 3.1.1)
+    formatador (0.2.4)
+    geokit (1.6.5)
+      multi_json
+    geokit-rails3 (0.1.5)
+      geokit (~> 1.5)
+      rails (~> 3.0)
+    haml (4.0.0)
+      tilt
+    highline (1.6.15)
+    hike (1.2.1)
+    http_parser.rb (0.5.3)
+    httparty (0.10.2)
+      multi_json (~> 1.0)
+      multi_xml (>= 0.5.2)
+    i18n (0.6.4)
+    journey (1.0.4)
+    jquery-rails (2.2.1)
+      railties (>= 3.0, < 5.0)
+      thor (>= 0.14, < 2.0)
+    jquery-ui-rails (3.0.1)
+      jquery-rails
+      railties (>= 3.1.0)
+    json (1.7.7)
+    kaminari (0.14.1)
+      actionpack (>= 3.0.0)
+      activesupport (>= 3.0.0)
+    kgio (2.8.0)
+    kramdown (0.14.2)
+    mail (2.4.4)
+      i18n (>= 0.4.0)
+      mime-types (~> 1.16)
+      treetop (~> 1.4.8)
+    method_source (0.8.1)
+    mime-types (1.21)
+    multi_json (1.6.1)
+    multi_xml (0.5.3)
+    multipart-post (1.2.0)
+    mysql2 (0.3.11)
+    nested_form (0.3.1)
+    net-scp (1.0.4)
+      net-ssh (>= 1.99.1)
+    net-sftp (2.1.1)
+      net-ssh (>= 2.6.5)
+    net-ssh (2.6.6)
+    net-ssh-gateway (1.2.0)
+      net-ssh (>= 2.6.5)
+    nokogiri (1.5.6)
+    open4 (1.3.0)
+    orm_adapter (0.4.0)
+    polyglot (0.3.3)
+    pry (0.9.12)
+      coderay (~> 1.0.5)
+      method_source (~> 0.8)
+      slop (~> 3.4)
+    rack (1.4.5)
+    rack-cache (1.2)
+      rack (>= 0.4)
+    rack-pjax (0.7.0)
+      nokogiri (~> 1.5)
+      rack (~> 1.3)
+    rack-ssl (1.3.3)
+      rack
+    rack-test (0.6.2)
+      rack (>= 1.0)
+    rails (3.2.12)
+      actionmailer (= 3.2.12)
+      actionpack (= 3.2.12)
+      activerecord (= 3.2.12)
+      activeresource (= 3.2.12)
+      activesupport (= 3.2.12)
+      bundler (~> 1.0)
+      railties (= 3.2.12)
+    rails_admin (0.4.5)
+      bootstrap-sass (~> 2.2)
+      builder (~> 3.0)
+      coffee-rails (~> 3.1)
+      font-awesome-sass-rails (~> 3.0, >= 3.0.0.1)
+      haml (~> 4.0)
+      jquery-rails (~> 2.1)
+      jquery-ui-rails (~> 3.0)
+      kaminari (~> 0.14)
+      nested_form (~> 0.3)
+      rack-pjax (~> 0.6)
+      rails (~> 3.1)
+      remotipart (~> 1.0)
+      safe_yaml (~> 0.6)
+      sass-rails (~> 3.1)
+    railties (3.2.12)
+      actionpack (= 3.2.12)
+      activesupport (= 3.2.12)
+      rack-ssl (~> 1.3.2)
+      rake (>= 0.8.7)
+      rdoc (~> 3.4)
+      thor (>= 0.14.6, < 2.0)
+    raindrops (0.10.0)
+    rake (10.0.3)
+    rdoc (3.12.2)
+      json (~> 1.4)
+    remotipart (1.0.5)
+    rr (1.0.4)
+    rspec (2.13.0)
+      rspec-core (~> 2.13.0)
+      rspec-expectations (~> 2.13.0)
+      rspec-mocks (~> 2.13.0)
+    rspec-core (2.13.0)
+    rspec-expectations (2.13.0)
+      diff-lcs (>= 1.1.3, < 2.0)
+    rspec-mocks (2.13.0)
+    rspec-rails (2.13.0)
+      actionpack (>= 3.0)
+      activesupport (>= 3.0)
+      railties (>= 3.0)
+      rspec-core (~> 2.13.0)
+      rspec-expectations (~> 2.13.0)
+      rspec-mocks (~> 2.13.0)
+    ruby-hmac (0.4.0)
+    rufus-scheduler (2.0.18)
+      tzinfo (>= 0.3.23)
+    rvm-capistrano (1.2.7)
+      capistrano (>= 2.0.0)
+    safe_yaml (0.8.4)
+    sass (3.2.7)
+    sass-rails (3.2.6)
+      railties (~> 3.2.0)
+      sass (>= 3.1.10)
+      tilt (~> 1.3)
+    select2-rails (3.3.1)
+      sass-rails (>= 3.2)
+      thor (~> 0.14)
+    simple_oauth (0.1.9)
+    slop (3.4.3)
+    sprockets (2.2.2)
+      hike (~> 1.2)
+      multi_json (~> 1.0)
+      rack (~> 1.0)
+      tilt (~> 1.1, != 1.3.0)
+    thor (0.17.0)
+    tilt (1.3.5)
+    treetop (1.4.12)
+      polyglot
+      polyglot (>= 0.3.1)
+    twitter (4.4.0)
+      faraday (~> 0.8)
+      multi_json (~> 1.3)
+      simple_oauth (~> 0.1.6)
+    twitter-stream (0.1.16)
+      eventmachine (>= 0.12.8)
+      http_parser.rb (~> 0.5.1)
+      simple_oauth (~> 0.1.4)
+    typhoeus (0.6.2)
+      ethon (~> 0.5.10)
+    tzinfo (0.3.36)
+    uglifier (1.3.0)
+      execjs (>= 0.3.0)
+      multi_json (~> 1.0, >= 1.0.2)
+    unicorn (4.6.2)
+      kgio (~> 2.6)
+      rack
+      raindrops (~> 0.7)
+    warden (1.2.1)
+      rack (>= 1.0)
+    webmock (1.11.0)
+      addressable (>= 2.2.7)
+      crack (>= 0.3.2)
+    wunderground (1.0.0)
+      addressable
+      httparty (> 0.6.0)
+      json (> 1.4.0)
+
+PLATFORMS
+  ruby
+
+DEPENDENCIES
+  backup
+  bootstrap-kaminari-views
+  capistrano
+  capistrano-unicorn
+  coffee-rails (~> 3.2.1)
+  daemons
+  delayed_job_active_record (~> 0.3.3)
+  devise
+  em-http-request
+  fog (~> 1.4.0)
+  geokit-rails3
+  jquery-rails
+  json (>= 1.7.7)
+  kaminari
+  kramdown
+  mysql2
+  nokogiri
+  pry
+  rails
+  rails_admin
+  rr
+  rspec
+  rspec-rails
+  rufus-scheduler
+  rvm-capistrano
+  sass-rails (~> 3.2.3)
+  select2-rails
+  twitter
+  twitter-stream (>= 0.1.16)
+  typhoeus
+  uglifier (>= 1.0.3)
+  unicorn
+  webmock
+  wunderground

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+The MIT License
+
+Copyright (c) 2013, Andrew Cantino
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 65 - 0
README.md

@@ -0,0 +1,65 @@
+# Huginn.  Your agents are standing by.
+
+## What is Huginn?
+
+Huginn is a system for building agents that perform automated tasks for you online.  They can read the web, watch for events, and take actions on your behalf.  We're just getting started, but here are some of the things you can do with Huginn right now:
+
+
+
+Control your own data, run your own data hub.
+You know where the data is and who has it.  Don't be afraid to log stuff because of where it is.
+
+Make agents that serve you.
+
+
+
+## Getting Started
+
+* Make a private fork of this repository on GitHub.
+* In your fork, edit `config/secret_token.rb` and replace `REPLACE_ME_NOW!` with the output of `rake secret`.
+* Edit `app/models/user.rb` and change the invitation code(s) in `INVITATION_CODES`.  This controls who can signup to use your installation.
+* Run `rake db:create`, `rake db:migrate`, and then `rake db:seed` to create a development MySQL database with some example seed data.  Run `rails s`, visit `localhost:3000`, and login with the username of `admin` and the password of `password`.
+* Make some extra Terminal windows and run `bundle exec rails runner bin/schedule.rb` and `bundle exec rails runner bin/twitter_stream.rb`
+
+## Deployment
+
+Deployment right now is configured with Capistrano, Unicorn, and nginx.  You should feel free to deploy in a different way, however.
+
+### Required Setup
+
+* Edit `app/mailers/system_mailer.rb` and set your default from address.
+* Edit `config/unicorn/production.rb` and replace instances of *you* with the correct username for your server.
+* Edit `config/environments/production.rb` and change the value of `DOMAIN` and the `config.action_mailer.smtp_settings` setup, which is currently setup for sending email through a Google Apps account on Gmail.
+* Setup a place for Huginn to run.  I recommend making a dedicated user on your server for Huginn, but this is not required.  Setup nginx or Apache to proxy pass to unicorn.  There is an example nginx script in `config/nginx/production.conf`.
+* Setup a production MySQL database for your installation.
+* Edit `config/deploy.rb` and change all instances of `you` and `yourdomain` to the appropriate values for your server setup, then run `cap deploy:setup` followed by `cap deploy`.  If everything goes well, this should start some unicorn workers on your server to run the Huginn web app.
+* After deploying with capistrano, SSH into your server, go to the deployment directory, and run `RAILS_ENV=production bundle exec rake db:seed` to generate your admin user.  Immediately login to your new Huginn installation with the username of `admin` and the password of `password` and change your email and password!
+* You'll need to run bin/schedule.rb and bin/twitter_stream.rb in a daemonized way.  I've just been using screen sessions, but please contribute something better!
+
+    RAILS_ENV=production bundle exec rails runner bin/schedule.rb
+
+    RAILS_ENV=production bundle exec rails runner bin/twitter_stream.rb
+
+### Optional Setup
+
+#### Enable the WeatherAgent
+
+In order to use the WeatherAgent you need an [API key with Wunderground](http://www.wunderground.com/weather/api/).  Signup for one and then put it in `app/models/agents/weather_agent.rb` in the `wunderground` method.
+
+#### Enable DelayedJobWeb for handy delayed_job monitoring and control
+
+* Edit `config.ru`, uncomment the DelayedJobWeb section, and change the DelayedJobWeb username and password.
+* Uncomment `match "/delayed_job" => DelayedJobWeb, :anchor => false` in `config/routes.rb`.
+* Uncomment `gem "delayed_job_web"` in Gemfile and run `bundle`.
+
+#### Disable SSL
+
+We assume your deployment will run over SSL. This is a very good idea! However, if you wish to turn this off, you'll probably need to edit `config/initializers/devise.rb` and modify the line containing `config.rememberable_options = { :secure => true }`.  You will also need to edit `config/environments/production.rb` and modify the value of `config.force_ssl`.
+
+## License
+
+Huginn is provided under the MIT License.
+
+## Contribution
+
+Please fork, add specs, and send pull requests!

+ 9 - 0
Rakefile

@@ -0,0 +1,9 @@
+#!/usr/bin/env rake
+# Add your own tasks in files placed in lib/tasks ending in .rake,
+# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
+
+ENV['SKIP_RAILS_ADMIN_INITIALIZER'] = 'true'
+
+require File.expand_path('../config/application', __FILE__)
+
+Huginn::Application.load_tasks

BIN
app/assets/images/odin.jpg


BIN
app/assets/images/spinner-arrows.gif


+ 94 - 0
app/assets/javascripts/application.js.coffee.erb

@@ -0,0 +1,94 @@
+#= require jquery
+#= require jquery_ujs
+#= require bootstrap
+#= require select2
+#= require json2
+#= require jquery.json-editor
+#= require latlon_and_geo
+#= require ./worker-checker
+#= require_self
+
+# Place all the behaviors and hooks related to the matching controller here.
+# All this logic will automatically be available in application.js.
+# You can use CoffeeScript in this file: http://jashkenas.github.com/coffee-script/
+
+setupJsonEditor = ->
+  JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>'
+  JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>'
+  if $(".live-json-editor").length
+    window.jsonEditor = new JSONEditor($(".live-json-editor"), 400, 500)
+    window.jsonEditor.doTruncation true
+    window.jsonEditor.showFunctionButtons()
+
+hideSchedule = ->
+  $(".schedule-region select").hide()
+  $(".schedule-region .cannot-be-scheduled").show()
+
+showSchedule = ->
+  $(".schedule-region select").show()
+  $(".schedule-region .cannot-be-scheduled").hide()
+
+hideLinks = ->
+  $(".link-region .select2-container").hide()
+  $(".link-region .cannot-receive-events").show()
+
+showLinks = ->
+  $(".link-region .select2-container").show()
+  $(".link-region .cannot-receive-events").hide()
+  showEventDescriptions()
+
+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()
+
+$(document).ready ->
+  setupJsonEditor()
+  $(".multi-select").select2(width: 'resolve')
+
+  if $(".top-flash").length
+    setTimeout((-> $(".top-flash").slideUp(-> $(".top-flash").remove())), 5000)
+
+  $("#agent_source_ids").on "change", showEventDescriptions
+
+  $("#agent_type").on "change", ->
+    if window.jsonEditor?
+      $(@).closest(".control-group").find(".spinner").fadeIn();
+      $("#agent_source_ids ").select2("val", {});
+      $(".event-descriptions").html("").hide()
+      $.getJSON "/agents/type_details", { type: $(@).val() }, (json) =>
+        if json.can_be_scheduled
+          showSchedule()
+        else
+          hideSchedule()
+
+        if json.can_receive_events
+          showLinks()
+        else
+          hideLinks()
+
+        $(".description").html(json.description_html) if json.description_html?
+
+        window.jsonEditor.json = json.options
+        window.jsonEditor.rebuild()
+
+        $(@).closest(".control-group").find(".spinner").stop(true, true).fadeOut();
+
+  $("#agent_type").change() if $("#agent_type").length
+
+  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()

+ 60 - 0
app/assets/javascripts/graphing.js.coffee

@@ -0,0 +1,60 @@
+#= require d3
+#= require rickshaw
+#= require_self
+
+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()

+ 16 - 0
app/assets/javascripts/worker-checker.js.coffee

@@ -0,0 +1,16 @@
+$ ->
+  if $("#job-indicator").length
+    check = ->
+      $.getJSON "/worker_status", (json) ->
+        if json.pending? && json.pending > 0
+          tooltipOptions = {
+            title: "#{json.pending} pending, #{json.awaiting_retry} awaiting retry, and #{json.recent_failures} recent failures"
+            delay: 0
+            placement: "bottom"
+            trigger: "hover"
+          }
+          $("#job-indicator").tooltip('destroy').tooltip(tooltipOptions).fadeIn().find(".number").text(json.pending)
+        else
+          $("#job-indicator:visible").tooltip('destroy').fadeOut()
+        window.workerCheckTimeout = setTimeout check, 2000
+    check()

+ 67 - 0
app/assets/stylesheets/agent_views/peak_detector_agent/show.css.scss

@@ -0,0 +1,67 @@
+.show-view.peak-detector-agent,
+.show-view.twitter-stream-agent {
+  .rickshaw_annotation_timeline {
+    left: 40px;
+    width: 700px;
+  }
+
+  .filter-group {
+    margin-bottom: 20px;
+
+    &.tweets {
+      border-left: 5px solid #eee;
+      padding-left: 10px;
+    }
+
+    .filter {
+      font-weight: bold;
+      margin: 10px 0;
+    }
+
+    .tweet {
+      margin-bottom: 10px;
+    }
+
+    .chart-container {
+      position: relative;
+      font-family: Arial, Helvetica, sans-serif;
+
+      .chart {
+        position: relative;
+        left: 40px;
+        overflow: hidden;
+        width: 700px;
+      }
+
+      .summary-statistic {
+        stroke-width: 2;
+        stroke: #000;
+        stroke-dasharray: 9, 5;
+
+        &.mean {
+          stroke-opacity: 0.4;
+          stroke-dasharray: none;
+        }
+
+        &.one-std {
+          stroke-opacity: 0.3;
+        }
+
+        &.two-std {
+          stroke-opacity: 0.2;
+        }
+
+        &.three-std {
+          stroke-opacity: 0.1;
+        }
+      }
+
+      .y-axis {
+        position: absolute;
+        top: 0;
+        bottom: 0;
+        width: 40px;
+      }
+    }
+  }
+}

+ 92 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -0,0 +1,92 @@
+/*
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
+ * listed below.
+ *
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
+ * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
+ *
+ * You're free to add application-wide styles to this file and they'll appear at the top of the
+ * compiled file, but it's generally better to create a new file per style scope.
+ *
+ *= require select2
+ *= require jquery.json-editor
+ *= require rickshaw
+ *= require_tree .
+ *= require_self
+ */
+
+$glyphicons-halflings-url: "<%= image_path "glyphicons-halflings.png" %>";
+$glyphicons-halflings-white-url: "<%= image_path "glyphicons-halflings-white.png" %>";
+
+@import "bootstrap";
+
+body { padding-top: 60px; }
+
+#main {
+  margin-bottom: 100px;
+}
+
+/* Rails scaffold style compatibility */
+#error_explanation {
+  @extend .alert;
+  @extend .alert-error;
+  @extend .alert-block;
+}
+
+.field_with_errors {
+  @extend .control-group.error;
+}
+
+table.events {
+  .payload {
+    color: #999;
+    font-size: 12px;
+    //text-align: center;
+    font-family: monospace;
+  }
+}
+
+.multi-select {
+  float: none !important;
+  margin-left: 0 !important;
+}
+
+#job-indicator {
+  display: none;
+}
+
+img.odin {
+  position: relative;
+  top: -32px;
+}
+
+.link-region .cannot-receive-events,
+.schedule-region .cannot-be-scheduled {
+  display: none;
+}
+
+.type-select {
+  width: 275px;
+
+  img.spinner {
+    display: none;
+    float: right;
+  }
+}
+
+.hidden {
+  display: none;
+}
+
+#show-tabs {
+  margin-top: 10px;
+  height: 400px;
+}
+
+.digraph {
+  margin-top: 20px;
+}
+
+.show-view {
+  overflow: hidden;
+}

+ 111 - 0
app/controllers/agents_controller.rb

@@ -0,0 +1,111 @@
+class AgentsController < ApplicationController
+  def index
+    @agents = current_user.agents.page(params[:page])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @agents }
+    end
+  end
+
+  def run
+    @agent = current_user.agents.find(params[:id])
+    @agent.async_check
+    redirect_to agents_path, notice: "Agent run queued"
+  end
+
+  def type_details
+    agent = Agent.build_for_type(params[:type], current_user, {})
+    render :json => {
+        :can_be_scheduled => agent.can_be_scheduled?,
+        :can_receive_events => agent.can_receive_events?,
+        :options => agent.default_options,
+        :description_html => agent.html_description
+    }
+  end
+
+  def event_descriptions
+    html = current_user.agents.find(params[:ids].split(",")).group_by(&:type).map { |type, agents|
+      agents.map(&:html_event_description).uniq.map { |desc|
+        "<p><strong>#{type}</strong><br />" + desc + "</p>"
+      }
+    }.flatten.join()
+    render :json => { :description_html => html }
+  end
+
+  def remove_events
+    @agent = current_user.agents.find(params[:id])
+    @agent.events.delete_all
+    redirect_to agents_path, notice: "All events removed"
+  end
+
+  def propagate
+    details = Agent.receive!
+    redirect_to agents_path, notice: "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)"
+  end
+
+  def show
+    @agent = current_user.agents.find(params[:id])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @agent }
+    end
+  end
+
+  def new
+    @agent = current_user.agents.build
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @agent }
+    end
+  end
+
+  def edit
+    @agent = current_user.agents.find(params[:id])
+  end
+
+  def diagram
+    @agents = current_user.agents.includes(:receivers)
+  end
+
+  def create
+    @agent = Agent.build_for_type(params[:agent].delete(:type),
+                                  current_user,
+                                  params[:agent])
+    respond_to do |format|
+      if @agent.save
+        format.html { redirect_to agents_path, notice: 'Your Agent was successfully created.' }
+        format.json { render json: @agent, status: :created, location: @agent }
+      else
+        format.html { render action: "new" }
+        format.json { render json: @agent.errors, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def update
+    @agent = current_user.agents.find(params[:id])
+
+    respond_to do |format|
+      if @agent.update_attributes(params[:agent])
+        format.html { redirect_to agents_path, notice: 'Your Agent was successfully updated.' }
+        format.json { head :no_content }
+      else
+        format.html { render action: "edit" }
+        format.json { render json: @agent.errors, status: :unprocessable_entity }
+      end
+    end
+  end
+
+  def destroy
+    @agent = current_user.agents.find(params[:id])
+    @agent.destroy
+
+    respond_to do |format|
+      format.html { redirect_to agents_path }
+      format.json { head :no_content }
+    end
+  end
+end

+ 7 - 0
app/controllers/application_controller.rb

@@ -0,0 +1,7 @@
+class ApplicationController < ActionController::Base
+  protect_from_forgery
+
+  before_filter :authenticate_user!
+
+  helper :all
+end

+ 34 - 0
app/controllers/events_controller.rb

@@ -0,0 +1,34 @@
+class EventsController < ApplicationController
+  def index
+    if params[:agent]
+      @agent = current_user.agents.find(params[:agent])
+      @events = @agent.events.page(params[:page])
+    else
+      @events = current_user.events.page(params[:page])
+    end
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @event }
+    end
+  end
+
+  def show
+    @event = current_user.events.find(params[:id])
+
+    respond_to do |format|
+      format.html
+      format.json { render json: @event }
+    end
+  end
+
+  def destroy
+    event = current_user.events.find(params[:id])
+    event.destroy
+
+    respond_to do |format|
+      format.html { redirect_to events_path }
+      format.json { head :no_content }
+    end
+  end
+end

+ 9 - 0
app/controllers/home_controller.rb

@@ -0,0 +1,9 @@
+class HomeController < ApplicationController
+  skip_before_filter :authenticate_user!
+
+  def index
+  end
+
+  def about
+  end
+end

+ 18 - 0
app/controllers/user_location_updates_controller.rb

@@ -0,0 +1,18 @@
+class UserLocationUpdatesController < ApplicationController
+  skip_before_filter :authenticate_user!
+
+  def create
+    user = User.find_by_id(params[:user_id])
+    if user
+      secret = params[:secret]
+      user.agents.of_type(Agents::UserLocationAgent).find_all {|agent| agent.options[:secret] == secret }.each do |agent|
+        agent.create_event :payload => params.except(:controller, :action, :secret, :user_id, :format),
+                           :lat => params[:latitude],
+                           :lng => params[:longitude]
+      end
+      render :text => "ok"
+    else
+      render :text => "user not found", :status => :not_found
+    end
+  end
+end

+ 13 - 0
app/controllers/worker_status_controller.rb

@@ -0,0 +1,13 @@
+class WorkerStatusController < ApplicationController
+  skip_before_filter :authenticate_user!
+
+  def show
+    start = Time.now.to_f
+    render :json => {
+        :pending => Delayed::Job.where("run_at <= ? AND locked_at IS NULL AND attempts = 0", Time.now).count,
+        :awaiting_retry => Delayed::Job.where("failed_at IS NULL AND attempts > 0").count,
+        :recent_failures => Delayed::Job.where("failed_at IS NOT NULL AND failed_at > ?", 5.days.ago).count,
+        :compute_time => Time.now.to_f - start
+    }
+  end
+end

+ 12 - 0
app/helpers/agent_helper.rb

@@ -0,0 +1,12 @@
+module AgentHelper
+  def agent_show_view(agent)
+    name = agent.short_type.underscore
+    if File.exists?(Rails.root.join("app", "views", "agents", "agent_views", name, "_show.html.erb"))
+      File.join("agents", "agent_views", name, "show")
+    end
+  end
+
+  def agent_show_class(agent)
+    agent.short_type.underscore.dasherize
+  end
+end

+ 17 - 0
app/helpers/application_helper.rb

@@ -0,0 +1,17 @@
+module ApplicationHelper
+  def nav_link(name, path, options = {})
+    (<<-HTML).html_safe
+      <li class='#{(current_page?(path) ? "active" : "")}'>
+        #{link_to name, path}
+      </li>
+    HTML
+  end
+
+  def working(agent)
+    if agent.working?
+      '<span class="label label-success">Yes</span>'.html_safe
+    else
+      '<span class="label label-warning">No</span>'.html_safe
+    end
+  end
+end

+ 9 - 0
app/mailers/system_mailer.rb

@@ -0,0 +1,9 @@
+class SystemMailer < ActionMailer::Base
+  default from: "huginn@your-google-apps-domain.com"
+
+  def send_message(options)
+    @lines = options[:lines]
+    @headline = options[:headline]
+    mail :to => options[:to], :subject => options[:subject]
+  end
+end

+ 221 - 0
app/models/agent.rb

@@ -0,0 +1,221 @@
+require 'serialize_and_symbolize'
+require 'assignable_types'
+require 'markdown_class_attributes'
+require 'utils'
+
+class Agent < ActiveRecord::Base
+  include SerializeAndSymbolize
+  include AssignableTypes
+  include MarkdownClassAttributes
+
+  serialize_and_symbolize :options, :memory
+  markdown_class_attributes :description, :event_description
+
+  load_types_in "Agents"
+
+  SCHEDULES = %w[every_2m every_5m every_10m every_30m every_1h every_2h every_5h every_12h every_1d every_2d every_7d
+                 midnight 1am 2am 3am 4am 5am 6am 7am 8am 9am 10am 11am noon 1pm 2pm 3pm 4pm 5pm 6pm 7pm 8pm 9pm 10pm 11pm]
+
+  attr_accessible :options, :memory, :name, :type, :schedule, :source_ids
+
+  validates_presence_of :name, :user
+  validate :sources_are_owned
+  validate :validate_schedule
+
+  after_initialize :set_default_schedule
+  before_validation :set_default_schedule
+  before_validation :unschedule_if_cannot_schedule
+  before_save :unschedule_if_cannot_schedule
+
+  belongs_to :user, :inverse_of => :agents
+  has_many :events, :dependent => :delete_all, :inverse_of => :agent, :order => "events.id desc"
+  has_many :received_events, :through => :sources, :class_name => "Event", :source => :events, :order => "events.id desc"
+  has_many :links_as_source, :dependent => :delete_all, :foreign_key => "source_id", :class_name => "Link", :inverse_of => :source
+  has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver
+  has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers
+  has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources
+
+  scope :of_type, lambda { |type|
+    type = case type
+             when String, Symbol, Class
+               type.to_s
+             when Agent
+               type.class.to_s
+             else
+               type.to_s
+           end
+    where(:type => type)
+  }
+
+  def check
+    # Implement me in your subclass of Agent.
+  end
+
+  def default_options
+    # Implement me in your subclass of Agent.
+    {}
+  end
+
+  def receive(events)
+    # Implement me in your subclass of Agent.
+  end
+
+  # Implement me in your subclass to decide if your Agent is working.
+  def working?
+    raise "Implement me in your subclass"
+  end
+
+  def event_created_within(seconds)
+    last_event = events.first
+    last_event && last_event.created_at > seconds.ago && last_event
+  end
+
+  def sources_are_owned
+    errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user }
+  end
+
+  def create_event(attrs)
+    events.create!({ :user => user }.merge(attrs))
+  end
+
+  def validate_schedule
+    unless cannot_be_scheduled?
+      errors.add(:schedule, "is not a valid schedule") unless SCHEDULES.include?(schedule.to_s)
+    end
+  end
+
+  def make_message(payload, message = options[:message])
+    message.gsub(/<([^>]+)>/) { value_at(payload, $1) || "??" }
+  end
+
+  def value_at(data, path)
+    if data.is_a?(Hash)
+      path.split(".").inject(data) { |memo, segment|
+        if memo
+          if memo[segment]
+            memo[segment]
+          elsif memo[segment.to_sym]
+            memo[segment.to_sym]
+          else
+            nil
+          end
+        else
+          nil
+        end
+      }.to_s
+    else
+      data
+    end
+  end
+
+  def set_default_schedule
+    self.schedule = default_schedule unless schedule.present? || cannot_be_scheduled?
+  end
+
+  def unschedule_if_cannot_schedule
+    self.schedule = nil if cannot_be_scheduled?
+  end
+
+  def last_event_at
+    @memoized_last_event_at ||= events.select(:created_at).first.try(:created_at)
+  end
+
+  def async_check
+    check
+    self.last_check_at = Time.now
+    save!
+  end
+  handle_asynchronously :async_check #, :priority => 10, :run_at => Proc.new { 5.minutes.from_now }
+
+  def async_receive(event_ids)
+    receive(Event.where(:id => event_ids))
+    self.last_receive_at = Time.now
+    save!
+  end
+  handle_asynchronously :async_receive #, :priority => 10, :run_at => Proc.new { 5.minutes.from_now }
+
+  def default_schedule
+    self.class.default_schedule
+  end
+
+  def cannot_be_scheduled?
+    self.class.cannot_be_scheduled?
+  end
+
+  def can_be_scheduled?
+    !cannot_be_scheduled?
+  end
+
+  def cannot_receive_events?
+    self.class.cannot_receive_events?
+  end
+
+  def can_receive_events?
+    !cannot_receive_events?
+  end
+
+  # Class Methods
+
+  def self.cannot_be_scheduled!
+    @cannot_be_scheduled = true
+  end
+
+  def self.cannot_be_scheduled?
+    !!@cannot_be_scheduled
+  end
+
+  def self.default_schedule(schedule = nil)
+    @default_schedule = schedule unless schedule.nil?
+    @default_schedule
+  end
+
+  def self.cannot_receive_events!
+    @cannot_receive_events = true
+  end
+
+  def self.cannot_receive_events?
+    !!@cannot_receive_events
+  end
+
+  def self.receive!
+    sql = Agent.
+            select("agents.id AS receiver_agent_id, sources.id AS source_agent_id, events.id AS event_id").
+            joins("JOIN links ON (links.receiver_id = agents.id)").
+            joins("JOIN agents AS sources ON (links.source_id = sources.id)").
+            joins("JOIN events ON (events.agent_id = sources.id)").
+            where("agents.last_checked_event_id IS NULL OR events.id > agents.last_checked_event_id").to_sql
+
+    agents_to_events = {}
+    Agent.connection.select_rows(sql).each do |receiver_agent_id, source_agent_id, event_id|
+      agents_to_events[receiver_agent_id] ||= []
+      agents_to_events[receiver_agent_id] << event_id
+    end
+
+    event_ids = agents_to_events.values.flatten.uniq.compact
+
+    Agent.where(:id => agents_to_events.keys).each do |agent|
+      agent.update_attribute :last_checked_event_id, event_ids.max
+      agent.async_receive(agents_to_events[agent.id].uniq)
+    end
+
+    {
+        :agent_count => agents_to_events.keys.length,
+        :event_count => event_ids.length
+    }
+  end
+
+  def self.run_schedule(schedule)
+    types = where(:schedule => schedule).group(:type).pluck(:type)
+    types.each do |type|
+      type.constantize.bulk_check(schedule)
+    end
+  end
+
+  # You can override this to define a custom bulk_check for your type of Agent.
+  def self.bulk_check(schedule)
+    raise "Call #bulk_check on the appropriate subclass of Agent" if self == Agent
+    where(:schedule => schedule).find_each do |agent|
+      agent.async_check
+    end
+  end
+end

+ 66 - 0
app/models/agents/digest_email_agent.rb

@@ -0,0 +1,66 @@
+module Agents
+  class DigestEmailAgent < Agent
+    MAIN_KEYS = %w[title message text main value].map(&:to_sym)
+    default_schedule "5am"
+
+    description <<-MD
+      The DigestEmailAgent collects any Events sent to it and sends them all via email when run.
+      The email will be sent to your account's address and will have a `subject` and an optional `headline` before
+      listing the Events.  If the Events' payloads contain a `:message`, that will be highlighted, otherwise everything in
+      their payloads will be shown.
+
+      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
+
+    def default_options
+      {
+          :subject => "You have some notifications!",
+          :headline => "Your notifications:",
+          :expected_receive_period_in_days => "2"
+      }
+    end
+
+    def working?
+      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago
+    end
+
+    def validate_options
+      errors.add(:base, "subject and expected_receive_period_in_days are required") unless options[:subject].present? && options[:expected_receive_period_in_days].present?
+    end
+
+    def receive(incoming_events)
+      incoming_events.each do |event|
+        self.memory[:queue] ||= []
+        self.memory[:queue] << event.payload
+      end
+    end
+
+    def check
+      if self.memory[:queue] && self.memory[:queue].length > 0
+        lines = self.memory[:queue].map {|item| present(item) }
+        puts "Sending mail to #{user.email}..." unless Rails.env.test?
+        SystemMailer.delay.send_message(:to => user.email, :subject => options[:subject], :headline => options[:headline], :lines => lines)
+        self.memory[:queue] = []
+      end
+    end
+
+    def present(item)
+      if item.is_a?(Hash)
+        MAIN_KEYS.each do |key|
+          if item.has_key?(key)
+            return "#{item[key]}" + ((item.length > 1 && item.length < 5) ? " (#{present_hash item, key})" : "")
+          elsif item.has_key?(key.to_s)
+            return "#{item[key.to_s]}" + ((item.length > 1 && item.length < 5) ? " (#{present_hash item, key.to_s})" : "")
+          end
+        end
+        present_hash item
+      else
+        item.to_s
+      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 [skip_key].include?(k) }.compact.to_sentence
+    end
+  end
+end

+ 124 - 0
app/models/agents/peak_detector_agent.rb

@@ -0,0 +1,124 @@
+require 'pp'
+
+module Agents
+  class PeakDetectorAgent < Agent
+    cannot_be_scheduled!
+
+    description <<-MD
+      Use a PeakDetectorAgent to 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>`
+
+      The `value_path` value is a hash path to the value of interest.  `group_by_path` is a hash path that will be used to group values, if present.
+
+      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.
+
+      You may set `window_duration` to change the default memory window length of two weeks,
+      `peak_spacing` to change the default minimum peak spacing of two days, and
+      `std_multiple` to change the default standard deviation threshold multiple of 3.
+    MD
+
+    event_description <<-MD
+      Events look like this:
+
+          { :message => "Your message", :peak => 6, :peak_time => 3456789242, :grouped_by => "something" }
+    MD
+
+    def validate_options
+      unless options[:expected_receive_period_in_days].present? && options[:message].present? && options[:value_path].present?
+        errors.add(:base, "expected_receive_period_in_days, value_path, and message are required")
+      end
+    end
+
+    def default_options
+      {
+          :expected_receive_period_in_days => "2",
+          :group_by_path => "filter",
+          :value_path => "count",
+          :message => "A peak was found"
+      }
+    end
+
+    def working?
+      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago
+    end
+
+    def receive(incoming_events)
+      incoming_events.sort_by(&:created_at).each do |event|
+        group = group_for(event)
+        remember group, event
+        check_for_peak group, event
+      end
+    end
+
+    private
+
+    def check_for_peak(group, event)
+      memory[:peaks] ||= {}
+      memory[:peaks][group] ||= []
+
+      if memory[:data][group].length > 4 && (memory[:peaks][group].empty? || memory[:peaks][group].last < event.created_at.to_i - peak_spacing)
+        average_value, standard_deviation = stats_for(group, :skip_last => 2)
+        newest_value = memory[:data][group][-1].first.to_f
+        second_newest_value, second_newest_time = memory[:data][group][-2].map(&:to_f)
+
+        #pp({:newest_value => newest_value,
+        #    :second_newest_value => second_newest_value,
+        #    :average_value => average_value,
+        #    :standard_deviation => standard_deviation,
+        #    :threshold => average_value + std_multiple * standard_deviation })
+
+        if newest_value < second_newest_value && second_newest_value > average_value + std_multiple * standard_deviation
+          memory[:peaks][group] << second_newest_time
+          memory[:peaks][group].reject! { |p| p <= second_newest_time - window_duration }
+          create_event :payload => { :message => options[:message], :peak => second_newest_value, :peak_time => second_newest_time, :grouped_by => group.to_s }
+        end
+      end
+    end
+
+    def stats_for(group, options = {})
+      data = memory[:data][group].map {|d| d.first.to_f }
+      data = data[0...(memory[:data][group].length - (options[:skip_last] || 0))]
+      length = data.length.to_f
+      mean = 0
+      mean_variance = 0
+      data.each do |value|
+        mean += value
+      end
+      mean /= length
+      data.each do |value|
+        variance = (value - mean)**2
+        mean_variance += variance
+      end
+      mean_variance /= length
+      standard_deviation = Math.sqrt(mean_variance)
+      [mean, standard_deviation]
+    end
+
+    def window_duration
+      (options[:window_duration].present? && options[:window_duration].to_i) || 2.weeks
+    end
+
+    def std_multiple
+      (options[:std_multiple].present? && options[:std_multiple].to_i) || 3
+    end
+
+    def peak_spacing
+      (options[:peak_spacing].present? && options[:peak_spacing].to_i) || 2.days
+    end
+
+    def group_for(event)
+      ((options[:group_by_path].present? && value_at(event.payload, options[:group_by_path])) || 'no_group').to_sym
+    end
+
+    def remember(group, event)
+      memory[:data] ||= {}
+      memory[:data][group] ||= []
+      memory[:data][group] << [value_at(event.payload, options[:value_path]), 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 }
+    end
+  end
+end

+ 84 - 0
app/models/agents/trigger_agent.rb

@@ -0,0 +1,84 @@
+module Agents
+  class TriggerAgent < Agent
+    cannot_be_scheduled!
+
+    VALID_COMPARISON_TYPES = %w[regex field<value field<=value field==value field>=value field>value]
+
+    description <<-MD
+      Use a TriggerAgent to watch for a specific value in an Event payload.
+
+      The `rules` array contains hashes of `path`, `value`, and `type`.  The `path` value is a dotted path through a hash, for example `foo.bar` would return `hello` from this structure:
+
+          {
+            :foo => {
+              :bar => "hello"
+            },
+            :something => "else"
+          }
+
+      The `type` can be one of #{VALID_COMPARISON_TYPES.map { |t| "`#{t}`" }.to_sentence} and compares with the `value`.
+
+      All rules must match for the Agent to match.  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>`
+
+      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
+      Events look like this:
+
+          { :message => "Your message" }
+    MD
+
+    def validate_options
+      unless options[:expected_receive_period_in_days].present? && options[:message].present? && options[:rules].present? &&
+          options[:rules].all? { |rule| rule[:type].present? && VALID_COMPARISON_TYPES.include?(rule[:type]) && rule[:value].present? && rule[:path].present? }
+        errors.add(:base, "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required")
+      end
+    end
+
+    def default_options
+      {
+          :expected_receive_period_in_days => "2",
+          :rules => [{
+                         :type => "regex",
+                         :value => "foo\\d+bar",
+                         :path => "topkey.subkey.subkey.goal",
+                     }],
+          :message => "Looks like your pattern matched in '<value>'!"
+      }
+    end
+
+    def working?
+      last_receive_at && last_receive_at > options[:expected_receive_period_in_days].to_i.days.ago
+    end
+
+    def receive(incoming_events)
+      incoming_events.each do |event|
+        match = options[:rules].all? do |rule|
+          value_at_path = value_at(event[:payload], rule[:path])
+          case rule[:type]
+            when "regex"
+              value_at_path.to_s =~ Regexp.new(rule[:value], Regexp::IGNORECASE)
+            when "field>value"
+              value_at_path.to_f > rule[:value].to_f
+            when "field>=value"
+              value_at_path.to_f >= rule[:value].to_f
+            when "field<value"
+              value_at_path.to_f < rule[:value].to_f
+            when "field<=value"
+              value_at_path.to_f <= rule[:value].to_f
+            when "field==value"
+              value_at_path.to_s == rule[:value].to_s
+            else
+              raise "Invalid :type of #{rule[:type]} in TriggerAgent##{id}"
+          end
+        end
+
+        if match
+          create_event :payload => { :message => make_message(event[:payload]) } # Maybe this should include the
+                                                                                 # original event as well?
+        end
+      end
+    end
+  end
+end

+ 91 - 0
app/models/agents/twitter_stream_agent.rb

@@ -0,0 +1,91 @@
+module Agents
+  class TwitterStreamAgent < Agent
+    cannot_receive_events!
+
+    description <<-MD
+      The TwitterStreamAgent follows the Twitter stream in real time, watching for certain keywords, or filters, that you provide.
+
+      You must provide a `twitter_username` and `twitter_password`, as well as an array of `filters`.  Multiple words in a filter
+      must all show up in a tweet, but are independent of order.
+
+      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.
+
+      `generate` should be either `events` or `counts`.  If set to `counts`, it will output event summaries whenever the Agent is scheduled.
+    MD
+
+    event_description <<-MD
+      When in `counts` mode, TwitterStreamAgent events look like:
+
+          {
+            :filter => "hello world",
+            :count => 25,
+            :time => 3456785456
+          }
+
+      When in `events` mode, TwitterStreamAgent events look like:
+
+          { :filter=>"selectorgadget",
+             ... every Tweet field, including ...
+           :text=> "something",
+           :user=>
+            { :name=>"Mr. Someone",
+              :screen_name=>"Someone",
+              :location=>"Vancouver BC Canada",
+              :description=> "...",
+              :followers_count=>486,
+              :friends_count=>1983,
+              :created_at=>"Mon Aug 29 23:38:14 +0000 2011",
+              :time_zone=>"Pacific Time (US & Canada)",
+              :statuses_count=>3807,
+              :lang=>"en" },
+           :retweet_count=>0,
+           :entities=> ...
+           :lang=>"en" }
+    MD
+
+    default_schedule "11pm"
+
+    def validate_options
+      unless options[:twitter_username].present? && options[:twitter_password].present? && options[:filters].present? && options[:expected_update_period_in_days].present? && options[:generate].present?
+        errors.add(:base, "expected_update_period_in_days, generate, twitter_username, twitter_password, and filters are required")
+      end
+    end
+
+    def working?
+      (event = event_created_within(options[:expected_update_period_in_days].to_i.days)) && event.payload.present?
+    end
+
+    def default_options
+      {
+          :twitter_username => "---",
+          :twitter_password => "---",
+          :filters => %w[keyword1 keyword2],
+          :expected_update_period_in_days => "2",
+          :generate => "events"
+      }
+    end
+
+    def process_tweet(filter, status)
+      if options[:generate] == "counts"
+        # Avoid memory pollution
+        me = Agent.find(id)
+        me.memory[:filter_counts] ||= {}
+        me.memory[:filter_counts][filter.to_sym] ||= 0
+        me.memory[:filter_counts][filter.to_sym] += 1
+        me.save!
+      else
+        create_event :payload => status.merge(:filter => filter.to_s)
+      end
+    end
+
+    def check
+      if memory[:filter_counts] && memory[:filter_counts].length > 0
+        memory[:filter_counts].each do |filter, count|
+          create_event :payload => { :filter => filter.to_s, :count => count, :time => Time.now.to_i }
+        end
+        memory[:filter_counts] = {}
+        save!
+      end
+    end
+  end
+end

+ 44 - 0
app/models/agents/user_location_agent.rb

@@ -0,0 +1,44 @@
+require 'securerandom'
+
+module Agents
+  class UserLocationAgent < Agent
+    cannot_receive_events!
+    cannot_be_scheduled!
+
+    description do
+      <<-MD
+        The UserLocationAgent creates events based on WebHook POSTS that contain a `latitude` and `longitude`.  You can use the POSTLocation iOS app to post your location.
+
+        Your POST path will be `https://#{DOMAIN}/users/#{user.id}/update_location/:secret` where `:secret` is specified in your options.
+      MD
+    end
+
+    event_description <<-MD
+      Assuming you're using the iOS application, events look like this:
+
+          {
+            :latitude => "37.12345",
+            :longitude => "-122.12345",
+            :timestamp => "123456789.0",
+            :altitude => "22.0",
+            :horizontal_accuracy => "5.0",
+            :vertical_accuracy => "3.0",
+            :speed => "0.52595",
+            :course => "72.0703",
+            :device_token => "..."
+          }
+    MD
+
+    def working?
+      (event = event_created_within(2.days)) && event.payload.present?
+    end
+
+    def default_options
+      { :secret => SecureRandom.hex(7) }
+    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
+    end
+  end
+end

+ 68 - 0
app/models/agents/weather_agent.rb

@@ -0,0 +1,68 @@
+require 'date'
+
+module Agents
+  class WeatherAgent < Agent
+    cannot_receive_events!
+
+    description <<-MD
+      The WeatherAgent created an event for the following day's weather at `zipcode`.
+    MD
+
+    event_description <<-MD
+      Events look like this:
+
+          {
+            :zipcode => 12345,
+            :date => { :epoch=>"1357959600", :pretty=>"10:00 PM EST on January 11, 2013" },
+            :high => { :fahrenheit=>"64", :celsius=>"18" },
+            :low => { :fahrenheit=>"52", :celsius=>"11" },
+            :conditions => "Rain Showers",
+            :icon=>"rain",
+            :icon_url => "http://icons-ak.wxug.com/i/c/k/rain.gif",
+            :skyicon => "mostlycloudy",
+            :pop => 80,
+            :qpf_allday => { :in=>0.24, :mm=>6.1 },
+            :qpf_day => { :in=>0.13, :mm=>3.3 },
+            :qpf_night => { :in=>0.03, :mm=>0.8 },
+            :snow_allday => { :in=>0, :cm=>0 },
+            :snow_day => { :in=>0, :cm=>0 },
+            :snow_night => { :in=>0, :cm=>0 },
+            :maxwind => { :mph=>15, :kph=>24, :dir=>"SSE", :degrees=>160 },
+            :avewind => { :mph=>9, :kph=>14, :dir=>"SSW", :degrees=>194 },
+            :avehumidity => 85,
+            :maxhumidity => 93,
+            :minhumidity => 63
+          }
+    MD
+
+    default_schedule "midnight"
+
+    def working?
+      (event = event_created_within(2.days)) && event.payload.present?
+    end
+
+    def wunderground
+      Wunderground.new("your-api-key")
+    end
+
+    def default_options
+      { :zipcode => "94103" }
+    end
+
+    def validate_options
+      errors.add(:base, "zipcode is required") unless options[:zipcode].present?
+    end
+
+    def check
+      wunderground.forecast_for(options[:zipcode])["forecast"]["simpleforecast"]["forecastday"].each do |day|
+        if is_tomorrow?(day)
+          create_event :payload => day.merge(:zipcode => options[:zipcode])
+        end
+      end
+    end
+
+    def is_tomorrow?(day)
+      Time.zone.at(day["date"]["epoch"].to_i).to_date == Time.zone.now.tomorrow.to_date
+    end
+  end
+end

+ 98 - 0
app/models/agents/website_agent.rb

@@ -0,0 +1,98 @@
+require 'nokogiri'
+require 'typhoeus'
+require 'date'
+
+module Agents
+  class WebsiteAgent < Agent
+    cannot_receive_events!
+
+    description <<-MD
+      The WebsiteAgent scrapes a website and creates Events based on any changes in the results.
+
+      Specify the website's `url` and select a `mode` for when to create Events based on the scraped data, either `all` or `on_change`.
+
+      To tell the Agent how to scrape the site, specify `extract` as a hash with keys naming the extractions and values of hashes.
+      These subhashes specify how to extract with a `:css` CSS selector and either `:text => true` or `attr` pointing to an attribute name to grab.  An example:
+
+          :extract => {
+            :url => { :css => "#comic img", :attr => "src" },
+            :title => { :css => "#comic img", :attr => "title" },
+            :body_text => { :css => "div.main", :text => true }
+          }
+
+      Note that whatever you extract MUST have the same number of matches for each extractor.  E.g., if you're extracting rows, all extractors must match all rows.
+
+      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 do <<-MD
+      Events will have the fields you specified.  Your options look like:
+
+          #{PP.pp(options[:extract], "")}
+      MD
+    end
+
+    default_schedule "every_12h"
+
+    UNIQUENESS_LOOK_BACK = 30
+
+    def working?
+      (event = event_created_within(options[:expected_update_period_in_days].to_i.days)) && event.payload.present?
+    end
+
+    def default_options
+      {
+          :expected_update_period_in_days => "2",
+          :url => "http://xkcd.com",
+          :mode => :on_change,
+          :extract => {
+              :url => {:css => "#comic img", :attr => "src"},
+              :title => {:css => "#comic img", :attr => "title"}
+          }
+      }
+    end
+
+    def validate_options
+      errors.add(:base, "url, expected_update_period_in_days, and extract are required") unless options[:expected_update_period_in_days].present? && options[:url].present? && options[:extract].present?
+    end
+
+    def check
+      hydra = Typhoeus::Hydra.new
+      request = Typhoeus::Request.new(options[:url], :followlocation => true)
+      request.on_complete do |response|
+        doc = (options[:type].to_s == "xml" || options[:url] =~ /\.(rss|xml)$/i) ? Nokogiri::XML(response.body) : Nokogiri::HTML(response.body)
+        output = {}
+        options[:extract].each do |name, extraction_details|
+          output[name] = doc.css(extraction_details[:css]).map { |node|
+            if extraction_details[:attr]
+              node.attr(extraction_details[:attr])
+            elsif extraction_details[:text]
+              node.text()
+            else
+              raise StandardError, ":attr or :text is required on each of the extraction patterns."
+            end
+          }
+        end
+
+        num_unique_lengths = options[:extract].keys.map { |name| output[name].length }.uniq
+
+        raise StandardError, "Got an uneven number of matches for #{options[:name]}: #{options[:extract].inspect}" unless num_unique_lengths.length == 1
+
+        previous_payloads = events.order("id desc").limit(UNIQUENESS_LOOK_BACK).pluck(:payload) if options[:mode].to_s == "on_change"
+        num_unique_lengths.first.times do |index|
+          result = {}
+          options[:extract].keys.each do |name|
+            result[name] = output[name][index]
+          end
+
+          if !options[:mode] || options[:mode].to_s == "all" || (options[:mode].to_s == "on_change" && !previous_payloads.include?(result))
+            Rails.logger.info "Storing new result for '#{options[:name]}': #{result.inspect}"
+            create_event :payload => result
+          end
+        end
+      end
+      hydra.queue request
+      hydra.run
+    end
+  end
+end

+ 12 - 0
app/models/contact.rb

@@ -0,0 +1,12 @@
+class Contact < ActiveRecord::Base
+  attr_accessible :email, :message, :name
+
+  validates_format_of :email, :with => /\A[A-Z0-9._%+-]+@[A-Z0-9.-]+\.(?:[A-Z]{2}|com|org|net|edu|gov|mil|biz|info|mobi|name|aero|asia|jobs|museum)\Z/i
+  validates_presence_of :name, :message
+
+  after_create :send_contact
+
+  def send_contact
+    ContactMailer.send_contact(self).deliver
+  end
+end

+ 20 - 0
app/models/event.rb

@@ -0,0 +1,20 @@
+class Event < ActiveRecord::Base
+  attr_accessible :lat, :lng, :payload, :user_id, :user
+
+  acts_as_mappable
+
+  serialize :payload
+
+  belongs_to :user
+  belongs_to :agent, :counter_cache => true
+
+  before_save :symbolize_payload
+
+  scope :recent, lambda { |timespan = 12.hours.ago|
+    where("events.created_at > ?", timespan)
+  }
+
+  def symbolize_payload
+    self.payload = payload.recursively_symbolize_keys if payload.is_a?(Hash)
+  end
+end

+ 6 - 0
app/models/link.rb

@@ -0,0 +1,6 @@
+class Link < ActiveRecord::Base
+  attr_accessible :source_id, :receiver_id
+
+  belongs_to :source, :class_name => "Agent", :inverse_of => :links_as_source
+  belongs_to :receiver, :class_name => "Agent", :inverse_of => :links_as_receiver
+end

+ 36 - 0
app/models/user.rb

@@ -0,0 +1,36 @@
+class User < ActiveRecord::Base
+  # Include default devise modules. Others available are:
+  # :token_authenticatable, :confirmable,
+  # :lockable, :timeoutable and :omniauthable
+  devise :database_authenticatable, :registerable,
+         :recoverable, :rememberable, :trackable, :validatable, :lockable
+
+  INVITATION_CODES = %w[try-huginn]
+
+  # Virtual attribute for authenticating by either username or email
+  # This is in addition to a real persisted field like 'username'
+  attr_accessor :login
+
+  ACCESSIBLE_ATTRIBUTES = [ :email, :username, :login, :password, :password_confirmation, :remember_me, :invitation_code ]
+
+  attr_accessible *ACCESSIBLE_ATTRIBUTES
+  attr_accessible *(ACCESSIBLE_ATTRIBUTES + [:admin]), :as => :admin
+
+  validates_presence_of :username
+  validates_uniqueness_of :username
+  validates_format_of :username, :with => /\A[a-zA-Z0-9_-]{3,15}\Z/, :message => "can only contain letters, numbers, underscores, and dashes, and must be between 3 and 15 characters in length."
+  validates_inclusion_of :invitation_code, :in => INVITATION_CODES, :message => "is not valid"
+
+  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
+
+  # Allow users to login via either email or username.
+  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
+    else
+      where(conditions).first
+    end
+  end
+end

+ 71 - 0
app/views/agents/_form.html.erb

@@ -0,0 +1,71 @@
+<%= form_for(@agent,
+             :as => :agent,
+             :url => @agent.new_record? ? agents_path : agent_path(@agent),
+             :method => @agent.new_record? ? "POST" : "PUT") do |f| %>
+  <% if @agent.errors.any? %>
+    <div id="error_explanation">
+      <h2><%= pluralize(@agent.errors.count, "error") %> prohibited this Agent from being saved:</h2>
+      <ul>
+      <% @agent.errors.full_messages.each do |msg| %>
+        <li><%= msg %></li>
+      <% end %>
+      </ul>
+    </div>
+  <% end %>
+
+  <div class='pull-right well description span6'>
+    <%= @agent.html_description unless @agent.new_record? %>
+  </div>
+
+  <div class='pull-right well event-descriptions span6 hidden'>
+  </div>
+
+  <% if @agent.new_record? %>
+    <div class="control-group type-select">
+      <%= image_tag "spinner-arrows.gif", :class => "spinner" %>
+      <%= f.label :type, :class => 'control-label' %>
+      <div class="controls">
+        <%= f.select :type, options_for_select(Agent.types.map {|type| [type.to_s.gsub(/^.*::/, ''), type.to_s] }, @agent.type), :class => 'span4' %>
+      </div>
+    </div>
+  <% end %>
+
+  <div class="control-group">
+    <%= f.label :name, :class => 'control-label' %>
+    <div class="controls">
+      <%= f.text_field :name, :class => 'span4' %>
+    </div>
+  </div>
+
+  <div class="control-group">
+    <%= f.label :schedule, :class => 'control-label' %>
+    <div class="controls schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">
+      <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), :class => 'span4' %>
+      <span class='cannot-be-scheduled text-info'>This type of Agent cannot be scheduled.</span>
+    </div>
+  </div>
+
+  <div class="control-group">
+    <%= f.label :sources, :class => 'control-label' %>
+    <div class="controls link-region" data-can-receive-events="<%= @agent.can_receive_events? %>">
+      <%= f.select(:source_ids,
+                   options_for_select((current_user.agents - [@agent]).map {|s| [s.name, s.id] },
+                                      @agent.source_ids),
+                   {}, { :multiple => true, :size => 5, :class => 'span4 multi-select' }) %>
+      <span class='cannot-receive-events text-info'>This type of Agent cannot receive events.</span>
+    </div>
+  </div>
+
+  <div class="control-group">
+    <%= f.label :options, :class => 'control-label' %>
+    <div class="controls">
+      <textarea rows="10" id="agent_options" name="agent[options]" class="span8 live-json-editor">
+        <%= ((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options).to_json %>
+      </textarea>
+    </div>
+  </div>
+
+  <div class='form-actions' style='clear: both'>
+    <%= f.submit :class => "btn btn-primary" %>
+  </div>
+<% end %>

+ 33 - 0
app/views/agents/agent_views/peak_detector_agent/_show.html.erb

@@ -0,0 +1,33 @@
+<% content_for :head do %>
+  <%= javascript_include_tag "graphing" %>
+<% end %>
+
+<% if @agent.memory[:data] && @agent.memory[:data].length > 0 %>
+  <h3>Recent Tweet Trends</h3>
+  <% @agent.memory[:data].each.with_index do |(group_name, data), index| %>
+    <div class="filter-group counts">
+      <div class='filter'><%= link_to group_name.to_s, "https://twitter.com/search?q=#{CGI::escape group_name.to_s}", :target => "blank" %></div>
+
+      <div class="chart-container group-<%= index.to_s %>">
+        <div class="y-axis"></div>
+        <div class="chart"></div>
+        <div class="timeline"></div>
+      </div>
+
+      <script>
+        $(function() {
+          var $chart = $(".chart-container.group-<%= index.to_s %>").last();
+          var data = <%= data.map {|count, time| { :x => time.to_i, :y => count.to_i } }.to_json.html_safe %>;
+          var peaks = <%= ((@agent.memory[:peaks] && @agent.memory[:peaks][group_name]) || []).to_json.html_safe %>;
+          var name = <%= group_name.to_json.html_safe %>;
+
+          renderGraph($chart, data, peaks, name);
+        });
+      </script>
+    </div>
+  <% end %>
+<% else %>
+  <p>
+    No data has been received.
+  </p>
+<% end %>

+ 52 - 0
app/views/agents/agent_views/twitter_stream_agent/_show.html.erb

@@ -0,0 +1,52 @@
+<% content_for :head do %>
+  <%= javascript_include_tag "graphing" %>
+<% end %>
+
+<% grouped_events = @agent.events.order("id desc").limit(2000).group_by {|e| e.payload[:filter] || e.payload[:match] }%>
+<% if grouped_events.length > 0 %>
+  <% if @agent.options[:generate] == "events" %>
+
+    <h3>Recent Tweets</h3>
+      <% grouped_events.each do |filter, group|  %>
+      <div class="filter-group tweets">
+        <div class='filter'><%= filter %></div>
+        <% group.each do |event| %>
+          <% next unless event.payload[:text].present? %>
+          <div class='tweet'>
+            <%= event.payload[:text] %> - <%= link_to event.payload[:user][:screen_name], "http://twitter.com/#{event.payload[:user][:screen_name]}" %>
+          </div>
+        <% end %>
+      </div>
+    <% end %>
+
+  <% else %>
+
+    <h3>Recent Tweet Trends</h3>
+    <% grouped_events.each.with_index do |(filter, group), index|  %>
+      <div class="filter-group counts">
+        <div class='filter'><%= link_to filter, "https://twitter.com/search?q=#{CGI::escape filter}", :target => "blank" %></div>
+
+        <div class="chart-container group-<%= index.to_s %>">
+          <div class="y-axis"></div>
+          <div class="chart"></div>
+          <div class="timeline"></div>
+        </div>
+
+        <script>
+          $(function() {
+            var $chart = $(".chart-container.group-<%= index.to_s %>").last();
+            var data = <%= group.select {|e| e.payload[:count].present? }.sort_by {|e| e.payload[:time] }.map {|e| { :x => e.payload[:time].to_i, :y => e.payload[:count].to_i } }.to_json.html_safe %>;
+            var name = <%= filter.to_json.html_safe %>;
+
+            renderGraph($chart, data, [], name);
+          });
+        </script>
+      </div>
+    <% end %>
+
+  <% end %>
+<% else %>
+  <p>
+    No recent tweets found.
+  </p>
+<% end %>

+ 61 - 0
app/views/agents/agent_views/user_location_agent/_map_marker.html.erb

@@ -0,0 +1,61 @@
+<script>
+  (function(map) {
+    <%
+       if event.payload[:horizontal_accuracy] && event.payload[:vertical_accuracy]
+         radius = (event.payload[:horizontal_accuracy].to_f + event.payload[:vertical_accuracy].to_f) / 2.0
+       elsif event.payload[:horizontal_accuracy]
+         radius = event.payload[:horizontal_accuracy].to_f
+       elsif event.payload[:vertical_accuracy]
+         radius = event.payload[:vertical_accuracy].to_f
+       elsif event.payload[:accuracy]
+         radius = event.payload[:accuracy].to_f
+       else
+         radius = 0
+       end
+    %>
+
+    var pos = new google.maps.LatLng(<%= event.lat %>, <%= event.lng %>);
+
+    <% if radius > 0 %>
+      var accuracyCircle = new google.maps.Circle({
+        strokeColor: '#FF0000',
+        strokeOpacity: 0.8,
+        strokeWeight: 2,
+        fillColor: '#FF0000',
+        fillOpacity: 0.35,
+        map: map,
+        center: pos,
+        radius: <%= radius %>
+      });
+    <% else %>
+      var marker = new google.maps.Marker({
+        position: pos,
+        map: map,
+        title: 'Recorded Location'
+      });
+    <% end %>
+
+
+    <% if event.payload[:course] && event.payload[:course].to_f > -1 %>
+      var p1 = new LatLon(pos.lat(), pos.lng());
+      var p2 = p1.destinationPoint(<%= event.payload[:course].to_f %>, <%= [0.2, (event.payload[:speed] || 1).to_f].max * 0.1 %>);
+
+      var lineCoordinates = [ pos, new google.maps.LatLng(p2.lat(), p2.lon()) ];
+
+      var lineSymbol = {
+        path:google.maps.SymbolPath.FORWARD_CLOSED_ARROW
+      };
+
+      var line = new google.maps.Polyline({
+        path: lineCoordinates,
+        icons: [
+          {
+            icon: lineSymbol,
+            offset: '100%'
+          }
+        ],
+        map: map
+      });
+    <% end %>
+  })(map);
+</script>

+ 26 - 0
app/views/agents/agent_views/user_location_agent/_show.html.erb

@@ -0,0 +1,26 @@
+<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>
+
+<h3>Recent Event Map</h3>
+
+<% events = @agent.events.where("lat IS NOT null AND lng IS NOT null").order("id desc").limit(500) %>
+<% if events.length > 0 %>
+  <div id="map_canvas" style="width:800px; height:800px"></div>
+
+  <script type="text/javascript">
+    var mapOptions = {
+      center: new google.maps.LatLng(<%= events.first.lat %>, <%= events.first.lng %>),
+      zoom:15,
+      mapTypeId:google.maps.MapTypeId.ROADMAP
+    };
+
+    var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions);
+  </script>
+
+  <% events.each do |event| %>
+    <%= render "agents/agent_views/user_location_agent/map_marker", :event => event %>
+  <% end %>
+<% else %>
+  <p>
+    No events found.
+  </p>
+<% end %>

+ 27 - 0
app/views/agents/diagram.html.erb

@@ -0,0 +1,27 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>Agent Event Flow</h2>
+      </div>
+      <div class="btn-group">
+        <%= link_to '<i class="icon-chevron-left"></i> Back'.html_safe, agents_path, class: "btn" %>
+      </div>
+
+      <div class='digraph'>
+        <%
+           dot_format_string = "digraph foo {"
+           @agents.each.with_index do |agent, index|
+             dot_format_string += "\"#{agent.name}\";"
+             agent.receivers.each do |receiver|
+               dot_format_string += "\"#{agent.name}\"->\"#{receiver.name}\";"
+             end
+           end
+           dot_format_string = dot_format_string + "}"
+        %>
+
+        <img src="https://chart.googleapis.com/chart?cht=gv&chl=<%= CGI::escape dot_format_string %>" />
+      </div>
+    </div>
+  </div>
+</div>

+ 16 - 0
app/views/agents/edit.html.erb

@@ -0,0 +1,16 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>Editing your <%= @agent.short_type %></h2>
+      </div>
+
+      <%= render 'form' %>
+
+      <div class="btn-group">
+        <%= link_to '<i class="icon-chevron-left"></i> Back'.html_safe, agents_path, class: "btn" %>
+        <%= link_to '<i class="icon-asterisk"></i> Show'.html_safe, agent_path(@agent), class: "btn" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 73 - 0
app/views/agents/index.html.erb

@@ -0,0 +1,73 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>Your Agents</h2>
+      </div>
+
+      <table class='table table-striped'>
+        <tr>
+          <th>Name</th>
+          <th>Last Check</th>
+          <th>Last Event Out</th>
+          <th>Last Event In</th>
+          <th>Events</th>
+          <th>Schedule</th>
+          <th>Working?</th>
+          <th></th>
+        </tr>
+
+        <% @agents.each do |agent| %>
+            <tr>
+              <td>
+                <%= agent.name %>
+                <br/>
+                <span class='muted'><%= agent.short_type.titleize %></span>
+              </td>
+              <td>
+                <% if agent.cannot_be_scheduled? %>
+                    N/A
+                <% else %>
+                    <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
+                <% end %>
+              </td>
+              <td><%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %></td>
+              <td>
+                <% if agent.cannot_receive_events? %>
+                    N/A
+                <% else %>
+                    <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
+                <% end %>
+              </td>
+              <td><%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %></td>
+              <td><%= (agent.schedule || "n/a").to_s.humanize.titleize %></td>
+              <td><%= working(agent) %></td>
+              <td>
+                <div class="btn-group">
+                  <%= link_to 'Show', agent_path(agent), class: "btn btn-mini" %>
+                  <%= link_to 'Edit', edit_agent_path(agent), class: "btn btn-mini" %>
+                  <%= link_to 'Delete', agent_path(agent), method: :delete, data: {confirm: 'Are you sure?'}, class: "btn btn-mini" %>
+                  <% if agent.can_be_scheduled? %>
+                      <%= link_to 'Run', run_agent_path(agent), method: :post, class: "btn btn-mini" %>
+                  <% else %>
+                      <%= link_to 'Run', "#", class: "btn btn-mini disabled" %>
+                  <% end %>
+                </div>
+              </td>
+            </tr>
+        <% end %>
+      </table>
+
+      <%= paginate @agents, :theme => 'twitter-bootstrap' %>
+
+      <br/>
+
+      <div class="btn-group">
+        <%= link_to '<i class="icon-plus"></i> New Agent'.html_safe, new_agent_path, class: "btn" %>
+        <%= link_to '<i class="icon-refresh"></i> Run event propagation'.html_safe, propagate_agents_path, method: 'post', class: "btn" %>
+        <%= link_to '<i class="icon-random"></i> View diagram'.html_safe, diagram_agents_path, class: "btn" %>
+      </div>
+    </div>
+  </div>
+</div>
+

+ 15 - 0
app/views/agents/new.html.erb

@@ -0,0 +1,15 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>Create a new Agent</h2>
+      </div>
+
+      <%= render 'form' %>
+
+      <div class="btn-group">
+        <%= link_to '<i class="icon-chevron-left"></i> Back'.html_safe, agents_path, class: "btn" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 105 - 0
app/views/agents/show.html.erb

@@ -0,0 +1,105 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+
+      <div class="tabbable tabs-left">
+        <ul class="nav nav-tabs" id="show-tabs">
+          <% if agent_show_view(@agent).present? %>
+            <li class='active'><a href="#summary" data-toggle="tab"><i class='icon-picture'></i> Summary</a></li>
+            <li><a href="#details" data-toggle="tab"><i class='icon-indent-left'></i> Details</a></li>
+          <% else %>
+            <li class='disabled'><a><i class='icon-picture'></i> Summary</a></li>
+            <li class='active'><a href="#details" data-toggle="tab"><i class='icon-indent-left'></i> Details</a></li>
+          <% end %>
+
+          <% if @agent.events.count > 0 %>
+            <li><%= link_to '<i class="icon-random"></i> Events'.html_safe, events_path(:agent => @agent.to_param) %></li>
+          <% end %>
+          <li><%= link_to '<i class="icon-chevron-left"></i> Back'.html_safe, agents_path %></li>
+          <li><%= link_to '<i class="icon-pencil"></i> Edit'.html_safe, edit_agent_path(@agent) %></li>
+
+          <% if @agent.events.count > 0 %>
+            <li class="dropdown">
+              <a class="dropdown-toggle" data-toggle="dropdown" href="#">Actions <b class="caret"></b></a>
+              <ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">
+                  <li>
+                    <%= link_to '<i class="icon-trash"></i> Delete all events'.html_safe, remove_events_agent_path(@agent), method: :delete, data: {confirm: 'Are you sure you want to delete ALL events for this Agent?'}, :tabindex => "-1" %>
+                  </li>
+              </ul>
+            </li>
+          <% end %>
+
+        </ul>
+
+        <div class="tab-content">
+          <div class="tab-pane <%= agent_show_view(@agent).present? ? "active" : "disabled" %>" id="summary">
+            <h2><%= @agent.name %> Summary</h2>
+
+            <% if agent_show_view(@agent).present? %>
+              <div class="show-view <%= agent_show_class(@agent) %>">
+                <%= render agent_show_view(@agent) %>
+              </div>
+            <% end %>
+          </div>
+
+          <div class="tab-pane <%= agent_show_view(@agent).present? ? "" : "active" %>" id="details">
+            <h2><%= @agent.name %> Details</h2>
+
+            <p>
+              <b>Type:</b>
+              <%= @agent.short_type.titleize %>
+            </p>
+
+            <p>
+              <b>Schedule:</b>
+              <%= (@agent.schedule || "n/a").humanize.titleize %>
+            </p>
+
+            <p>
+              <b>Last checked:</b>
+              <% if @agent.cannot_be_scheduled? %>
+                N/A
+              <% else %>
+                <%= @agent.last_check_at ? time_ago_in_words(@agent.last_check_at) + " ago" : "never" %>
+              <% end %>
+            </p>
+
+            <p>
+              <b>Last event created:</b>
+              <%= @agent.last_event_at ? time_ago_in_words(@agent.last_event_at) + " ago" : "never" %>
+            </p>
+
+            <p>
+              <b>Last received event:</b>
+              <% if @agent.cannot_receive_events? %>
+                N/A
+              <% else %>
+                <%= @agent.last_receive_at ? time_ago_in_words(@agent.last_receive_at) + " ago" : "never" %>
+              <% end %>
+            </p>
+
+            <p>
+              <b>Event count:</b>
+              <%= link_to @agent.events.count, events_path(:agent => @agent.to_param) %>
+            </p>
+
+            <p>
+              <b>Working:</b>
+              <%= working @agent %>
+            </p>
+
+            <p>
+              <b>Options:</b>
+              <pre><%= PP.pp(@agent.options, "") %></pre>
+            </p>
+
+            <p>
+              <b>Memory:</b>
+              <pre><%= PP.pp(@agent.memory, "") %></pre>
+            </p>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>

+ 27 - 0
app/views/devise/confirmations/new.html.erb

@@ -0,0 +1,27 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span8 offset2'>
+      <div class='well'>
+
+        <h2>Resend confirmation instructions</h2>
+
+        <%= form_for(resource, :as => resource_name, :url => confirmation_path(resource_name), :html => { :method => :post, :class => 'form-horizontal' }) do |f| %>
+          <%= devise_error_messages! %>
+
+          <div class="control-group">
+            <%= f.label :email, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.email_field :email, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class='form-actions'>
+            <%= f.submit "Resend confirmation instructions", :class => "btn btn-primary" %>
+          </div>
+        <% end %>
+
+        <%= render "devise/shared/links" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 5 - 0
app/views/devise/mailer/confirmation_instructions.html.erb

@@ -0,0 +1,5 @@
+<p>Welcome <%= @resource.email %>!</p>
+
+<p>You can confirm your account email through the link below:</p>
+
+<p><%= link_to 'Confirm my account', confirmation_url(@resource, :confirmation_token => @resource.confirmation_token) %></p>

+ 8 - 0
app/views/devise/mailer/reset_password_instructions.html.erb

@@ -0,0 +1,8 @@
+<p>Hello <%= @resource.email %>!</p>
+
+<p>Someone has requested a link to change your password, and you can do this through the link below.</p>
+
+<p><%= link_to 'Change my password', edit_password_url(@resource, :reset_password_token => @resource.reset_password_token) %></p>
+
+<p>If you didn't request this, please ignore this email.</p>
+<p>Your password won't change until you access the link above and create a new one.</p>

+ 7 - 0
app/views/devise/mailer/unlock_instructions.html.erb

@@ -0,0 +1,7 @@
+<p>Hello <%= @resource.email %>!</p>
+
+<p>Your account has been locked due to an excessive amount of unsuccessful sign in attempts.</p>
+
+<p>Click the link below to unlock your account:</p>
+
+<p><%= link_to 'Unlock my account', unlock_url(@resource, :unlock_token => @resource.unlock_token) %></p>

+ 34 - 0
app/views/devise/passwords/edit.html.erb

@@ -0,0 +1,34 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span8 offset2'>
+      <div class='well'>
+        <h2>Change your password</h2>
+
+        <%= form_for(resource, :as => resource_name, :url => password_path(resource_name), :html => { :method => :put, :class => 'form-horizontal' }) do |f| %>
+          <%= devise_error_messages! %>
+          <%= f.hidden_field :reset_password_token %>
+
+          <div class="control-group">
+            <%= f.label :password, "New password", :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :password_confirmation, "Confirm new password", :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password_confirmation, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class='form-actions'>
+            <%= f.submit "Change my password", :class => "btn btn-primary" %>
+          </div>
+        <% end %>
+
+        <%= render "devise/shared/links" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 27 - 0
app/views/devise/passwords/new.html.erb

@@ -0,0 +1,27 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span8 offset2'>
+      <div class='well'>
+        <h2>Forgot your password?</h2>
+
+        <%= form_for(resource, :as => resource_name, :url => password_path(resource_name), :html => { :method => :post, :class => 'form-horizontal' }) do |f| %>
+          <%= devise_error_messages! %>
+
+          <div class="control-group">
+            <%= f.label :login, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.text_field :login, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class='form-actions'>
+            <%= f.submit "Send me reset password instructions", :class => "btn btn-primary" %>
+          </div>
+        <% end %>
+
+        <%= render "devise/shared/links" %>
+
+      </div>
+    </div>
+  </div>
+</div>

+ 61 - 0
app/views/devise/registrations/edit.html.erb

@@ -0,0 +1,61 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span8 offset2'>
+      <div class='well'>
+
+        <h2>Edit <%= resource_name.to_s.humanize %></h2>
+
+        <%= form_for(resource, :as => resource_name, :url => registration_path(resource_name), :html => { :method => :put, :class => 'form-horizontal' }) do |f| %>
+          <%= devise_error_messages! %>
+
+          <div class="control-group">
+            <%= f.label :email, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.email_field :email, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :username, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.text_field :username, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :password, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password, :autocomplete => "off", :class => 'span4' %>
+              <span class="help-inline">Leave blank if you don't want to change it.</span>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :password_confirmation, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password_confirmation, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :current_password, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :current_password, :class => 'span4' %>
+              <span class='help-inline'>We need your current password to confirm your changes.</span>
+            </div>
+          </div>
+
+          <div class='form-actions'>
+            <%= f.submit "Update", :class => "btn btn-primary" %>
+          </div>
+        <% end %>
+
+        <h3>Cancel my account</h3>
+
+        <p>Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), :data => { :confirm => "Are you sure?" }, :method => :delete %>.</p>
+
+        <%= link_to "Back", :back %>
+      </div>
+    </div>
+  </div>
+</div>

+ 55 - 0
app/views/devise/registrations/new.html.erb

@@ -0,0 +1,55 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span8 offset2'>
+      <div class='well'>
+        <h2>Sign up</h2>
+
+        <%= form_for(resource, :as => resource_name, :url => registration_path(resource_name), :html => { :class => 'form-horizontal' }) do |f| %>
+          <%= devise_error_messages! %>
+
+          <div class="control-group">
+            <%= f.label :invitation_code, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.text_field :invitation_code, :class => 'span4' %>
+              <span class="help-inline">We are not yet open to the public.  If you have an invitation code, please enter it here.</span>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :email, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.email_field :email, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :username, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.text_field :username, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :password, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :password_confirmation, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password_confirmation, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class='form-actions'>
+            <%= f.submit "Sign up", :class => "btn btn-primary" %>
+          </div>
+        <% end %>
+
+        <%= render "devise/shared/links" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 42 - 0
app/views/devise/sessions/new.html.erb

@@ -0,0 +1,42 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span8 offset2'>
+      <div class='well'>
+
+        <h2>Sign in</h2>
+
+        <%= form_for(resource, :as => resource_name, :url => session_path(resource_name), :html => { :class => 'form-horizontal' }) do |f| %>
+          <div class="control-group">
+            <%= f.label :login, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.text_field :login, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <%= f.label :password, :class => 'control-label' %>
+            <div class="controls">
+              <%= f.password_field :password, :class => 'span4' %>
+            </div>
+          </div>
+
+          <div class="control-group">
+            <% if devise_mapping.rememberable? %>
+              <div class='controls'>
+                <label class='checkbox'>
+                  <%= f.check_box :remember_me %> Remember me
+                </label>
+              </div>
+            <% end -%>
+          </div>
+
+          <div class='form-actions'>
+            <%= f.submit "Sign in", :class => "btn btn-primary" %>
+          </div>
+        <% end %>
+
+        <%= render "devise/shared/links" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 25 - 0
app/views/devise/shared/_links.erb

@@ -0,0 +1,25 @@
+<%- if controller_name != 'sessions' %>
+  <%= link_to "Sign in", new_session_path(resource_name) %><br />
+<% end -%>
+
+<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
+  <%= link_to "Sign up", new_registration_path(resource_name) %><br />
+<% end -%>
+
+<%- if devise_mapping.recoverable? && controller_name != 'passwords' %>
+  <%= link_to "Forgot your password?", new_password_path(resource_name) %><br />
+<% end -%>
+
+<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
+  <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %><br />
+<% end -%>
+
+<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
+  <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %><br />
+<% end -%>
+
+<%- if devise_mapping.omniauthable? %>
+  <%- resource_class.omniauth_providers.each do |provider| %>
+    <%= link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %><br />
+  <% end -%>
+<% end -%>

+ 14 - 0
app/views/devise/unlocks/new.html.erb

@@ -0,0 +1,14 @@
+<h2>Resend unlock instructions</h2>
+
+<%= form_for(resource, :as => resource_name, :url => unlock_path(resource_name), :html => { :method => :post }) do |f| %>
+  <%= devise_error_messages! %>
+
+  <div>
+    <%= f.label :email %>
+    <%= f.email_field :email %>
+  </div>
+
+  <div><%= f.submit "Resend unlock instructions" %></div>
+<% end %>
+
+<%= render "devise/shared/links" %>

+ 46 - 0
app/views/events/index.html.erb

@@ -0,0 +1,46 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>
+          Your Events <%= @agent && "from #{@agent.name}" %>
+        </h2>
+      </div>
+
+      <table class='table table-striped events'>
+        <tr>
+          <th>Source</th>
+          <th>Created</th>
+          <th>Payload</th>
+          <th></th>
+        </tr>
+
+      <% @events.each do |event| %>
+        <tr>
+          <td><%= link_to event.agent.name, agent_path(event.agent) %></td>
+          <td><%= time_ago_in_words event.created_at %> ago</td>
+          <td class='payload'><%= truncate event.payload.to_json, :length => 90, :omission => "" %></td>
+          <td>
+            <div class="btn-group">
+              <%= link_to 'Show', event_path(event), class: "btn btn-mini" %>
+              <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-mini" %>
+            </div>
+          </td>
+        </tr>
+      <% end %>
+      </table>
+
+      <%= paginate @events, :theme => 'twitter-bootstrap' %>
+
+      <br />
+
+      <% if @agent %>
+        <div class="btn-group">
+          <%= link_to '<i class="icon-chevron-left"></i> Back'.html_safe, agents_path, class: "btn" %>
+          <%= link_to '<i class="icon-random""></i> See all events'.html_safe, events_path, class: "btn" %>
+        </div>
+      <% end %>
+    </div>
+  </div>
+</div>
+

+ 45 - 0
app/views/events/show.html.erb

@@ -0,0 +1,45 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>Event from <%= @event.agent.name %></h2>
+      </div>
+
+      <p>
+        <b>Payload:</b>
+        <pre><%= PP.pp(@event.payload, "") %></pre>
+      </p>
+
+      <% if @event.lat && @event.lng %>
+        <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?sensor=false"></script>
+
+        <p>
+          <b>Lat:</b>
+          <%= @event.lat %>
+          <br/>
+          <b>Lng:</b>
+          <%= @event.lng %>
+        </p>
+
+        <div id="map_canvas" style="width:400px; height:300px"></div>
+
+        <script type="text/javascript">
+          var mapOptions = {
+            center: new google.maps.LatLng(<%= @event.lat %>, <%= @event.lng %>),
+            zoom:15,
+            mapTypeId:google.maps.MapTypeId.ROADMAP
+          };
+
+          var map = new google.maps.Map(document.getElementById("map_canvas"), mapOptions);
+        </script>
+
+        <%= render "map_marker", :event => @event %>
+      <% end %>
+
+      <br />
+      <div class="btn-group">
+        <%= link_to '<i class="icon-chevron-left"></i> Back'.html_safe, events_path, class: "btn" %>
+      </div>
+    </div>
+  </div>
+</div>

+ 46 - 0
app/views/home/_signed_in_index.html.erb

@@ -0,0 +1,46 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h2>Welcome to Huginn!
+          <small></small>
+        </h2>
+      </div>
+    </div>
+  </div>
+
+  <div class='container'>
+    <div class='row'>
+      <div class='span6'>
+        <p>
+          You have created <span class="text-success"><%= pluralize current_user.agents.count, "agent" %></span>.
+        </p>
+        <%= link_to 'View <i class="icon-chevron-right"></i>'.html_safe, agents_path, class: "btn btn-primary" %>
+      </div>
+
+      <div class='span6'>
+        <p>
+          Your agents have recorded
+          <span class="text-success"><%= pluralize current_user.events.recent.count, "event" %> recently</span> and
+          <span class=""><%= pluralize current_user.events.count, 'event' %> in total</span>.
+        </p>
+        <%= link_to 'View <i class="icon-chevron-right"></i>'.html_safe, events_path, class: "btn btn-primary" %>
+      </div>
+    </div>
+  </div>
+
+  <div class='row' style='margin-top:250px'>
+    <div class='span10'>
+      <blockquote class=''>
+        <p>...two ravens named Huginn and Muninn sit on Odin's shoulders. The ravens tell Odin everything they see and
+          hear. Odin sends Huginn and Muninn out at dawn, and the birds fly all over the world before returning at
+          dinner-time. As a result, Odin is kept informed of many events.</p>
+        <small><a href="http://en.wikipedia.org/wiki/Huginn_and_Muninn">Wikipedia</a>, in reference to
+          <em>Gylfaginning</em></small>
+      </blockquote>
+    </div>
+    <div class='span2'>
+      <%= image_tag 'odin.jpg', :class => 'img-rounded odin', :title => "Wägner, Wilhelm. 1882. Nordisch-germanische Götter und Helden. Otto Spamer, Leipzig & Berlin. Page 7." %>
+    </div>
+  </div>
+</div>

+ 13 - 0
app/views/home/_signed_out_index.html.erb

@@ -0,0 +1,13 @@
+<div class='container'>
+  <div class='row'>
+    <div class="span5 offset2">
+      <h1>Your agents are standing by</h1>
+      <p>Know the world around you</p>
+
+      <%= link_to "Signup", new_user_registration_path, :class => "btn btn-primary btn-large center" %>
+    </div>
+    <div class="span3">
+      <%= image_tag 'odin.jpg', :class => 'img-rounded', :title => "Wägner, Wilhelm. 1882. Nordisch-germanische Götter und Helden. Otto Spamer, Leipzig & Berlin. Page 7." %>
+    </div>
+  </div>
+</div>

+ 9 - 0
app/views/home/about.html.erb

@@ -0,0 +1,9 @@
+<div class='container'>
+  <div class='row'>
+    <div class='span12'>
+      <div class="page-header">
+        <h1>Example page header</h1>
+      </div>
+    </div>
+  </div>
+</div>

+ 5 - 0
app/views/home/index.html.erb

@@ -0,0 +1,5 @@
+<% if user_signed_in? %>
+  <%= render "signed_in_index" %>
+<% else %>
+  <%= render "signed_out_index" %>
+<% end %>

+ 6 - 0
app/views/layouts/_messages.html.erb

@@ -0,0 +1,6 @@
+<% flash.each do |name, msg| %>
+  <div class="top-flash alert alert-<%= name == :notice ? "success" : "error" %>">
+    <a class="close" data-dismiss="alert">&#215;</a>
+    <%= content_tag :div, msg, :id => "flash_#{name}" if msg.is_a?(String) %>
+  </div>
+<% end %>

+ 50 - 0
app/views/layouts/_navigation.html.erb

@@ -0,0 +1,50 @@
+<%= link_to "Huginn", root_path, :class => 'brand' %>
+
+<% if user_signed_in? %>
+  <ul class='nav pull-left'>
+    <%= nav_link "Agents", agents_path %>
+    <%= nav_link "Events", events_path %>
+  </ul>
+<% end %>
+
+<ul class="nav pull-right">
+  <% if current_user.try(:admin?) %>
+    <li>
+      <%= link_to 'Admin', rails_admin_path %>
+    </li>
+
+    <li class='divider-vertical'></li>
+  <% end %>
+
+  <% if user_signed_in? %>
+    <li id='job-indicator'>
+      <a href="/delayed_job">
+        <span class="badge"><i class="icon-refresh icon-white"></i> <span class='number'>0</span></span>
+      </a>
+    </li>
+  <% end %>
+
+  <li class="dropdown">
+    <a href="#" class="dropdown-toggle" data-toggle="dropdown">
+      Account
+      <b class="caret"></b>
+    </a>
+    <ul class="dropdown-menu" role="menu" aria-labelledby="dLabel">
+      <li>
+        <% if user_signed_in? %>
+          <%= link_to 'Account', edit_user_registration_path, :tabindex => "-1" %>
+        <% else %>
+          <%= link_to 'Sign up', new_user_registration_path, :tabindex => "-1" %>
+        <% end %>
+      </li>
+
+      <li>
+        <% if user_signed_in? %>
+          <%= link_to 'Logout', destroy_user_session_path, :method => :delete, :tabindex => "-1" %>
+        <% else %>
+          <%= link_to 'Login', new_user_session_path, :tabindex => "-1" %>
+        <% end %>
+      </li>
+    </ul>
+  </li>
+</ul>

+ 36 - 0
app/views/layouts/application.html.erb

@@ -0,0 +1,36 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><%= content_for?(:title) ? yield(:title) : "Huginn" %></title>
+    <meta name="description" content="">
+    <meta name="author" content="">
+    <%= stylesheet_link_tag    "application", :media => "all" %>
+    <%= javascript_include_tag "application" %>
+    <%= csrf_meta_tags %>
+    <%= yield(:head) %>
+  </head>
+  <body>
+    <header class="navbar navbar-fixed-top">
+      <nav class="navbar-inner">
+        <div class="container">
+          <%= render 'layouts/navigation' %>
+        </div>
+      </nav>
+    </header>
+
+    <div id="main" role="main">
+      <div class="container">
+        <div class="content">
+           <div class="row">
+            <div class="span12">
+              <%= render 'layouts/messages' %>
+              <%= yield %>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </body>
+</html>

+ 16 - 0
app/views/system_mailer/send_message.html.erb

@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
+  </head>
+  <body>
+    <% if @headline %>
+      <h1><%= @headline %></h1>
+    <% end %>
+    <% @lines.each do |line| %>
+      <p>
+        <%= line %>
+      </p>
+    <% end %>
+  </body>
+</html>

+ 5 - 0
app/views/system_mailer/send_message.text.erb

@@ -0,0 +1,5 @@
+<% if @headline %><%= @headline %>
+
+<% end %><% @lines.each do |line| %><%= line %>
+
+<% end %>

+ 10 - 0
bin/decrypt_backup.rb

@@ -0,0 +1,10 @@
+#!/usr/bin/env ruby
+
+in_file = ARGV.shift
+out_file = ARGV.shift || "decrypted_backup.tar"
+
+puts "About to decrypt #{in_file} and write it to #{out_file}."
+
+cmd = "bundle exec backup decrypt --encryptor openssl --base64 --salt --in #{in_file} --out #{out_file}"
+puts "Executing: #{cmd}"
+puts `#{cmd}`

+ 62 - 0
bin/schedule.rb

@@ -0,0 +1,62 @@
+#!/usr/bin/env ruby
+
+unless defined?(Rails)
+  puts
+  puts "Please run me with rails runner, for example:"
+  puts "  RAILS_ENV=production bundle exec rails runner bin/schedule.rb"
+  puts
+  exit 1
+end
+
+require 'rufus/scheduler'
+
+def run_schedule(time, mutex)
+  mutex.synchronize do
+    puts "Queuing schedule for #{time}"
+    Agent.delay.run_schedule(time)
+  end
+end
+
+def propogate!(mutex)
+  mutex.synchronize do
+    puts "Queuing event propagation"
+    Agent.delay.receive!
+  end
+end
+
+mutex = Mutex.new
+
+scheduler = Rufus::Scheduler.start_new
+
+# Schedule event propagation.
+
+scheduler.every '5m' do
+  propogate!(mutex)
+end
+
+# Schedule repeating events.
+
+%w[2m 5m 10m 30m 1h 2h 5h 12h 1d 2d 7d].each do |schedule|
+  scheduler.every schedule do
+    run_schedule "every_#{schedule}", mutex
+  end
+end
+
+# Schedule events for specific times.
+
+# Times are assumed to be in PST for now.  Can store a user#timezone later.
+24.times do |hour|
+  scheduler.cron "0 #{hour} * * * America/Los_Angeles" do
+    if hour == 0
+      run_schedule "midnight", mutex
+    elsif hour < 12
+      run_schedule "#{hour}am", mutex
+    elsif hour == 12
+      run_schedule "noon", mutex
+    else
+      run_schedule "#{hour - 12}pm", mutex
+    end
+  end
+end
+
+scheduler.join

+ 120 - 0
bin/twitter_stream.rb

@@ -0,0 +1,120 @@
+#!/usr/bin/env ruby
+
+unless defined?(Rails)
+  puts
+  puts "Please run me with rails runner, for example:"
+  puts "  RAILS_ENV=production bundle exec rails runner bin/twitter_stream.rb"
+  puts
+  exit 1
+end
+
+require 'cgi'
+require 'json'
+require 'twitter/json_stream'
+require 'em-http-request'
+require 'pp'
+
+def stream!(username, password, filters, &block)
+  stream = Twitter::JSONStream.connect(
+    :path    => "/1/statuses/#{(filters && filters.length > 0) ? 'filter' : 'sample'}.json#{"?track=#{filters.map {|f| CGI::escape(f) }.join(",")}" if filters && filters.length > 0}",
+    :auth    => "#{username}:#{password}",
+    :ssl     => true
+  )
+
+  stream.each_item do |status|
+    status = JSON.parse(status) if status.is_a?(String)
+    next unless status
+    next if status.has_key?('delete')
+    next unless status['text']
+    status['text'] = status['text'].gsub(/&lt;/, "<").gsub(/&gt;/, ">").gsub(/[\t\n\r]/, '  ')
+    block.call(status)
+  end
+
+  stream.on_error do |message|
+    STDERR.puts " --> Twitter error: #{message} <--"
+  end
+
+  stream.on_no_data do |message|
+    STDERR.puts " --> Got no data for awhile; trying to reconnect."
+    EventMachine::stop_event_loop
+  end
+
+  stream.on_max_reconnects do |timeout, retries|
+    STDERR.puts " --> Oops, tried too many times! <--"
+    EventMachine::stop_event_loop
+  end
+end
+
+def load_and_run(agents)
+  agents.group_by { |agent| agent.options[:twitter_username] }.each do |twitter_username, agents|
+    filter_to_agent_map = agents.map { |agent| agent.options[:filters] }.flatten.uniq.compact.inject({}) { |m, f| m[f] = []; m }
+
+    agents.each do |agent|
+      agent.options[:filters].uniq.map(&:strip).each do |filter|
+        filter_to_agent_map[filter] << agent
+      end
+    end
+
+    username = agents.first.options[:twitter_username]
+    password = agents.first.options[:twitter_password]
+
+    recent_tweets = []
+
+    stream!(username, password, filter_to_agent_map.keys) do |status|
+      if status["retweeted_status"].present? && status["retweeted_status"].is_a?(Hash)
+        puts "Skipping retweet: #{status["text"]}"
+      elsif recent_tweets.include?(status["id_str"])
+        puts "Skipping duplicate tweet: #{status["text"]}"
+      else
+        recent_tweets << status["id_str"]
+        recent_tweets.shift if recent_tweets.length > DUPLICATE_DETECTION_LENGTH
+        puts status["text"]
+        filter_to_agent_map.keys.each do |filter|
+          if filter.downcase.split(SEPARATOR) - status["text"].downcase.split(SEPARATOR) == [] # Hacky McHackerson
+            filter_to_agent_map[filter].each do |agent|
+              puts " -> #{agent.name}"
+              agent.process_tweet(filter, status)
+            end
+          end
+        end
+      end
+    end
+  end
+end
+
+RELOAD_TIMEOUT = 10.minutes
+DUPLICATE_DETECTION_LENGTH = 1000
+SEPARATOR = /[^\w_\-]+/
+
+while true
+  begin
+    agents = Agents::TwitterStreamAgent.all
+
+    EventMachine::run do
+      EventMachine.add_periodic_timer(RELOAD_TIMEOUT) {
+        puts "Reloading EventMachine and all Agents..."
+        EventMachine::stop_event_loop
+      }
+
+      if agents.length == 0
+        puts "No agents found.  Will look again in a minute."
+        sleep 60
+        EventMachine::stop_event_loop
+      else
+        puts "Found #{agents.length} agent(s).  Loading them now..."
+        load_and_run agents
+      end
+    end
+
+    print "Pausing..."; STDOUT.flush
+    sleep 5
+    puts "done."
+  rescue SignalException, SystemExit
+    EventMachine::stop_event_loop if EventMachine.reactor_running?
+    exit
+  rescue StandardError => e
+    STDERR.puts "\nException #{e.message}:\n#{e.backtrace.join("\n")}\n\n"
+    STDERR.puts "Waiting for a couple of minutes..."
+    sleep 120
+  end
+end

+ 11 - 0
config.ru

@@ -0,0 +1,11 @@
+# This file is used by Rack-based servers to start the application.
+
+require ::File.expand_path('../config/environment',  __FILE__)
+
+# if Rails.env.production?
+#  DelayedJobWeb.use Rack::Auth::Basic do |username, password|
+#    username == 'admin' && password == 'password'
+#  end
+# end
+
+run Huginn::Application

+ 62 - 0
config/application.rb

@@ -0,0 +1,62 @@
+require File.expand_path('../boot', __FILE__)
+
+require 'rails/all'
+
+if defined?(Bundler)
+  # If you precompile assets before deploying to production, use this line
+  Bundler.require(*Rails.groups(:assets => %w(development test)))
+  # If you want your assets lazily compiled in production, use this line
+  # Bundler.require(:default, :assets, Rails.env)
+end
+
+module Huginn
+  class Application < Rails::Application
+    # Settings in config/environments/* take precedence over those specified here.
+    # Application configuration should go into files in config/initializers
+    # -- all .rb files in that directory are automatically loaded.
+
+    # Custom directories with classes and modules you want to be autoloadable.
+    config.autoload_paths += %W(#{config.root}/lib)
+
+    # Only load the plugins named here, in the order given (default is alphabetical).
+    # :all can be used as a placeholder for all plugins not explicitly named.
+    # config.plugins = [ :exception_notification, :ssl_requirement, :all ]
+
+    # Activate observers that should always be running.
+    # config.active_record.observers = :cacher, :garbage_collector, :forum_observer
+
+    # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
+    # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
+    config.time_zone = 'Pacific Time (US & Canada)'
+
+    # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
+    # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
+    # config.i18n.default_locale = :de
+
+    # Configure the default encoding used in templates for Ruby 1.9.
+    config.encoding = "utf-8"
+
+    # Configure sensitive parameters which will be filtered from the log file.
+    config.filter_parameters += [:password]
+
+    # Enable escaping HTML in JSON.
+    config.active_support.escape_html_entities_in_json = true
+
+    # Use SQL instead of Active Record's schema dumper when creating the database.
+    # This is necessary if your schema can't be completely dumped by the schema dumper,
+    # like if you have constraints or database-specific column types
+    # config.active_record.schema_format = :sql
+
+    # Enforce whitelist mode for mass assignment.
+    # This will create an empty whitelist of attributes available for mass-assignment for all models
+    # in your app. As such, your models will need to explicitly whitelist or blacklist accessible
+    # parameters by using an attr_accessible or attr_protected declaration.
+    config.active_record.whitelist_attributes = true
+
+    # Enable the asset pipeline
+    config.assets.enabled = true
+
+    # Version of your assets, change this if you want to expire all your assets
+    config.assets.version = '1.0'
+  end
+end

+ 6 - 0
config/boot.rb

@@ -0,0 +1,6 @@
+require 'rubygems'
+
+# Set up gems listed in the Gemfile.
+ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
+
+require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE'])

+ 32 - 0
config/database.yml

@@ -0,0 +1,32 @@
+development:
+  adapter: mysql2
+  database: huginn_development
+  username: root
+  password: password
+  socket: <%= ["/var/run/mysqld/mysqld.sock", "/opt/local/var/run/mysql5/mysqld.sock", "/tmp/mysql.sock"].find{ |path| File.exist? path } %>
+  encoding: utf8
+  reconnect: true
+  pool: 5
+
+# Warning: The database defined as "test" will be erased and
+# re-generated from your development database when you run "rake".
+# Do not set this db to the same as development or production.
+test:
+  adapter: mysql2
+  database: huginn_test
+  username: root
+  password: password
+  socket: <%= ["/var/run/mysqld/mysqld.sock", "/opt/local/var/run/mysql5/mysqld.sock", "/tmp/mysql.sock"].find{ |path| File.exist? path } %>
+  encoding: utf8
+  reconnect: true
+  pool: 5
+
+production:
+  adapter: mysql2
+  encoding: utf8
+  reconnect: true
+  database: huginn_production
+  pool: 5
+  username: root
+  password: password
+  socket: <%= ["/var/run/mysqld/mysqld.sock", "/opt/local/var/run/mysql5/mysqld.sock", "/tmp/mysql.sock"].find{ |path| File.exist? path } %>

+ 51 - 0
config/deploy.rb

@@ -0,0 +1,51 @@
+default_run_options[:pty] = true
+
+set :application, "huginn"
+set :deploy_to, "/home/you/app"
+set :user, "you"
+set :use_sudo, false
+set :rails_env, "production" #added for delayed job
+set :scm, :git
+set :repository, "git@github.com:you/huginn.git"
+set :branch, "master"
+set :deploy_via, :remote_cache
+set :keep_releases, 5
+
+# If you want to use rvm on the server:
+set :rvm_ruby_string, '1.9.3-p286@huginn'
+set :rvm_type, :user
+
+set :bundle_without, [:development]
+set :unicorn_pid, "#{shared_path}/pids/unicorn.pid"
+
+server "yourdomain.com", :app, :delayed_job, :web, :db, :primary => true
+
+set :delayed_job_server_role, :delayed_job
+
+set :rails_env, 'production'
+set :sync_backups, 3
+
+before 'deploy:restart', 'deploy:migrate'
+before 'deploy', 'rvm:install_rvm'
+before 'deploy', 'rvm:install_ruby'
+after 'deploy', 'deploy:cleanup'
+
+set :bundle_without, [:development, :test]
+
+after "deploy:stop", "delayed_job:stop"
+after "deploy:start", "delayed_job:start"
+after "deploy:restart", "delayed_job:restart"
+
+# If you want to use command line options, for example to start multiple workers,
+# define a Capistrano variable delayed_job_args:
+#
+#   set :delayed_job_args, "-n 2"
+
+# Load Capistrano additions
+Dir[File.expand_path("../../lib/capistrano/*.rb", __FILE__)].each{|f| load f }
+
+require "rvm/capistrano"
+require "bundler/capistrano"
+require "capistrano-unicorn"
+require "delayed/recipes"
+load 'deploy/assets'

+ 8 - 0
config/environment.rb

@@ -0,0 +1,8 @@
+# Load the rails application
+require File.expand_path('../application', __FILE__)
+
+# Remove the XML parser from the list that will be used to initialize the application's XML parser list.
+ActionDispatch::ParamsParser::DEFAULT_PARSERS.delete(Mime::XML)
+
+# Initialize the rails application
+Huginn::Application.initialize!

+ 51 - 0
config/environments/development.rb

@@ -0,0 +1,51 @@
+Huginn::Application.configure do
+  # Settings specified here will take precedence over those in config/application.rb
+
+  # In the development environment your application's code is reloaded on
+  # every request. This slows down response time but is perfect for development
+  # since you don't have to restart the web server when you make code changes.
+  config.cache_classes = false
+
+  # Log error messages when you accidentally call methods on nil.
+  config.whiny_nils = true
+
+  # Show full error reports and disable caching
+  config.consider_all_requests_local       = true
+  config.action_controller.perform_caching = false
+
+  # Print deprecation notices to the Rails logger
+  config.active_support.deprecation = :log
+
+  # Only use best-standards-support built into browsers
+  config.action_dispatch.best_standards_support = :builtin
+
+  # Raise exception on mass assignment protection for Active Record models
+  config.active_record.mass_assignment_sanitizer = :strict
+
+  # Log the query plan for queries taking more than this (works
+  # with SQLite, MySQL, and PostgreSQL)
+  config.active_record.auto_explain_threshold_in_seconds = 0.5
+
+  # Do not compress assets
+  config.assets.compress = false
+
+  # Expands the lines which load the assets
+  config.assets.debug = true
+
+  DOMAIN = "localhost:3000"
+
+  config.action_mailer.default_url_options = { :host => DOMAIN }
+  config.action_mailer.asset_host = DOMAIN
+  config.action_mailer.perform_deliveries = false # Enable when testing!
+  config.action_mailer.raise_delivery_errors = true
+  config.action_mailer.delivery_method = :smtp
+  config.action_mailer.smtp_settings = {
+      address: "smtp.gmail.com",
+      port: 587,
+      domain: "your-google-apps-domain.com",
+      authentication: "plain",
+      enable_starttls_auto: true,
+      user_name: "huginn@your-google-apps-domain.com",
+      password: "your-password"
+  }
+end

+ 81 - 0
config/environments/production.rb

@@ -0,0 +1,81 @@
+Huginn::Application.configure do
+  # Settings specified here will take precedence over those in config/application.rb
+
+  # Code is not reloaded between requests
+  config.cache_classes = true
+
+  # Full error reports are disabled and caching is turned on
+  config.consider_all_requests_local       = false
+  config.action_controller.perform_caching = true
+
+  # Disable Rails's static asset server (Apache or nginx will already do this)
+  config.serve_static_assets = false
+
+  # Compress JavaScripts and CSS
+  config.assets.compress = true
+
+  # Don't fallback to assets pipeline if a precompiled asset is missed
+  config.assets.compile = false
+
+  # Generate digests for assets URLs
+  config.assets.digest = true
+
+  # Defaults to nil and saved in location specified by config.assets.prefix
+  # config.assets.manifest = YOUR_PATH
+
+  # Specifies the header that your server uses for sending files
+  # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache
+  # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx
+
+  # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
+  config.force_ssl = true
+
+  # See everything in the log (default is :info)
+  # config.log_level = :debug
+
+  # Prepend all log lines with the following tags
+  config.log_tags = [ :uuid ] # :subdomain
+
+  # Use a different logger for distributed setups
+  # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
+
+  # Use a different cache store in production
+  # config.cache_store = :mem_cache_store
+
+  # Enable serving of images, stylesheets, and JavaScripts from an asset server
+  # config.action_controller.asset_host = "http://assets.example.com"
+
+  # Precompile additional assets (application.js.coffee.erb, application.css, and all non-JS/CSS are already added)
+  config.assets.precompile += %w( graphing.js )
+
+  # Enable threaded mode
+  # config.threadsafe!
+
+  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+  # the I18n.default_locale when a translation can not be found)
+  config.i18n.fallbacks = true
+
+  # Send deprecation notices to registered listeners
+  config.active_support.deprecation = :notify
+
+  # Log the query plan for queries taking more than this (works
+  # with SQLite, MySQL, and PostgreSQL)
+  # config.active_record.auto_explain_threshold_in_seconds = 0.5
+
+  DOMAIN = 'www.yourdomain.com'
+
+  config.action_mailer.default_url_options = { :host => DOMAIN }
+  config.action_mailer.asset_host = DOMAIN
+  config.action_mailer.perform_deliveries = true
+  config.action_mailer.raise_delivery_errors = true
+  config.action_mailer.delivery_method = :smtp
+  config.action_mailer.smtp_settings = {
+      address: "smtp.gmail.com",
+      port: 587,
+      domain: "your-google-apps-domain.com",
+      authentication: "plain",
+      enable_starttls_auto: true,
+      user_name: "huginn@your-google-apps-domain.com",
+      password: "your-password"
+  }
+end

+ 44 - 0
config/environments/test.rb

@@ -0,0 +1,44 @@
+Huginn::Application.configure do
+  # Settings specified here will take precedence over those in config/application.rb
+
+  # The test environment is used exclusively to run your application's
+  # test suite. You never need to work with it otherwise. Remember that
+  # your test database is "scratch space" for the test suite and is wiped
+  # and recreated between test runs. Don't rely on the data there!
+  config.cache_classes = true
+
+  # Configure static asset server for tests with Cache-Control for performance
+  config.serve_static_assets = true
+  config.static_cache_control = "public, max-age=3600"
+
+  # Log error messages when you accidentally call methods on nil
+  config.whiny_nils = true
+
+  # Show full error reports and disable caching
+  config.consider_all_requests_local       = true
+  config.action_controller.perform_caching = false
+
+  # Raise exceptions instead of rendering exception templates
+  config.action_dispatch.show_exceptions = false
+
+  # Disable request forgery protection in test environment
+  config.action_controller.allow_forgery_protection    = false
+
+  # Tell Action Mailer not to deliver emails to the real world.
+  # The :test delivery method accumulates sent emails in the
+  # ActionMailer::Base.deliveries array.
+  config.action_mailer.delivery_method = :test
+
+  config.action_mailer.raise_delivery_errors = true
+
+  # Raise exception on mass assignment protection for Active Record models
+  config.active_record.mass_assignment_sanitizer = :strict
+
+  # Print deprecation notices to the stderr
+  config.active_support.deprecation = :stderr
+
+  DOMAIN = 'test.host'
+
+  config.action_mailer.default_url_options = { :host => DOMAIN }
+  config.action_mailer.perform_deliveries = true
+end

+ 7 - 0
config/initializers/backtrace_silencers.rb

@@ -0,0 +1,7 @@
+# Be sure to restart your server when you modify this file.
+
+# You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
+# Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
+
+# You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
+# Rails.backtrace_cleaner.remove_silencers!

+ 8 - 0
config/initializers/delayed_job.rb

@@ -0,0 +1,8 @@
+Delayed::Worker.destroy_failed_jobs = false
+Delayed::Worker.max_attempts = 5
+Delayed::Worker.max_run_time = 20.minutes
+Delayed::Worker.default_priority = 10
+Delayed::Worker.delay_jobs = !Rails.env.test?
+
+Delayed::Worker.logger = Logger.new(Rails.root.join('log', 'delayed_job.log'))
+Delayed::Worker.logger.level = Logger::DEBUG

+ 236 - 0
config/initializers/devise.rb

@@ -0,0 +1,236 @@
+# Use this hook to configure devise mailer, warden hooks and so forth.
+# Many of these configuration options can be set straight in your model.
+Devise.setup do |config|
+  # ==> Mailer Configuration
+  # Configure the e-mail address which will be shown in Devise::Mailer,
+  # note that it will be overwritten if you use your own mailer class with default "from" parameter.
+  config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com"
+
+  # Configure the class responsible to send e-mails.
+  # config.mailer = "Devise::Mailer"
+
+  # ==> ORM configuration
+  # Load and configure the ORM. Supports :active_record (default) and
+  # :mongoid (bson_ext recommended) by default. Other ORMs may be
+  # available as additional gems.
+  require 'devise/orm/active_record'
+
+  # ==> Configuration for any authentication mechanism
+  # Configure which keys are used when authenticating a user. The default is
+  # just :email. You can configure it to use [:username, :subdomain], so for
+  # authenticating a user, both parameters are required. Remember that those
+  # parameters are used only when authenticating and not when retrieving from
+  # session. If you need permissions, you should implement that in a before filter.
+  # You can also supply a hash where the value is a boolean determining whether
+  # or not authentication should be aborted when the value is not present.
+  config.authentication_keys = [ :login ]
+
+  # Configure parameters from the request object used for authentication. Each entry
+  # given should be a request method and it will automatically be passed to the
+  # find_for_authentication method and considered in your model lookup. For instance,
+  # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
+  # The same considerations mentioned for authentication_keys also apply to request_keys.
+  # config.request_keys = []
+
+  # Configure which authentication keys should be case-insensitive.
+  # These keys will be downcased upon creating or modifying a user and when used
+  # to authenticate or find a user. Default is :email.
+  config.case_insensitive_keys = [ :email ]
+
+  # Configure which authentication keys should have whitespace stripped.
+  # These keys will have whitespace before and after removed upon creating or
+  # modifying a user and when used to authenticate or find a user. Default is :email.
+  config.strip_whitespace_keys = [ :email ]
+
+  # Tell if authentication through request.params is enabled. True by default.
+  # It can be set to an array that will enable params authentication only for the
+  # given strategies, for example, `config.params_authenticatable = [:database]` will
+  # enable it only for database (email + password) authentication.
+  # config.params_authenticatable = true
+
+  # Tell if authentication through HTTP Basic Auth is enabled. False by default.
+  # It can be set to an array that will enable http authentication only for the
+  # given strategies, for example, `config.http_authenticatable = [:token]` will
+  # enable it only for token authentication.
+  # config.http_authenticatable = false
+
+  # If http headers should be returned for AJAX requests. True by default.
+  # config.http_authenticatable_on_xhr = true
+
+  # The realm used in Http Basic Authentication. "Application" by default.
+  # config.http_authentication_realm = "Application"
+
+  # It will change confirmation, password recovery and other workflows
+  # to behave the same regardless if the e-mail provided was right or wrong.
+  # Does not affect registerable.
+  # config.paranoid = true
+
+  # By default Devise will store the user in session. You can skip storage for
+  # :http_auth and :token_auth by adding those symbols to the array below.
+  # Notice that if you are skipping storage for all authentication paths, you
+  # may want to disable generating routes to Devise's sessions controller by
+  # passing :skip => :sessions to `devise_for` in your config/routes.rb
+  config.skip_session_storage = [:http_auth]
+
+  # ==> Configuration for :database_authenticatable
+  # For bcrypt, this is the cost for hashing the password and defaults to 10. If
+  # using other encryptors, it sets how many times you want the password re-encrypted.
+  #
+  # Limiting the stretches to just one in testing will increase the performance of
+  # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use
+  # a value less than 10 in other environments.
+  config.stretches = Rails.env.test? ? 1 : 10
+
+  # Setup a pepper to generate the encrypted password.
+  # config.pepper = "SOME LONG HASH GENERATED WITH rake secret"
+
+  # ==> Configuration for :confirmable
+  # A period that the user is allowed to access the website even without
+  # confirming his account. For instance, if set to 2.days, the user will be
+  # able to access the website for two days without confirming his account,
+  # access will be blocked just in the third day. Default is 0.days, meaning
+  # the user cannot access the website without confirming his account.
+  # config.allow_unconfirmed_access_for = 2.days
+
+  # If true, requires any email changes to be confirmed (exactly the same way as
+  # initial account confirmation) to be applied. Requires additional unconfirmed_email
+  # db field (see migrations). Until confirmed new email is stored in
+  # unconfirmed email column, and copied to email column on successful confirmation.
+  config.reconfirmable = true
+
+  # Defines which key will be used when confirming an account
+  config.confirmation_keys = [ :login ]
+
+  # ==> Configuration for :rememberable
+  # The time the user will be remembered without asking for credentials again.
+  config.remember_for = 4.weeks
+
+  # If true, extends the user's remember period when remembered via cookie.
+  # config.extend_remember_period = false
+
+  # Options to be passed to the created cookie. For instance, you can set
+  # :secure => true in order to force SSL only cookies.
+  if Rails.env.production?
+    config.rememberable_options = { :secure => true }
+  else
+    config.rememberable_options = { }
+  end
+
+  # ==> Configuration for :validatable
+  # Range for password length. Default is 6..128.
+  # config.password_length = 6..128
+
+  # Email regex used to validate email formats. It simply asserts that
+  # an one (and only one) @ exists in the given string. This is mainly
+  # to give user feedback and not to assert the e-mail validity.
+  # config.email_regexp = /\A[^@]+@[^@]+\z/
+
+  # ==> Configuration for :timeoutable
+  # The time you want to timeout the user session without activity. After this
+  # time the user will be asked for credentials again. Default is 30 minutes.
+  # config.timeout_in = 30.minutes
+  
+  # If true, expires auth token on session timeout.
+  # config.expire_auth_token_on_timeout = false
+
+  # ==> Configuration for :lockable
+  # Defines which strategy will be used to lock an account.
+  # :failed_attempts = Locks an account after a number of failed attempts to sign in.
+  # :none            = No lock strategy. You should handle locking by yourself.
+  config.lock_strategy = :failed_attempts
+
+  # Defines which key will be used when locking and unlocking an account
+  config.unlock_keys = [ :email ]
+
+  # Defines which strategy will be used to unlock an account.
+  # :email = Sends an unlock link to the user email
+  # :time  = Re-enables login after a certain amount of time (see :unlock_in below)
+  # :both  = Enables both strategies
+  # :none  = No unlock strategy. You should handle unlocking by yourself.
+  config.unlock_strategy = :both
+
+  # Number of authentication tries before locking an account if lock_strategy
+  # is failed attempts.
+  config.maximum_attempts = 10
+
+  # Time interval to unlock the account if :time is enabled as unlock_strategy.
+  config.unlock_in = 1.hour
+
+  # ==> Configuration for :recoverable
+  #
+  # Defines which key will be used when recovering the password for an account
+  config.reset_password_keys = [ :login ]
+
+  # Time interval you can reset your password with a reset password key.
+  # Don't put a too small interval or your users won't have the time to
+  # change their passwords.
+  config.reset_password_within = 6.hours
+
+  # ==> Configuration for :encryptable
+  # Allow you to use another encryption algorithm besides bcrypt (default). You can use
+  # :sha1, :sha512 or encryptors from others authentication tools as :clearance_sha1,
+  # :authlogic_sha512 (then you should set stretches above to 20 for default behavior)
+  # and :restful_authentication_sha1 (then you should set stretches to 10, and copy
+  # REST_AUTH_SITE_KEY to pepper)
+  # config.encryptor = :sha512
+
+  # ==> Configuration for :token_authenticatable
+  # Defines name of the authentication token params key
+  # config.token_authentication_key = :auth_token
+
+  # ==> Scopes configuration
+  # Turn scoped views on. Before rendering "sessions/new", it will first check for
+  # "users/sessions/new". It's turned off by default because it's slower if you
+  # are using only default views.
+  # config.scoped_views = false
+
+  # Configure the default scope given to Warden. By default it's the first
+  # devise role declared in your routes (usually :user).
+  # config.default_scope = :user
+
+  # Set this configuration to false if you want /users/sign_out to sign out
+  # only the current scope. By default, Devise signs out all scopes.
+  # config.sign_out_all_scopes = true
+
+  # ==> Navigation configuration
+  # Lists the formats that should be treated as navigational. Formats like
+  # :html, should redirect to the sign in page when the user does not have
+  # access, but formats like :xml or :json, should return 401.
+  #
+  # If you have any extra navigational formats, like :iphone or :mobile, you
+  # should add them to the navigational formats lists.
+  #
+  # The "*/*" below is required to match Internet Explorer requests.
+  # config.navigational_formats = ["*/*", :html]
+
+  # The default HTTP method used to sign out a resource. Default is :delete.
+  config.sign_out_via = :get
+
+  # ==> OmniAuth
+  # Add a new OmniAuth provider. Check the wiki for more information on setting
+  # up on your models and hooks.
+  # config.omniauth :github, 'APP_ID', 'APP_SECRET', :scope => 'user,public_repo'
+
+  # ==> Warden configuration
+  # If you want to use other strategies, that are not supported by Devise, or
+  # change the failure app, you can configure them inside the config.warden block.
+  #
+  # config.warden do |manager|
+  #   manager.intercept_401 = false
+  #   manager.default_strategies(:scope => :user).unshift :some_external_strategy
+  # end
+
+  # ==> Mountable engine configurations
+  # When using Devise inside an engine, let's call it `MyEngine`, and this engine
+  # is mountable, there are some extra configurations to be taken into account.
+  # The following options are available, assuming the engine is mounted as:
+  #
+  #     mount MyEngine, at: "/my_engine"
+  #
+  # The router that invoked `devise_for`, in the example above, would be:
+  # config.router_name = :my_engine
+  #
+  # When using omniauth, Devise cannot automatically set Omniauth path,
+  # so you need to do it manually. For the users scope, it would be:
+  # config.omniauth_path_prefix = "/my_engine/users/auth"
+end

+ 15 - 0
config/initializers/inflections.rb

@@ -0,0 +1,15 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format
+# (all these examples are active by default):
+# ActiveSupport::Inflector.inflections do |inflect|
+#   inflect.plural /^(ox)$/i, '\1en'
+#   inflect.singular /^(ox)en/i, '\1'
+#   inflect.irregular 'person', 'people'
+#   inflect.uncountable %w( fish sheep )
+# end
+#
+# These inflection rules are supported but not enabled by default:
+# ActiveSupport::Inflector.inflections do |inflect|
+#   inflect.acronym 'RESTful'
+# end

+ 5 - 0
config/initializers/mime_types.rb

@@ -0,0 +1,5 @@
+# Be sure to restart your server when you modify this file.
+
+# Add new mime types for use in respond_to blocks:
+# Mime::Type.register "text/richtext", :rtf
+# Mime::Type.register_alias "text/html", :iphone

+ 129 - 0
config/initializers/multi_xml_patch.rb

@@ -0,0 +1,129 @@
+# Same vulnerability as CVE-2013-0156
+#   https://groups.google.com/forum/#!topic/rubyonrails-security/61bkgvnSGTQ/discussion
+
+# Code has been submitted back to the project:
+#   https://github.com/sferik/multi_xml/pull/34
+
+# Until the fix is released, use this monkey-patch.
+
+require "multi_xml"
+
+module MultiXml
+  class DisallowedTypeError < StandardError
+    def initialize(type)
+      super "Disallowed type attribute: #{type.inspect}"
+    end
+  end
+
+  DISALLOWED_XML_TYPES = %w(symbol yaml)
+
+  class << self
+    def parse(xml, options={})
+      xml ||= ''
+
+      xml.strip! if xml.respond_to?(:strip!)
+      begin
+        xml = StringIO.new(xml) unless xml.respond_to?(:read)
+
+        char = xml.getc
+        return {} if char.nil?
+        xml.ungetc(char)
+
+        hash = typecast_xml_value(undasherize_keys(parser.parse(xml)), options[:disallowed_types]) || {}
+      rescue DisallowedTypeError
+        raise
+      rescue parser.parse_error => error
+        raise ParseError, error.to_s, error.backtrace
+      end
+      hash = symbolize_keys(hash) if options[:symbolize_keys]
+      hash
+    end
+
+    private
+
+    def typecast_xml_value(value, disallowed_types=nil)
+      disallowed_types ||= DISALLOWED_XML_TYPES
+
+      case value
+      when Hash
+        if value.include?('type') && !value['type'].is_a?(Hash) && disallowed_types.include?(value['type'])
+          raise DisallowedTypeError, value['type']
+        end
+
+        if value['type'] == 'array'
+
+          # this commented-out suggestion helps to avoid the multiple attribute
+          # problem, but it breaks when there is only one item in the array.
+          #
+          # from: https://github.com/jnunemaker/httparty/issues/102
+          #
+          # _, entries = value.detect { |k, v| k != 'type' && v.is_a?(Array) }
+
+          # This attempt fails to consider the order that the detect method
+          # retrieves the entries.
+          #_, entries = value.detect {|key, _| key != 'type'}
+
+          # This approach ignores attribute entries that are not convertable
+          # to an Array which allows attributes to be ignored.
+          _, entries = value.detect {|k, v| k != 'type' && (v.is_a?(Array) || v.is_a?(Hash)) }
+
+          if entries.nil? || (entries.is_a?(String) && entries.strip.empty?)
+            []
+          else
+            case entries
+            when Array
+              entries.map {|entry| typecast_xml_value(entry, disallowed_types)}
+            when Hash
+              [typecast_xml_value(entries, disallowed_types)]
+            else
+              raise "can't typecast #{entries.class.name}: #{entries.inspect}"
+            end
+          end
+        elsif value.has_key?(CONTENT_ROOT)
+          content = value[CONTENT_ROOT]
+          if block = PARSING[value['type']]
+            if block.arity == 1
+              value.delete('type') if PARSING[value['type']]
+              if value.keys.size > 1
+                value[CONTENT_ROOT] = block.call(content)
+                value
+              else
+                block.call(content)
+              end
+            else
+              block.call(content, value)
+            end
+          else
+            value.keys.size > 1 ? value : content
+          end
+        elsif value['type'] == 'string' && value['nil'] != 'true'
+          ''
+        # blank or nil parsed values are represented by nil
+        elsif value.empty? || value['nil'] == 'true'
+          nil
+        # If the type is the only element which makes it then
+        # this still makes the value nil, except if type is
+        # a XML node(where type['value'] is a Hash)
+        elsif value['type'] && value.size == 1 && !value['type'].is_a?(Hash)
+          nil
+        else
+          xml_value = value.inject({}) do |hash, (k, v)|
+            hash[k] = typecast_xml_value(v, disallowed_types)
+            hash
+          end
+
+          # Turn {:files => {:file => #<StringIO>} into {:files => #<StringIO>} so it is compatible with
+          # how multipart uploaded files from HTML appear
+          xml_value['file'].is_a?(StringIO) ? xml_value['file'] : xml_value
+        end
+      when Array
+        value.map!{|i| typecast_xml_value(i, disallowed_types)}
+        value.length > 1 ? value : value.first
+      when String
+        value
+      else
+        raise "can't typecast #{value.class.name}: #{value.inspect}"
+      end
+    end
+  end
+end

+ 121 - 0
config/initializers/rails_admin.rb

@@ -0,0 +1,121 @@
+# RailsAdmin config file. Generated on July 28, 2012 14:28
+# See github.com/sferik/rails_admin for more informations
+
+RailsAdmin.config do |config|
+
+  # If your default_local is different from :en, uncomment the following 2 lines and set your default locale here:
+  # require 'i18n'
+  # I18n.default_locale = :de
+
+  config.current_user_method { current_user } # auto-generated
+
+  # If you want to track changes on your models:
+  config.audit_with :history, User
+
+  # Or with a PaperTrail: (you need to install it first)
+  # config.audit_with :paper_trail, User
+
+  # Set the admin name here (optional second array element will appear in a beautiful RailsAdmin red ©)
+  config.main_app_name = ['Huginn', 'Admin']
+  # or for a dynamic name:
+  # config.main_app_name = Proc.new { |controller| [Rails.application.engine_name.titleize, controller.params['action'].titleize] }
+
+  config.authenticate_with do
+    authenticate_user!
+  end
+
+  config.authorize_with do
+    redirect_to "/" unless warden.user.admin?
+  end
+
+  config.attr_accessible_role do
+    if _current_user.admin?
+      :admin
+    else
+      :default
+    end
+  end
+
+  #  ==> Global show view settings
+  # Display empty fields in show views
+  # config.compact_show_view = false
+
+  #  ==> Global list view settings
+  # Number of default rows per-page:
+  # config.default_items_per_page = 20
+
+  #  ==> Included models
+  # Add all excluded models here:
+  # config.excluded_models = [User]
+
+  # Add models here if you want to go 'whitelist mode':
+  # config.included_models = [User]
+
+  # Application wide tried label methods for models' instances
+  # config.label_methods << :description # Default is [:name, :title]
+
+  #  ==> Global models configuration
+  # config.models do
+  #   # Configuration here will affect all included models in all scopes, handle with care!
+  #
+  #   list do
+  #     # Configuration here will affect all included models in list sections (same for show, export, edit, update, create)
+  #
+  #     fields_of_type :date do
+  #       # Configuration here will affect all date fields, in the list section, for all included models. See README for a comprehensive type list.
+  #     end
+  #   end
+  # end
+  #
+  #  ==> Model specific configuration
+  # Keep in mind that *all* configuration blocks are optional.
+  # RailsAdmin will try his best to provide the best defaults for each section, for each field.
+  # Try to override as few things as possible, in the most generic way. Try to avoid setting labels for models and attributes, use ActiveRecord I18n API instead.
+  # Less code is better code!
+  # config.model MyModel do
+  #   # Cross-section field configuration
+  #   object_label_method :name     # Name of the method called for pretty printing an *instance* of ModelName
+  #   label 'My model'              # Name of ModelName (smartly defaults to ActiveRecord's I18n API)
+  #   label_plural 'My models'      # Same, plural
+  #   weight -1                     # Navigation priority. Bigger is higher.
+  #   parent OtherModel             # Set parent model for navigation. MyModel will be nested below. OtherModel will be on first position of the dropdown
+  #   navigation_label              # Sets dropdown entry's name in navigation. Only for parents!
+  #   # Section specific configuration:
+  #   list do
+  #     filters [:id, :name]  # Array of field names which filters should be shown by default in the table header
+  #     items_per_page 100    # Override default_items_per_page
+  #     sort_by :id           # Sort column (default is primary key)
+  #     sort_reverse true     # Sort direction (default is true for primary key, last created first)
+  #     # Here goes the fields configuration for the list view
+  #   end
+  # end
+
+  # Your model's configuration, to help you get started:
+
+  # All fields marked as 'hidden' won't be shown anywhere in the rails_admin unless you mark them as visible. (visible(true))
+
+  # config.model User do
+  #   # Found associations:
+  #   # Found columns:
+  #     configure :id, :integer
+  #     configure :email, :string
+  #     configure :password, :password         # Hidden
+  #     configure :password_confirmation, :password         # Hidden
+  #     configure :reset_password_token, :string         # Hidden
+  #     configure :reset_password_sent_at, :datetime
+  #     configure :remember_created_at, :datetime
+  #     configure :sign_in_count, :integer
+  #     configure :current_sign_in_at, :datetime
+  #     configure :last_sign_in_at, :datetime
+  #     configure :current_sign_in_ip, :string
+  #     configure :last_sign_in_ip, :string
+  #     configure :created_at, :datetime
+  #     configure :updated_at, :datetime   #   # Sections:
+  #   list do; end
+  #   export do; end
+  #   show do; end
+  #   edit do; end
+  #   create do; end
+  #   update do; end
+  # end
+end

+ 7 - 0
config/initializers/recursively_symbolize_keys.rb

@@ -0,0 +1,7 @@
+require 'utils'
+
+class Hash
+  def recursively_symbolize_keys
+    Utils.recursively_symbolize_keys self
+  end
+end

+ 1 - 0
config/initializers/requires.rb

@@ -0,0 +1 @@
+require 'pp'

+ 7 - 0
config/initializers/secret_token.rb

@@ -0,0 +1,7 @@
+# Be sure to restart your server when you modify this file.
+
+# Your secret key for verifying the integrity of signed cookies.
+# If you change this key, all old signed cookies will become invalid!
+# Make sure the secret is at least 30 characters and all random,
+# no regular words or you'll be exposed to dictionary attacks.
+Huginn::Application.config.secret_token = 'REPLACE_ME_NOW!'

+ 8 - 0
config/initializers/session_store.rb

@@ -0,0 +1,8 @@
+# Be sure to restart your server when you modify this file.
+
+Huginn::Application.config.session_store :cookie_store, key: '_rails_session'
+
+# Use the database for sessions instead of the cookie-based default,
+# which shouldn't be used to store highly confidential information
+# (create the session table with "rails generate session_migration")
+# Huginn::Application.config.session_store :active_record_store

+ 14 - 0
config/initializers/wrap_parameters.rb

@@ -0,0 +1,14 @@
+# Be sure to restart your server when you modify this file.
+#
+# This file contains settings for ActionController::ParamsWrapper which
+# is enabled by default.
+
+# Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
+ActiveSupport.on_load(:action_controller) do
+  wrap_parameters format: [:json]
+end
+
+# Disable root element in JSON by default.
+ActiveSupport.on_load(:active_record) do
+  self.include_root_in_json = false
+end

+ 58 - 0
config/locales/devise.en.yml

@@ -0,0 +1,58 @@
+# Additional translations at https://github.com/plataformatec/devise/wiki/I18n
+
+en:
+  errors:
+    messages:
+      expired: "has expired, please request a new one"
+      not_found: "not found"
+      already_confirmed: "was already confirmed, please try signing in"
+      not_locked: "was not locked"
+      not_saved:
+        one: "1 error prohibited this %{resource} from being saved:"
+        other: "%{count} errors prohibited this %{resource} from being saved:"
+
+  devise:
+    failure:
+      already_authenticated: 'You are already signed in.'
+      unauthenticated: 'You need to sign in or sign up before continuing.'
+      unconfirmed: 'You have to confirm your account before continuing.'
+      locked: 'Your account is locked.'
+      invalid: 'Invalid login or password.'
+      invalid_token: 'Invalid authentication token.'
+      timeout: 'Your session expired, please sign in again to continue.'
+      inactive: 'Your account was not activated yet.'
+    sessions:
+      signed_in: 'Signed in successfully.'
+      signed_out: 'Signed out successfully.'
+    passwords:
+      send_instructions: 'You will receive an email with instructions about how to reset your password in a few minutes.'
+      updated: 'Your password was changed successfully. You are now signed in.'
+      updated_not_active: 'Your password was changed successfully.'
+      send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
+      no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
+    confirmations:
+      send_instructions: 'You will receive an email with instructions about how to confirm your account in a few minutes.'
+      send_paranoid_instructions: 'If your email address exists in our database, you will receive an email with instructions about how to confirm your account in a few minutes.'
+      confirmed: 'Your account was successfully confirmed. You are now signed in.'
+    registrations:
+      signed_up: 'Welcome! You have signed up successfully.'
+      signed_up_but_unconfirmed: 'A message with a confirmation link has been sent to your email address. Please open the link to activate your account.'
+      signed_up_but_inactive: 'You have signed up successfully. However, we could not sign you in because your account is not yet activated.'
+      signed_up_but_locked: 'You have signed up successfully. However, we could not sign you in because your account is locked.'
+      updated: 'You updated your account successfully.'
+      update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and click on the confirm link to finalize confirming your new email address."
+      destroyed: 'Bye! Your account was successfully cancelled. We hope to see you again soon.'
+    unlocks:
+      send_instructions: 'You will receive an email with instructions about how to unlock your account in a few minutes.'
+      unlocked: 'Your account has been unlocked successfully. Please sign in to continue.'
+      send_paranoid_instructions: 'If your account exists, you will receive an email with instructions about how to unlock it in a few minutes.'
+    omniauth_callbacks:
+      success: 'Successfully authenticated from %{kind} account.'
+      failure: 'Could not authenticate you from %{kind} because "%{reason}".'
+    mailer:
+      confirmation_instructions:
+        subject: 'Confirmation instructions'
+      reset_password_instructions:
+        subject: 'Reset password instructions'
+      unlock_instructions:
+        subject: 'Unlock Instructions'

+ 40 - 0
config/locales/en.yml

@@ -0,0 +1,40 @@
+# Sample localization file for English. Add more files in this directory for other locales.
+# See https://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points.
+
+en:
+  datetime:
+    distance_in_words:
+      half_a_minute: "half a minute"
+      less_than_x_seconds:
+        one:   "<1s"
+        other: "<%{count}s"
+      x_seconds:
+        one:   "1s"
+        other: "%{count}s"
+      less_than_x_minutes:
+        one:   "<1m"
+        other: "<%{count}m"
+      x_minutes:
+        one:   "1m"
+        other: "%{count}m"
+      about_x_hours:
+        one:   "~1h"
+        other: "~%{count}h"
+      x_days:
+        one:   "1d"
+        other: "%{count}d"
+      about_x_months:
+        one:   "~1mo"
+        other: "~%{count}mo"
+      x_months:
+        one:   "1mo"
+        other: "%{count}mo"
+      about_x_years:
+        one:   "~1yr"
+        other: "~%{count}yr"
+      over_x_years:
+        one:   ">1yr"
+        other: ">%{count}yr"
+      almost_x_years:
+        one:   "~1yr"
+        other: "~%{count}yr"

+ 67 - 0
config/nginx/production.conf

@@ -0,0 +1,67 @@
+upstream huginn_app_server {
+    # fail_timeout=0 means we always retry an upstream even if it failed
+    # to return a good HTTP response (in case the Unicorn master nukes a
+    # single worker for timing out).
+
+    # for UNIX domain socket setups:
+    server unix:/home/you/app/shared/pids/unicorn.socket;
+}
+
+server {
+    listen 80;
+    server_name your-domain.com;
+    rewrite ^(.*) https://www.your-domain.com$1 permanent;
+}
+
+server {
+    listen 80;
+    server_name www.your-domain.com;
+    rewrite ^(.*) https://www.your-domain.com$1 permanent;
+}
+
+server {
+    listen 443;
+    server_name your-domain.com;
+
+    ssl on;
+    ssl_certificate /etc/nginx/ssl-certs/your-domain.com.crt;
+    ssl_certificate_key /etc/nginx/ssl-certs/your-domain.com.key.nopass;  
+
+    rewrite ^(.*) https://www.your-domain.com$1 permanent;
+}
+
+server {
+    listen 443;
+
+    ssl on;
+    ssl_certificate /etc/nginx/ssl-certs/your-domain.com.crt;
+    ssl_certificate_key /etc/nginx/ssl-certs/your-domain.com.key.nopass;  
+  
+    client_max_body_size 4G;
+    server_name www.your-domain.com;
+
+    keepalive_timeout 5;
+
+    # path for static files
+    root /home/you/app/current/public;
+
+    try_files $uri/index.html $uri.html $uri @app;
+
+    # Rails error pages
+    error_page 500 502 503 504 /500.html;
+    location = /500.html {
+      root /home/you/app/current/public;
+    }
+
+    location @app {
+      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+
+      proxy_set_header X-Forwarded-Proto $scheme;
+
+      proxy_set_header Host $http_host;
+
+      proxy_redirect off;
+
+      proxy_pass http://huginn_app_server;
+    }
+}

+ 26 - 0
config/routes.rb

@@ -0,0 +1,26 @@
+Huginn::Application.routes.draw do
+  resources :agents do
+    member do
+      post :run
+      delete :remove_events
+    end
+
+    collection do
+      post :propagate
+      get :type_details
+      get :event_descriptions
+      get :diagram
+    end
+  end
+  resources :events, :only => [:index, :show, :destroy]
+  match "/worker_status" => "worker_status#show"
+
+  post "/users/:user_id/update_location/:secret" => "user_location_updates#create"
+
+  mount RailsAdmin::Engine => '/admin', :as => 'rails_admin'
+#  match "/delayed_job" => DelayedJobWeb, :anchor => false
+  devise_for :users, :sign_out_via => [ :post, :delete ]
+
+  match "/about" => "home#about"
+  root :to => "home#index"
+end

+ 33 - 0
config/unicorn/production.rb

@@ -0,0 +1,33 @@
+app_path = "/home/you/app/current"
+
+worker_processes 2
+preload_app true
+timeout 180
+listen '/home/you/app/shared/pids/unicorn.socket'
+
+working_directory app_path
+
+rails_env = ENV['RAILS_ENV'] || 'production'
+
+# Log everything to one file
+stderr_path "log/unicorn.log"
+stdout_path "log/unicorn.log"
+
+# Set master PID location
+pid '/home/you/app/shared/pids/unicorn.pid'
+
+before_fork do |server, worker|
+  ActiveRecord::Base.connection.disconnect!
+  old_pid = "#{server.config[:pid]}.oldbin"
+  if File.exists?(old_pid) && server.pid != old_pid
+    begin
+      Process.kill("QUIT", File.read(old_pid).to_i)
+    rescue Errno::ENOENT, Errno::ESRCH
+      # someone else did our job for us
+    end
+  end
+end
+
+after_fork do |server, worker|
+  ActiveRecord::Base.establish_connection
+end

+ 46 - 0
db/migrate/20120728210244_devise_create_users.rb

@@ -0,0 +1,46 @@
+class DeviseCreateUsers < ActiveRecord::Migration
+  def change
+    create_table(:users) do |t|
+      ## Database authenticatable
+      t.string :email,              :null => false, :default => ""
+      t.string :encrypted_password, :null => false, :default => ""
+
+      ## Recoverable
+      t.string   :reset_password_token
+      t.datetime :reset_password_sent_at
+
+      ## Rememberable
+      t.datetime :remember_created_at
+
+      ## Trackable
+      t.integer  :sign_in_count, :default => 0
+      t.datetime :current_sign_in_at
+      t.datetime :last_sign_in_at
+      t.string   :current_sign_in_ip
+      t.string   :last_sign_in_ip
+
+      ## Confirmable
+      # t.string   :confirmation_token
+      # t.datetime :confirmed_at
+      # t.datetime :confirmation_sent_at
+      # t.string   :unconfirmed_email # Only if using reconfirmable
+
+      ## Lockable
+      # t.integer  :failed_attempts, :default => 0 # Only if lock strategy is :failed_attempts
+      # t.string   :unlock_token # Only if unlock strategy is :email or :both
+      # t.datetime :locked_at
+
+      ## Token authenticatable
+      # t.string :authentication_token
+
+
+      t.timestamps
+    end
+
+    add_index :users, :email,                :unique => true
+    add_index :users, :reset_password_token, :unique => true
+    # add_index :users, :confirmation_token,   :unique => true
+    # add_index :users, :unlock_token,         :unique => true
+    # add_index :users, :authentication_token, :unique => true
+  end
+end

+ 18 - 0
db/migrate/20120728212820_create_rails_admin_histories_table.rb

@@ -0,0 +1,18 @@
+class CreateRailsAdminHistoriesTable < ActiveRecord::Migration
+   def self.up
+     create_table :rails_admin_histories do |t|
+       t.text :message # title, name, or object_id
+       t.string :username
+       t.integer :item
+       t.string :table
+       t.integer :month, :limit => 2
+       t.integer :year, :limit => 5
+       t.timestamps
+    end
+    add_index(:rails_admin_histories, [:item, :table, :month, :year], :name => 'index_rails_admin_histories' )
+  end
+
+  def self.down
+    drop_table :rails_admin_histories
+  end
+end

+ 5 - 0
db/migrate/20120728215449_add_admin_to_users.rb

@@ -0,0 +1,5 @@
+class AddAdminToUsers < ActiveRecord::Migration
+  def change
+    add_column :users, :admin, :boolean, :default => false, :null => false
+  end
+end

Some files were not shown because too many files changed in this diff