conversational 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. data/.gitignore +8 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.markdown +239 -0
  4. data/Rakefile +38 -0
  5. data/VERSION +1 -0
  6. data/conversational.gemspec +78 -0
  7. data/features/configure_blank_topic.feature +9 -0
  8. data/features/configure_exclusion_conversations.feature +20 -0
  9. data/features/configure_unknown_topic.feature +9 -0
  10. data/features/find_existing_conversation.feature +21 -0
  11. data/features/find_or_create_with.feature +33 -0
  12. data/features/retrieve_conversation_details.feature +13 -0
  13. data/features/step_definitions/conversation_steps.rb +60 -0
  14. data/features/step_definitions/pickle_steps.rb +87 -0
  15. data/features/support/conversation.rb +2 -0
  16. data/features/support/email_spec.rb +1 -0
  17. data/features/support/env.rb +58 -0
  18. data/features/support/mail.rb +6 -0
  19. data/features/support/paths.rb +33 -0
  20. data/features/support/pickle.rb +24 -0
  21. data/features/support/sample_conversation.rb +3 -0
  22. data/lib/conversational/active_record_additions.rb +121 -0
  23. data/lib/conversational/conversation.rb +188 -0
  24. data/lib/conversational/conversation_definition.rb +86 -0
  25. data/lib/conversational.rb +4 -0
  26. data/lib/generators/conversational/migration/USAGE +5 -0
  27. data/lib/generators/conversational/migration/migration_generator.rb +23 -0
  28. data/lib/generators/conversational/migration/templates/migration.rb +14 -0
  29. data/lib/generators/conversational/skeleton/USAGE +6 -0
  30. data/lib/generators/conversational/skeleton/skeleton_generator.rb +17 -0
  31. data/lib/generators/conversational/skeleton/templates/conversation.rb +3 -0
  32. data/spec/active_record_additions_spec.rb +120 -0
  33. data/spec/conversation_definition_spec.rb +248 -0
  34. data/spec/conversation_spec.rb +34 -0
  35. data/spec/spec_helper.rb +24 -0
  36. data/spec/support/conversation.rb +3 -0
  37. metadata +101 -0
@@ -0,0 +1,58 @@
1
+ # IMPORTANT: This file is generated by cucumber-rails - edit at your own peril.
2
+ # It is recommended to regenerate this file in the future when you upgrade to a
3
+ # newer version of cucumber-rails. Consider adding your own code to a new file
4
+ # instead of editing this one. Cucumber will automatically load all features/**/*.rb
5
+ # files.
6
+
7
+ ENV["RAILS_ENV"] ||= "test"
8
+ require File.expand_path(File.dirname(__FILE__) + '/../../config/environment')
9
+
10
+ require 'cucumber/formatter/unicode' # Remove this line if you don't want Cucumber Unicode support
11
+ require 'cucumber/rails/rspec'
12
+ require 'cucumber/rails/world'
13
+ require 'cucumber/rails/active_record'
14
+ require 'cucumber/web/tableish'
15
+
16
+ require 'capybara/rails'
17
+ require 'capybara/cucumber'
18
+ require 'capybara/session'
19
+ require 'cucumber/rails/capybara_javascript_emulation' # Lets you click links with onclick javascript handlers without using @culerity or @javascript
20
+ # Capybara defaults to XPath selectors rather than Webrat's default of CSS3. In
21
+ # order to ease the transition to Capybara we set the default here. If you'd
22
+ # prefer to use XPath just remove this line and adjust any selectors in your
23
+ # steps to use the XPath syntax.
24
+ Capybara.default_selector = :css
25
+
26
+ # If you set this to false, any error raised from within your app will bubble
27
+ # up to your step definition and out to cucumber unless you catch it somewhere
28
+ # on the way. You can make Rails rescue errors and render error pages on a
29
+ # per-scenario basis by tagging a scenario or feature with the @allow-rescue tag.
30
+ #
31
+ # If you set this to true, Rails will rescue all errors and render error
32
+ # pages, more or less in the same way your application would behave in the
33
+ # default production environment. It's not recommended to do this for all
34
+ # of your scenarios, as this makes it hard to discover errors in your application.
35
+ ActionController::Base.allow_rescue = false
36
+
37
+ # If you set this to true, each scenario will run in a database transaction.
38
+ # You can still turn off transactions on a per-scenario basis, simply tagging
39
+ # a feature or scenario with the @no-txn tag. If you are using Capybara,
40
+ # tagging with @culerity or @javascript will also turn transactions off.
41
+ #
42
+ # If you set this to false, transactions will be off for all scenarios,
43
+ # regardless of whether you use @no-txn or not.
44
+ #
45
+ # Beware that turning transactions off will leave data in your database
46
+ # after each scenario, which can lead to hard-to-debug failures in
47
+ # subsequent scenarios. If you do this, we recommend you create a Before
48
+ # block that will explicitly put your database in a known state.
49
+ Cucumber::Rails::World.use_transactional_fixtures = true
50
+ # How to clean your database when transactions are turned off. See
51
+ # http://github.com/bmabey/database_cleaner for more info.
52
+ if defined?(ActiveRecord::Base)
53
+ begin
54
+ require 'database_cleaner'
55
+ DatabaseCleaner.strategy = :truncation
56
+ rescue LoadError => ignore_if_database_cleaner_not_present
57
+ end
58
+ end
@@ -0,0 +1,6 @@
1
+ require 'mail'
2
+ Mail.defaults do
3
+ delivery_method :test
4
+ end
5
+
6
+
@@ -0,0 +1,33 @@
1
+ module NavigationHelpers
2
+ # Maps a name to a path. Used by the
3
+ #
4
+ # When /^I go to (.+)$/ do |page_name|
5
+ #
6
+ # step definition in web_steps.rb
7
+ #
8
+ def path_to(page_name)
9
+ case page_name
10
+
11
+ when /the home\s?page/
12
+ '/'
13
+
14
+ # Add more mappings here.
15
+ # Here is an example that pulls values out of the Regexp:
16
+ #
17
+ # when /^(.*)'s profile page$/i
18
+ # user_profile_path(User.find_by_login($1))
19
+
20
+ else
21
+ begin
22
+ page_name =~ /the (.*) page/
23
+ path_components = $1.split(/\s+/)
24
+ self.send(path_components.push('path').join('_').to_sym)
25
+ rescue Object => e
26
+ raise "Can't find mapping from \"#{page_name}\" to a path.\n" +
27
+ "Now, go and add a mapping in #{__FILE__}"
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ World(NavigationHelpers)
@@ -0,0 +1,24 @@
1
+ # this file generated by script/generate pickle [paths] [email]
2
+ #
3
+ # Make sure that you are loading your factory of choice in your cucumber environment
4
+ #
5
+ # For machinist add: features/support/machinist.rb
6
+ #
7
+ # require 'machinist/active_record' # or your chosen adaptor
8
+ # require File.dirname(__FILE__) + '/../../spec/blueprints' # or wherever your blueprints are
9
+ # Before { Sham.reset } # to reset Sham's seed between scenarios so each run has same random sequences
10
+ #
11
+ # For FactoryGirl add: features/support/factory_girl.rb
12
+ #
13
+ # require 'factory_girl'
14
+ # require File.dirname(__FILE__) + '/../../spec/factories' # or wherever your factories are
15
+ #
16
+ # You may also need to add gem dependencies on your factory of choice in <tt>config/environments/cucumber.rb</tt>
17
+
18
+ require 'pickle/world'
19
+ # Example of configuring pickle:
20
+ #
21
+ # Pickle.configure do |config|
22
+ # config.adapters = [:machinist]
23
+ # config.map 'I', 'myself', 'me', 'my', :to => 'user: "me"'
24
+ # end
@@ -0,0 +1,3 @@
1
+ class SampleConversation < Conversation
2
+ end
3
+
@@ -0,0 +1,121 @@
1
+ module Conversational
2
+ module ActiveRecordAdditions
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ end
6
+
7
+ module ClassMethods
8
+ attr_accessor :finishing_keywords
9
+
10
+ def converser(with)
11
+ scoped.where("with = ?", with)
12
+ end
13
+
14
+ def in_progress
15
+ scoped.where("state != ? OR state IS NULL", "finished")
16
+ end
17
+
18
+ def recent(time = nil)
19
+ time ||= 24.hours.ago
20
+ scoped.where("updated_at > ?", time)
21
+ end
22
+
23
+ def with(with)
24
+ scoped.converser(with).in_progress.recent
25
+ end
26
+
27
+ # Finds an existing conversation with using the defaults or
28
+ # creates a new conversation and returns the specific conversation based
29
+ # on the conversation topic.
30
+ # Example:
31
+ #
32
+ # <tt>
33
+ # Class Conversation < ActiveRecord::Base
34
+ # include Conversational::Conversation
35
+ # end
36
+ #
37
+ # Class HelloConversation < Conversation
38
+ # end
39
+ #
40
+ # Class GoodbyeConversation < Conversation
41
+ # end
42
+ #
43
+ # Conversation.create!("someone", "hello")
44
+ # existing_conversation = Conversation.find_or_create_with(
45
+ # "someone",
46
+ # "goodbye"
47
+ # ) => #<HelloConversation topic: "hello", with: "someone">
48
+ #
49
+ # Conversation.exclude HelloConversation
50
+ #
51
+ # existing_conversation = Conversation.find_or_create_with(
52
+ # "someone",
53
+ # "goodbye"
54
+ # ) => #<HelloConversation topic: "hello", with: "someone">
55
+ #
56
+ # existing_conversation.destroy
57
+ #
58
+ # non_existing_conversation = Conversation.find_or_create_with(
59
+ # "someone",
60
+ # "goodbye"
61
+ # ) => #<GoodbyeConversation topic: "goodbye", with: "someone">
62
+ #
63
+ # non_existing_conversation.destroy
64
+ #
65
+ # Conversation.exclude GoodbyeConversation
66
+ #
67
+ # non_existing_conversation = Conversation.find_or_create_with(
68
+ # "someone",
69
+ # "goodbye"
70
+ # ) => BOOM! (Raises Error)
71
+ #
72
+ # unknown_conversation = Conversation.find_or_create_with(
73
+ # "someone",
74
+ # "cheese"
75
+ # ) => BOOM! (Raises Error)
76
+ #
77
+ # Conversation.unknown_topic_subclass = HelloConversation
78
+ #
79
+ # unknown_conversation = Conversation.find_or_create_with(
80
+ # "someone",
81
+ # "cheese"
82
+ # ) => #<HelloConversation topic: "hello", with: "someone">
83
+ #
84
+ # unknown_conversation.destroy
85
+ #
86
+ # blank_conversation = Conversation.find_or_create_with(
87
+ # "someone"
88
+ # ) => BOOM! (Raises Error)
89
+ #
90
+ # Conversation.blank_topic_subclass = GoodbyeConversation
91
+ #
92
+ # blank_conversation = Conversation.find_or_create_with(
93
+ # "someone"
94
+ # ) => #<GoodbyeConversation topic: "goodbye", with: "someone">
95
+ #
96
+ # </tt>
97
+ def find_or_create_with(with, topic)
98
+ if default_find = self.with(with).last
99
+ default_find.details(:include_all => true)
100
+ else
101
+ subclass = ConversationDefinition.find_subclass_by_topic(topic)
102
+ if subclass.nil?
103
+ if topic && !topic.blank?
104
+ subclass_name = ConversationDefinition.topic_subclass_name(topic)
105
+ raise(
106
+ ArgumentError,
107
+ "You have either not defined #{subclass_name} it does not subclass #{self.to_s}, or it has been excluded. You can either define #{subclass_name} as a subclass of #{self.to_s} or define an unknown_topic_subclass for #{self.to_s}"
108
+ )
109
+ else
110
+ raise(
111
+ ArgumentError,
112
+ "You have not defined a blank_topic_subclass for #{self.to_s} so conversations without a topic are not allowed."
113
+ )
114
+ end
115
+ end
116
+ subclass.create!(:with => with, :topic => topic)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,188 @@
1
+ module Conversational
2
+ module Conversation
3
+ def self.included(base)
4
+ base.extend ClassMethods
5
+ ConversationDefinition.klass = base
6
+ if defined?(ActiveRecord::Base) && base <= ActiveRecord::Base
7
+ base.send(:include, ActiveRecordAdditions)
8
+ else
9
+ base.send(:include, InstanceAttributes)
10
+ end
11
+ end
12
+
13
+ # Returns the specific sublass of conversation based from the topic
14
+ # Example:
15
+ #
16
+ # <tt>
17
+ # Class Conversation
18
+ # include Conversational::Conversation
19
+ # end
20
+ #
21
+ # Class HelloConversation < Conversation
22
+ # end
23
+ #
24
+ # Class GoodbyeConversation < Conversation
25
+ # end
26
+ #
27
+ # hello = Conversation.new("someone", "hello")
28
+ # hello.details => #<HelloConversation topic: "hello", with: "someone">
29
+ #
30
+ # unknown = Conversation.new("someone", "cheese")
31
+ # unknown.details => nil
32
+ #
33
+ # Conversation.unknown_topic_subclass = HelloConversation
34
+ #
35
+ # unknown = Conversation.new("someone", "cheese")
36
+ # unknown.details => #<HelloConversation topic: "cheese", with: "someone">
37
+ #
38
+ # blank = Conversation.new("someone")
39
+ # blank.details => nil
40
+ #
41
+ # Conversation.blank_topic_subclass = GoodbyeConversation
42
+ #
43
+ # blank = Conversation.new("someone")
44
+ # blank.details => #<GoodbyeConversation topic: nil, with: "someone">
45
+ #
46
+ # Conversation.exclude HelloConversation
47
+ #
48
+ # hello = Conversation.new("someone", "hello")
49
+ # hello.details => nil
50
+ #
51
+ # hello.details(:include_all => true) => #<HelloConversation topic: "hello", with: "someone">
52
+ #
53
+ # </tt>
54
+ def details(options = {})
55
+ details_subclass = ConversationDefinition.find_subclass_by_topic(
56
+ topic, options
57
+ )
58
+ self.becomes(details_subclass) if details_subclass
59
+ end
60
+
61
+ protected
62
+ # Called from a subclass to deliver the message
63
+ def say(message)
64
+ ConversationDefinition.notification.call(with, message)
65
+ end
66
+
67
+ module InstanceAttributes
68
+ attr_accessor :with, :topic
69
+
70
+ def initialize(options = {})
71
+ self.with = options[:with]
72
+ self.topic = options[:topic]
73
+ end
74
+
75
+ end
76
+
77
+ module ClassMethods
78
+ def unknown_topic_subclass=(klass)
79
+ ConversationDefinition.unknown_topic_subclass = klass
80
+ end
81
+
82
+ def unknown_topic_subclass
83
+ ConversationDefinition.unknown_topic_subclass
84
+ end
85
+
86
+ def blank_topic_subclass=(klass)
87
+ ConversationDefinition.blank_topic_subclass = klass
88
+ end
89
+
90
+ def blank_topic_subclass
91
+ ConversationDefinition.blank_topic_subclass
92
+ end
93
+
94
+ # Register a service for sending notifications
95
+ #
96
+ # Example:
97
+ #
98
+ # <tt>
99
+ # Conversation.converse do |with, message|
100
+ # OutgoingTextMessage.create!(with, message).send
101
+ # end
102
+ # </tt>
103
+ def converse(&blk)
104
+ ConversationDefinition.notification = blk
105
+ end
106
+
107
+ # Register classes which will not be treated as conversations
108
+ # when you use #details or Conversation.find_or_create_with
109
+ #
110
+ # Example:
111
+ #
112
+ # Consider the following situation where you define AbstractConversation
113
+ # that MonkeyConversations inherits from. You want to work with MonkeyConversation
114
+ # but you never want to work with AbstractConversation directly.
115
+ #
116
+ # <tt>
117
+ # class AbstractConversation < Conversation
118
+ # end
119
+ #
120
+ # class MonkeyConversation < AbstractConversation
121
+ # end
122
+ #
123
+ # class UnknownTopicConversation < AbstractConversation
124
+ # end
125
+ #
126
+ # class IncomingTextMessage < ActiveRecord::Base
127
+ # end
128
+ #
129
+ # class IncomingTextMessagesController < ApplicationController
130
+ # def create
131
+ # IncomingTextMessage.create(params[:message_text], params[:number])
132
+ # end
133
+ # end
134
+ # </tt>
135
+ #
136
+ # Now what happens when a user sends in a message like "monkey"
137
+ # <tt>
138
+ # message = IncomingTextMessage.last
139
+ # topic = message.text.split(" ").first
140
+ # => "monkey"
141
+ # number = message.number
142
+ # => "123456789"
143
+ # conversation = Conversation.new(:with => number, :topic => topic).details
144
+ # => #<MonkeyConversation topic: "monkey", with: "123456789">
145
+ # </tt>
146
+
147
+ # No problem here you will work with MonkeyConversation directly. But what if
148
+ # the user sends in a message like "abstract"?
149
+ # <tt>
150
+ # message = IncomingTextMessage.last
151
+ # topic = message.text.split(" ").first
152
+ # => "abstract"
153
+ # number = message.number
154
+ # => "123456789"
155
+ # conversation = Conversation.new(:with => number, :topic => topic).details
156
+ # => #<AbstractConversation topic: "abstract", with: "123456789">
157
+ # </tt>
158
+ # Now you're are about to work with AbstractConversation directly
159
+ # which is not what you want. Let's fix it
160
+ # <tt>
161
+ # # config/initializers/conversation.rb
162
+ # Conversation.exclude AbstractConversation
163
+ # Conversation.unknown_topic_subclass = UnkownTopicConversation
164
+ # </tt>
165
+ #
166
+ # <tt>
167
+ # message = IncomingTextMessage.last
168
+ # topic = message.text.split(" ").first
169
+ # => "abstract"
170
+ # number = message.number
171
+ # => "123456789"
172
+ # conversation = Conversation.new(:with => number, :topic => topic).details
173
+ # => #<UnknownTopicConversation topic: "abstract", with: "123456789">
174
+ # </tt>
175
+ #
176
+ # <tt>Conversation.exclude</tt> accepts the following
177
+ # * Class: <tt>Conversation.exclude AbstractConversation</tt>
178
+ # * Array: <tt>Conversation.exclude [AbstractConversation, OtherConversation]</tt>
179
+ # * Symbol: <tt>Conversation.exclude :abstract_conversation</tt>
180
+ # * String: <tt>Conversation.exclude "abstract_conversation"</tt>
181
+ # * Regexp: <tt>Conversation.exclude /abstract/i</tt>
182
+
183
+ def exclude(classes)
184
+ ConversationDefinition.exclude(classes)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,86 @@
1
+ module Conversational
2
+ class ConversationDefinition
3
+ cattr_accessor :unknown_topic_subclass
4
+ cattr_accessor :blank_topic_subclass
5
+ cattr_accessor :notification
6
+ cattr_writer :klass
7
+
8
+ def self.exclude(classes)
9
+ if classes
10
+ if classes.is_a?(Array)
11
+ classes.each do |class_name|
12
+ check_exclude_options!(class_name)
13
+ end
14
+ else
15
+ check_exclude_options!(classes)
16
+ end
17
+ end
18
+ @@excluded_classes = classes
19
+ end
20
+
21
+ def self.find_subclass_by_topic(topic, options = {})
22
+ subclass = nil
23
+ if topic.nil? || topic.blank?
24
+ subclass = blank_topic_subclass if blank_topic_subclass
25
+ else
26
+ project_class_name = self.topic_subclass_name(topic)
27
+ begin
28
+ project_class = project_class_name.constantize
29
+ rescue
30
+ project_class = nil
31
+ end
32
+ # the subclass has been defined
33
+ # check that it is a subclass klass
34
+ if project_class && project_class <= @@klass &&
35
+ (options[:include_all] || !self.exclude?(project_class))
36
+ subclass = project_class
37
+ else
38
+ subclass = unknown_topic_subclass if unknown_topic_subclass
39
+ end
40
+ end
41
+ subclass
42
+ end
43
+
44
+ def self.topic_subclass_name(topic)
45
+ topic.classify + @@klass.to_s
46
+ end
47
+
48
+ private
49
+ def self.exclude?(subclass)
50
+ if defined?(@@excluded_classes)
51
+ if @@excluded_classes.is_a?(Array)
52
+ @@excluded_classes.each do |excluded_class|
53
+ break if exclude_class?(subclass)
54
+ end
55
+ else
56
+ exclude_class?(subclass)
57
+ end
58
+ end
59
+ end
60
+
61
+ def self.exclude_class?(subclass)
62
+ if @@excluded_classes.is_a?(Class)
63
+ @@excluded_classes == subclass
64
+ elsif @@excluded_classes.is_a?(Regexp)
65
+ subclass.to_s =~ @@excluded_classes
66
+ else
67
+ excluded_class = @@excluded_classes.to_s
68
+ begin
69
+ excluded_class.classify.constantize == subclass
70
+ rescue
71
+ false
72
+ end
73
+ end
74
+ end
75
+
76
+ def self.check_exclude_options!(classes)
77
+ raise(
78
+ ArgumentError,
79
+ "You must specify an Array, Symbol, Regex, String or Class or nil. You specified a #{classes.class}"
80
+ ) unless classes.is_a?(Symbol) ||
81
+ classes.is_a?(Regexp) ||
82
+ classes.is_a?(String) ||
83
+ classes.is_a?(Class)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,4 @@
1
+ require 'conversational/conversation'
2
+ require 'conversational/conversation_definition'
3
+ require 'conversational/active_record_additions'
4
+
@@ -0,0 +1,5 @@
1
+ Description:
2
+ Creates a migration file for when you want to use Conversation with ActiveRecord
3
+
4
+ Example:
5
+ rails g conversation:migration
@@ -0,0 +1,23 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ module Conversational
4
+ class MigrationGenerator < Rails::Generators::Base
5
+ include Rails::Generators::Migration
6
+
7
+ def self.source_root
8
+ @source_root ||= File.join(File.dirname(__FILE__), 'templates')
9
+ end
10
+
11
+ def self.next_migration_number(dirname)
12
+ if ActiveRecord::Base.timestamped_migrations
13
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
14
+ else
15
+ "%.3d" % (current_migration_number(dirname) + 1)
16
+ end
17
+ end
18
+
19
+ def create_migration_file
20
+ migration_template 'migration.rb', 'db/migrate/create_conversations.rb'
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ class CreateConversations < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :conversations do |t|
4
+ t.string :with, :null => false
5
+ t.string :state
6
+ t.string :topic
7
+ t.timestamps
8
+ end
9
+ end
10
+
11
+ def self.down
12
+ drop_table :conversations
13
+ end
14
+ end
@@ -0,0 +1,6 @@
1
+ Description:
2
+ Creates a blank Conversation class and a directory to store your conversations
3
+ under app/conversations
4
+
5
+ Example:
6
+ rails g conversation:skeleton
@@ -0,0 +1,17 @@
1
+ require 'rails/generators'
2
+ module Conversational
3
+ class SkeletonGenerator < Rails::Generators::Base
4
+
5
+ def self.source_root
6
+ @source_root ||= File.join(File.dirname(__FILE__), 'templates')
7
+ end
8
+
9
+ def create_conversations_directory
10
+ empty_directory "app/conversations"
11
+ end
12
+
13
+ def copy_conversation_file
14
+ copy_file "conversation.rb", "app/conversations/conversation.rb"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,3 @@
1
+ class Conversation
2
+ include Conversational::Conversation
3
+ end