socrates 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: da36c4903bca86d379dd6bbf2163f325b71fb852
4
+ data.tar.gz: ebd41affe29ae7b51ae5cfae2f348779b675d968
5
+ SHA512:
6
+ metadata.gz: de3be228b5f26e6c4c2f33d10125f852f8fee4b2a943c5a7eebcfdba8ce18fd0d66498138c86222d438707f0f752f19b3d4a23da063da97b61ca705e3af20dc2
7
+ data.tar.gz: 9c5dc64b695f7a767f98461ee984c6bec32cc9bb3ebf8e8e25eeac95f7c70c12e67d8bbae59953e6c1eb1c3cec76b9e19b04f82873a29498651d5f7cc04e9679
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /.rubocop-https---raw-githubusercontent-com-carbonfive-c5-conventions-master-rubocop-rubocop-yml
11
+ /socrates-*.gem
12
+
13
+ # rspec failure tracking
14
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,5 @@
1
+ inherit_from:
2
+ - https://raw.githubusercontent.com/carbonfive/c5-conventions/master/rubocop/rubocop.yml
3
+
4
+ AllCops:
5
+ TargetRubyVersion: 2.4
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.4.1
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in socrates.gemspec
4
+ gemspec
data/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # Socrates
2
+
3
+ Socrates is a micro-framework for building conversational interfaces. It provides straight-forward state management, a clear pattern for modeling the states and conversational flow (transitions), and some helpers.
4
+
5
+ It's designed for building conversational Slack bots, but is designed in such a way that other adapters could be written. It ships with a Console adapter for testing locally in the terminal as well as a Memory adapter for use in automated tests.
6
+
7
+ *Disclaimer: This framework is currently experimental and will change.*
8
+
9
+ Conceptually, a conversation is a sequence of asking and listening actions. As a conversation progresses, information is gathered and at some point, acted upon by the system.
10
+
11
+ Conversational state is captured either in memory (development and testing) or in Redis. This too is pluggable so that other storage backends could be used.
12
+
13
+ Here is a simple example, asking for one's name, birth date, and then responding with the current age.
14
+
15
+ ![Calculate Age](./sample-conversation-console.gif)
16
+
17
+ ```ruby
18
+ class GetStarted
19
+ include Socrates::Core::State
20
+
21
+ def listen(message)
22
+ case message.strip
23
+ when "help"
24
+ transition_to :help
25
+ when "age"
26
+ transition_to :ask_for_name
27
+ else
28
+ transition_to :no_comprende
29
+ end
30
+ end
31
+ end
32
+
33
+ class Help
34
+ include Socrates::Core::State
35
+
36
+ def ask
37
+ respond message: <<~MSG
38
+ Thanks for asking! I can do these things for you...
39
+
40
+ • `age` - Calculate your age from your birth date.
41
+ • `help` - Tell you what I can do for you.
42
+
43
+ So, what shall it be?
44
+ MSG
45
+ transition_to :get_started, action: :listen
46
+ end
47
+ end
48
+
49
+ class NoComprende
50
+ include Socrates::Core::State
51
+
52
+ def ask
53
+ respond message: "Whoops, I don't know what you mean by that. Try `help` to see my commands."
54
+ transition_to :get_started
55
+ end
56
+ end
57
+
58
+ class AskForName
59
+ include Socrates::Core::State
60
+
61
+ def ask
62
+ respond message: "First things first, what's your name?"
63
+ end
64
+
65
+ def listen(message)
66
+ # Transition to the next step while persisting the name for future retrieval.
67
+ transition_to :ask_for_birth_date, data: { name: message }
68
+ end
69
+ end
70
+
71
+ class AskForBirthDate
72
+ include Socrates::Core::State
73
+
74
+ def ask
75
+ respond message: "Hi #{first_name}! What's your birth date (e.g. MM/DD/YYYY)?"
76
+ end
77
+
78
+ def listen(message)
79
+ begin
80
+ birth_date = Date.strptime(message, "%m/%d/%Y")
81
+ rescue ArgumentError
82
+ respond message: "Whoops, I didn't understand that. What's your birth date (e.g. MM/DD/YYYY)?"
83
+ repeat_action
84
+ return
85
+ end
86
+ # Transition to the next step while persisting the birth date for future retrieval.
87
+ transition_to :calculate_age, data: { birth_date: birth_date }
88
+ end
89
+
90
+ private
91
+
92
+ def first_name
93
+ @data.get(:name).split.first
94
+ end
95
+ end
96
+
97
+ class CalculateAge
98
+ include Socrates::Core::State
99
+
100
+ def ask
101
+ respond message: "Got it #{first_name}! So that makes you #{calculate_age} years old."
102
+ end_conversation
103
+ end
104
+
105
+ private
106
+
107
+ def first_name
108
+ @data.get(:name).split.first
109
+ end
110
+
111
+ def birth_date
112
+ @data.get(:birth_date)
113
+ end
114
+
115
+ def calculate_age
116
+ ((Date.today.to_time - birth_date.to_time) / 31_536_000).floor
117
+ end
118
+ end
119
+ ```
120
+
121
+ ## Installation
122
+
123
+ Add this line to your application's Gemfile:
124
+
125
+ ```ruby
126
+ gem 'socrates'
127
+ ```
128
+
129
+ And then execute:
130
+
131
+ $ bundle
132
+
133
+ Or install it yourself as:
134
+
135
+ $ gem install socrates
136
+
137
+ ## Usage
138
+
139
+ TODO: Write usage instructions here.
140
+
141
+ ## Core Concepts
142
+
143
+ * Dispatcher
144
+ * Adapter (Slack, Console, Memory)
145
+ * Storage (Memory, Redis)
146
+ * State
147
+ * Helpers
148
+
149
+ TODO: Expand descriptions. Include a diagram.
150
+
151
+ ## Development
152
+
153
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the specs and rubocop. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
154
+
155
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
156
+
157
+ ## Contributing
158
+
159
+ Bug reports and pull requests are welcome on GitHub at https://github.com/carbonfive/socrates.
160
+
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "socrates"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/circle.yml ADDED
@@ -0,0 +1,3 @@
1
+ test:
2
+ post:
3
+ - bundle exec rubocop
data/exe/socrates ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ File.expand_path("../../lib", __FILE__).tap do |lib|
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ end
6
+
7
+ require "socrates"
8
+ require "socrates/sample_states"
9
+
10
+ puts "Type `help` to see what I can do..."
11
+
12
+ Socrates::Bots::CLIBot.new(state_factory: Socrates::SampleStates::StateFactory.new).start
@@ -0,0 +1,34 @@
1
+ class ConsoleAdapter
2
+ CLIENT_ID = "CONSOLE"
3
+
4
+ def initialize(name: "@socrates")
5
+ @name = name
6
+ end
7
+
8
+ def client_id_from_context(_context)
9
+ CLIENT_ID
10
+ end
11
+
12
+ def send_message(message, *)
13
+ puts "\n#{colorize(@name, "32;1")}: #{message}"
14
+ end
15
+
16
+ def send_direct_message(message, user, *)
17
+ name =
18
+ if user.respond_to?(:name)
19
+ user.name
20
+ elsif user.respond_to?(:id)
21
+ user.id
22
+ else
23
+ user
24
+ end
25
+
26
+ puts "\n[DM] #{colorize(name, "34;1")}: #{message}"
27
+ end
28
+
29
+ private
30
+
31
+ def colorize(str, color_code)
32
+ "\e[#{color_code}m#{str}\e[0m"
33
+ end
34
+ end
@@ -0,0 +1,29 @@
1
+ class MemoryAdapter
2
+ CLIENT_ID = "MEMORY"
3
+
4
+ attr_reader :history, :dms
5
+
6
+ def initialize
7
+ @history = []
8
+ @dms = {}
9
+ end
10
+
11
+ def client_id_from_context(_context)
12
+ CLIENT_ID
13
+ end
14
+
15
+ def send_message(message, *)
16
+ @history << message
17
+ end
18
+
19
+ def send_direct_message(message, user, *)
20
+ user = user.id if user.respond_to?(:id)
21
+
22
+ @dms[user] = [] unless @dms.key?(user)
23
+ @dms[user] << message
24
+ end
25
+
26
+ def last_message
27
+ @history.last
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ class SlackAdapter
2
+ def initialize(slack_real_time_client)
3
+ @slack_real_time_client = slack_real_time_client
4
+ end
5
+
6
+ def client_id_from_context(context)
7
+ context&.user
8
+ end
9
+
10
+ def send_message(message, context:)
11
+ @slack_real_time_client.message(text: message, channel: context.channel)
12
+ end
13
+
14
+ def send_direct_message(message, user, *)
15
+ user = user.id if user.respond_to?(:id)
16
+
17
+ im_channel = lookup_im_channel(user)
18
+
19
+ @slack_real_time_client.message(text: message, channel: im_channel)
20
+ end
21
+
22
+ private
23
+
24
+ def lookup_im_channel(user)
25
+ im = @slack_real_time_client.ims.values.find { |i| i.user == user }
26
+
27
+ return im if im.present?
28
+
29
+ # Start a new conversation with this user.
30
+ response = @slack_real_time_client.web_client.im_open(user: user.id)
31
+ response.channel.id
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module Socrates
2
+ module Bots
3
+ class CLIBot
4
+ def initialize(state_factory:)
5
+ @adapter = ConsoleAdapter.new
6
+ @storage = Storage::MemoryStorage.new
7
+ @dispatcher = Core::Dispatcher.new(storage: @storage, adapter: @adapter, state_factory: state_factory)
8
+ end
9
+
10
+ def start
11
+ # Clear out any remnants from previous runs.
12
+ @storage.clear(ConsoleAdapter::CLIENT_ID)
13
+
14
+ while (input = gets.chomp)
15
+ @dispatcher.dispatch(message: input)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ require "slack-ruby-client"
2
+
3
+ module Socrates
4
+ module Bots
5
+ class SlackBot
6
+ def initialize(state_factory:)
7
+ Slack.configure do |config|
8
+ config.token = ENV["SLACK_API_TOKEN"]
9
+ config.logger = Logger.new(STDOUT)
10
+ config.logger.level = Logger::INFO
11
+
12
+ raise "Missing ENV[SLACK_API_TOKEN]!" unless config.token
13
+ end
14
+
15
+ @slack_client = Slack::RealTime::Client.new
16
+ @adapter = SlackAdapter.new(@slack_client)
17
+ @storage = Storage::RedisStorage.new
18
+ @dispatcher = Core::Dispatcher.new(storage: @storage, adapter: @adapter, state_factory: state_factory)
19
+ end
20
+
21
+ def start
22
+ @slack_client.on :message do |data|
23
+ # puts "> #{data}"
24
+
25
+ # When first connecting, Slack may resend the last message. Ignore it...
26
+ next if data.reply_to.present?
27
+
28
+ @dispatcher.dispatch(message: data.text, context: data)
29
+ end
30
+
31
+ @slack_client.start!
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ module Socrates
2
+ module Config
3
+ extend self
4
+
5
+ attr_accessor :view_path
6
+ attr_accessor :error_message
7
+ attr_accessor :logger
8
+ end
9
+
10
+ class << self
11
+ def configure
12
+ block_given? ? yield(Config) : Config
13
+ end
14
+
15
+ def config
16
+ Config
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,121 @@
1
+ require "hashie"
2
+
3
+ require "socrates/logger"
4
+ require "socrates/string_helpers"
5
+ require "socrates/storage/storage"
6
+ require "socrates/core/state_data"
7
+
8
+ module Socrates
9
+ module Core
10
+ class Dispatcher
11
+ def initialize(adapter:, state_factory:, storage: nil)
12
+ @adapter = adapter
13
+ @state_factory = state_factory
14
+ @storage = storage || Storage::MemoryStorage.new
15
+
16
+ @logger = Socrates::Config.logger || Socrates::Logger.default
17
+ @error_message = Socrates::Config.error_message || DEFAULT_ERROR_MESSAGE
18
+ end
19
+
20
+ # rubocop:disable Metrics/AbcSize
21
+ def dispatch(message:, context: {})
22
+ message = message.strip
23
+
24
+ client_id = @adapter.client_id_from_context(context)
25
+
26
+ @logger.info %(#{client_id} recv: "#{message}")
27
+
28
+ # In many cases, a two actions will run in this loop: :listen => :ask, but it's possible that a chain of 2 or
29
+ # more :ask actions could run, before stopping at a :listen (and waiting for the next input).
30
+ loop do
31
+ state_data = fetch_snapshot(client_id)
32
+ state = instantiate_state(state_data, context)
33
+
34
+ args = [state.data.state_action]
35
+ args << message if state.data.state_action == :listen
36
+
37
+ msg = %(#{client_id} processing :#{state.data.state_id} / :#{args.first})
38
+ msg += %( / message: "#{args.second}") if args.count > 1
39
+ @logger.debug msg
40
+
41
+ begin
42
+ state.send(*args)
43
+ rescue => e
44
+ handle_action_error(e, client_id, state, context)
45
+ return
46
+ end
47
+
48
+ # Update the persisted state data so we know what to run next time.
49
+ state.data.state_id = state.next_state_id
50
+ state.data.state_action = state.next_state_action
51
+
52
+ @logger.debug %(#{client_id} transition to :#{state.data.state_id} / :#{state.data.state_action})
53
+
54
+ persist_snapshot(client_id, state.data)
55
+
56
+ # Break from the loop if there's nothing left to do, i.e. no more state transitions.
57
+ break if done_transitioning?(state)
58
+ end
59
+ end
60
+ # rubocop:enable Metrics/AbcSize
61
+
62
+ private
63
+
64
+ DEFAULT_ERROR_MESSAGE = "Sorry, an error occurred. We'll have to start over..."
65
+
66
+ def fetch_snapshot(client_id)
67
+ state_data =
68
+ if @storage.has_key?(client_id)
69
+ begin
70
+ snapshot = @storage.get(client_id)
71
+ StateData.deserialize(snapshot)
72
+ rescue => e
73
+ @logger.warn "Error while fetching snapshot for client id '#{client_id}', resetting state: #{e.message}"
74
+ @logger.warn e
75
+ end
76
+ end
77
+
78
+ state_data ||= StateData.new
79
+
80
+ # If the current state is nil or END_OF_CONVERSATION, set it to the default state, which is typically a state
81
+ # that waits for an initial command or input from the user (e.g. help, start, etc).
82
+ if state_data.state_id.nil? || state_data.state_id == State::END_OF_CONVERSATION
83
+ state_data.state_id = @state_factory.default_state
84
+ state_data.state_action = :listen
85
+ end
86
+
87
+ state_data
88
+ end
89
+
90
+ def persist_snapshot(client_id, state_data)
91
+ @storage.put(client_id, state_data.serialize)
92
+ end
93
+
94
+ def instantiate_state(state_data, context)
95
+ @state_factory.build(state_data: state_data, adapter: @adapter, context: context)
96
+ end
97
+
98
+ def done_transitioning?(state)
99
+ # Stop transitioning if we're waiting for the user to respond (i.e. we're listening).
100
+ return true if state.data.state_action == :listen
101
+
102
+ # Stop transitioning if there's no state to transition to, or the conversation has ended.
103
+ return true if state.data.state_id.nil? || state.data.state_id == State::END_OF_CONVERSATION
104
+
105
+ false
106
+ end
107
+
108
+ def handle_action_error(e, client_id, state, context)
109
+ @logger.warn "Error while processing action #{state.data.state_id}/#{state.data.state_action}: #{e.message}"
110
+ @logger.warn e
111
+
112
+ @adapter.send_message(@error_message, context: context)
113
+ state.data.clear
114
+ state.data.state_id = nil
115
+ state.data.state_action = nil
116
+
117
+ persist_snapshot(client_id, state.data)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,111 @@
1
+ require "hashie"
2
+ require "erb"
3
+
4
+ module Socrates
5
+ module Core
6
+ module State
7
+ attr_reader :data, :context
8
+
9
+ def initialize(data: StateData.new, adapter:, context: nil)
10
+ @data = data
11
+ @adapter = adapter
12
+ @context = context
13
+ @next_state_id = nil
14
+ @next_state_action = nil
15
+ @logger = Socrates::Config.logger || Socrates::Logger.default
16
+ end
17
+
18
+ def next_state_id
19
+ if @next_state_id.nil?
20
+ state_id_from_classname
21
+ else
22
+ @next_state_id
23
+ end
24
+ end
25
+
26
+ def next_state_action
27
+ if @next_state_action.nil?
28
+ next_action(@data.state_action)
29
+ else
30
+ @next_state_action
31
+ end
32
+ end
33
+
34
+ def respond(message: nil, template: nil)
35
+ if template
36
+ # TODO: Partials?
37
+ filename = File.join(Socrates.config.view_path, template)
38
+ source = File.read(filename)
39
+ message = ERB.new(source, 0, "<>").result(binding)
40
+ end
41
+
42
+ return if message.empty?
43
+
44
+ @logger.info %(#{client_id} send: "#{format_for_logging(message)}")
45
+ @adapter.send_message(message, context: @context)
46
+ end
47
+
48
+ def send_message(to:, message:)
49
+ @logger.info %(#{client_id} send direct to #{to}: "#{format_for_logging(message)}")
50
+ @adapter.send_direct_message(message, to, context: @context)
51
+ end
52
+
53
+ def transition_to(state_id, action: nil, data: {})
54
+ if action.nil?
55
+ action =
56
+ if state_id.nil?
57
+ nil
58
+ elsif state_id == state_id_from_classname
59
+ next_action(@data.state_action)
60
+ else
61
+ :ask
62
+ end
63
+ end
64
+
65
+ @next_state_id = state_id
66
+ @next_state_action = action
67
+
68
+ @data.merge(data)
69
+ end
70
+
71
+ def repeat_action
72
+ @next_state_id = @data.state_id
73
+ @next_state_action = @data.state_action
74
+ end
75
+
76
+ def end_conversation
77
+ @data.clear
78
+
79
+ transition_to END_OF_CONVERSATION, action: END_OF_CONVERSATION
80
+ end
81
+
82
+ def ask
83
+ # stub implementation, to be overwritten.
84
+ end
85
+
86
+ def listen(_message)
87
+ # stub implementation, to be overwritten.
88
+ end
89
+
90
+ private
91
+
92
+ END_OF_CONVERSATION = :__end__
93
+
94
+ def next_action(current_action)
95
+ (%i[ask listen] - [current_action]).first
96
+ end
97
+
98
+ def client_id
99
+ @adapter.client_id_from_context(@context)
100
+ end
101
+
102
+ def format_for_logging(message)
103
+ message.gsub("\n", "\\n")
104
+ end
105
+
106
+ def state_id_from_classname
107
+ StringHelpers.classname_to_underscore(self.class.to_s.split("::").last).to_sym
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,78 @@
1
+ require "hashie"
2
+ require "json"
3
+ require "set"
4
+ require "yaml"
5
+
6
+ module Socrates
7
+ module Core
8
+ class StateData
9
+ attr_accessor :state_id, :state_action
10
+
11
+ def initialize(state_id: nil, state_action: nil, data: {}, temporary_keys: [])
12
+ @state_id = state_id
13
+ @state_action = state_action
14
+ @data = data
15
+ @temporary_keys = Set.new(temporary_keys)
16
+ end
17
+
18
+ def keys
19
+ @data.keys
20
+ end
21
+
22
+ def has_key?(key)
23
+ @data.has_key?(key)
24
+ end
25
+
26
+ def has_temporary_key?(key)
27
+ # The !! turns nils into false, which shouldn"t be necessary, but seems to be after the set is loaded from yaml.
28
+ @temporary_keys.include?(key) == true
29
+ end
30
+
31
+ def get(key, clear: false)
32
+ value = @data[key]
33
+
34
+ if @temporary_keys.include?(key) || clear
35
+ @temporary_keys.delete(key)
36
+ @data.delete(key)
37
+ end
38
+
39
+ value
40
+ end
41
+
42
+ def set(key, value)
43
+ @data[key] = value
44
+ end
45
+
46
+ def set_temporary(key, value)
47
+ if @data.has_key?(key) && !@temporary_keys.include?(key)
48
+ raise ArgumentError, "Cannot overrite key '#{key}' with a temporary value."
49
+ end
50
+
51
+ @data[key] = value
52
+ @temporary_keys << key
53
+ end
54
+
55
+ def merge(other)
56
+ @data.merge!(other)
57
+ end
58
+
59
+ def clear(key = nil)
60
+ if key
61
+ @data.delete(key)
62
+ @temporary_keys.delete(key)
63
+ else
64
+ @data.clear
65
+ @temporary_keys.clear
66
+ end
67
+ end
68
+
69
+ def serialize
70
+ YAML.dump(self)
71
+ end
72
+
73
+ def self.deserialize(string)
74
+ YAML.load(string)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,13 @@
1
+ require "logger"
2
+
3
+ module Socrates
4
+ class Logger < ::Logger
5
+ def self.default
6
+ @logger ||= begin
7
+ logger = new(STDOUT)
8
+ logger.level = Logger::WARN
9
+ logger
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,161 @@
1
+ require "date"
2
+
3
+ require "socrates/core/state"
4
+
5
+ module Socrates
6
+ module SampleStates
7
+ class StateFactory
8
+ def default_state
9
+ :get_started
10
+ end
11
+
12
+ def build(state_data:, adapter:, context: nil)
13
+ classname = StringHelpers.underscore_to_classname(state_data.state_id)
14
+
15
+ Object.const_get("Socrates::SampleStates::#{classname}")
16
+ .new(data: state_data, adapter: adapter, context: context)
17
+ end
18
+ end
19
+
20
+ class GetStarted
21
+ include Socrates::Core::State
22
+
23
+ def listen(message)
24
+ case message.strip
25
+ when "help"
26
+ transition_to :help
27
+ when "age"
28
+ transition_to :ask_for_name
29
+ when "error"
30
+ transition_to :raise_error
31
+ else
32
+ transition_to :no_comprende
33
+ end
34
+ end
35
+ end
36
+
37
+ class Help
38
+ include Socrates::Core::State
39
+
40
+ def ask
41
+ respond message: <<~MSG
42
+ Thanks for asking! I can do these things for you...
43
+
44
+ • `age` - Calculate your age from your birth date.
45
+ • `error` - Start a short error path that raises an error.
46
+ • `help` - Tell you what I can do for you.
47
+
48
+ So, what shall it be?
49
+ MSG
50
+
51
+ transition_to :get_started, action: :listen
52
+ end
53
+ end
54
+
55
+ class NoComprende
56
+ include Socrates::Core::State
57
+
58
+ def ask
59
+ respond message: "Whoops, I don't know what you mean by that. Try `help` to see my commands."
60
+
61
+ transition_to :get_started
62
+ end
63
+ end
64
+
65
+ class AskForName
66
+ include Socrates::Core::State
67
+
68
+ def ask
69
+ respond message: "First things first, what's your name?"
70
+ end
71
+
72
+ def listen(message)
73
+ transition_to :ask_for_birth_date, data: { name: message }
74
+ end
75
+ end
76
+
77
+ class AskForBirthDate
78
+ include Socrates::Core::State
79
+
80
+ def ask
81
+ respond message: "Hi #{first_name}! What's your birth date (e.g. MM/DD/YYYY)?"
82
+ end
83
+
84
+ def listen(message)
85
+ begin
86
+ birth_date = Date.strptime(message, "%m/%d/%Y")
87
+ rescue ArgumentError
88
+ respond message: "Whoops, I didn't understand that. What's your birth date (e.g. MM/DD/YYYY)?"
89
+ repeat_action
90
+ return
91
+ end
92
+
93
+ transition_to :calculate_age, data: { birth_date: birth_date }
94
+ end
95
+
96
+ private
97
+
98
+ def first_name
99
+ @data.get(:name).split.first
100
+ end
101
+ end
102
+
103
+ class CalculateAge
104
+ include Socrates::Core::State
105
+
106
+ def ask
107
+ respond message: "Got it #{first_name}! So that makes you #{calculate_age} years old."
108
+
109
+ # Example of a :ask => :ask transition.
110
+ transition_to :end_conversation_1
111
+ end
112
+
113
+ private
114
+
115
+ def first_name
116
+ @data.get(:name).split.first
117
+ end
118
+
119
+ def birth_date
120
+ @data.get(:birth_date)
121
+ end
122
+
123
+ def calculate_age
124
+ ((Date.today.to_time - birth_date.to_time) / 31_536_000).floor
125
+ end
126
+ end
127
+
128
+ class EndConversation1
129
+ include Socrates::Core::State
130
+
131
+ def ask
132
+ respond message: "That's all for now..."
133
+
134
+ # Example of another :ask => :ask transition.
135
+ transition_to :end_conversation_2
136
+ end
137
+ end
138
+
139
+ class EndConversation2
140
+ include Socrates::Core::State
141
+
142
+ def ask
143
+ respond message: "Type `help` to see what else I can do."
144
+
145
+ end_conversation
146
+ end
147
+ end
148
+
149
+ class RaiseError
150
+ include Socrates::Core::State
151
+
152
+ def ask
153
+ respond message: "I will raise an error regardless of what you enter next..."
154
+ end
155
+
156
+ def listen(_message)
157
+ raise ArgumentError, "Boom!"
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,50 @@
1
+ require "redis"
2
+ require "json"
3
+
4
+ module Socrates
5
+ module Storage
6
+ class MemoryStorage
7
+ def initialize
8
+ @storage = {}
9
+ end
10
+
11
+ def has_key?(key)
12
+ @storage.has_key? key
13
+ end
14
+
15
+ def clear(key)
16
+ @storage.delete key
17
+ end
18
+
19
+ def get(key)
20
+ @storage[key]
21
+ end
22
+
23
+ def put(key, value)
24
+ @storage[key] = value
25
+ end
26
+ end
27
+
28
+ class RedisStorage
29
+ def initialize(url: "redis://localhost")
30
+ @redis = Redis.new(url: url)
31
+ end
32
+
33
+ def has_key?(key)
34
+ @redis.exists(key)
35
+ end
36
+
37
+ def clear(key)
38
+ @redis.del key
39
+ end
40
+
41
+ def get(key)
42
+ @redis.get(key)
43
+ end
44
+
45
+ def put(key, value)
46
+ @redis.set(key, value)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,23 @@
1
+ require "active_support/inflector"
2
+
3
+ module StringHelpers
4
+ def self.underscore_to_classname(underscored_symbol)
5
+ underscored_symbol.to_s.camelize
6
+ end
7
+
8
+ def self.classname_to_underscore(classname)
9
+ classname.underscore
10
+ end
11
+
12
+ # Lifted from Rails' text helpers.
13
+ def self.pluralize(count, singular, plural_arg = nil, plural: plural_arg)
14
+ word =
15
+ if count == 1 || count =~ /^1(\.0+)?$/
16
+ singular
17
+ else
18
+ plural || singular.pluralize
19
+ end
20
+
21
+ "#{count || 0} #{word}"
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module Socrates
2
+ VERSION = "0.1.0"
3
+ end
data/lib/socrates.rb ADDED
@@ -0,0 +1,19 @@
1
+ # Socrates
2
+ require "socrates/version"
3
+ require "socrates/config"
4
+ require "socrates/logger"
5
+ require "socrates/string_helpers"
6
+ require "socrates/adapters/console_adapter"
7
+ require "socrates/adapters/memory_adapter"
8
+ require "socrates/adapters/slack_adapter"
9
+ require "socrates/storage/storage"
10
+ require "socrates/core/state_data"
11
+ require "socrates/core/state"
12
+ require "socrates/core/dispatcher"
13
+
14
+ # Bot implementations
15
+ require "socrates/bots/cli_bot"
16
+ require "socrates/bots/slack_bot"
17
+
18
+ module Socrates
19
+ end
Binary file
data/socrates.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # coding: utf-8
2
+
3
+ lib = File.expand_path("../lib", __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+
6
+ require "socrates/version"
7
+
8
+ Gem::Specification.new do |spec|
9
+ spec.name = "socrates"
10
+ spec.version = Socrates::VERSION
11
+ spec.license = "MIT"
12
+ spec.authors = ["Christian Nelson"]
13
+ spec.email = ["christian@carbonfive.com"]
14
+
15
+ spec.summary = "A microframework for building stateful conversational bots."
16
+ spec.homepage = "https://github.com/carbonfive/socrates"
17
+
18
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
19
+ f.match(%r{^(test|spec|features)/})
20
+ end
21
+
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.required_ruby_version = ">= 2.3.0"
27
+
28
+ spec.add_development_dependency "bundler", "~> 1.14"
29
+ spec.add_development_dependency "rake", "~> 10.0"
30
+ spec.add_development_dependency "rspec", "~> 3.5"
31
+ spec.add_development_dependency "rubocop", "~> 0.48.1"
32
+
33
+ spec.add_dependency "activesupport", ">= 5.0.2"
34
+ spec.add_dependency "celluloid-io", ">= 0.17.3"
35
+ spec.add_dependency "hashie", ">= 3.5.5"
36
+ spec.add_dependency "redis", ">= 3.3.3"
37
+ spec.add_dependency "slack-ruby-client", ">= 0.8.0"
38
+ end
metadata ADDED
@@ -0,0 +1,199 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: socrates
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Christian Nelson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-04-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.14'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.14'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '10.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '10.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.5'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.5'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.48.1
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.48.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: activesupport
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 5.0.2
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 5.0.2
83
+ - !ruby/object:Gem::Dependency
84
+ name: celluloid-io
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 0.17.3
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 0.17.3
97
+ - !ruby/object:Gem::Dependency
98
+ name: hashie
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 3.5.5
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 3.5.5
111
+ - !ruby/object:Gem::Dependency
112
+ name: redis
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 3.3.3
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 3.3.3
125
+ - !ruby/object:Gem::Dependency
126
+ name: slack-ruby-client
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: 0.8.0
132
+ type: :runtime
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 0.8.0
139
+ description:
140
+ email:
141
+ - christian@carbonfive.com
142
+ executables:
143
+ - socrates
144
+ extensions: []
145
+ extra_rdoc_files: []
146
+ files:
147
+ - ".gitignore"
148
+ - ".rspec"
149
+ - ".rubocop.yml"
150
+ - ".ruby-version"
151
+ - Gemfile
152
+ - README.md
153
+ - Rakefile
154
+ - bin/console
155
+ - bin/setup
156
+ - circle.yml
157
+ - exe/socrates
158
+ - lib/socrates.rb
159
+ - lib/socrates/adapters/console_adapter.rb
160
+ - lib/socrates/adapters/memory_adapter.rb
161
+ - lib/socrates/adapters/slack_adapter.rb
162
+ - lib/socrates/bots/cli_bot.rb
163
+ - lib/socrates/bots/slack_bot.rb
164
+ - lib/socrates/config.rb
165
+ - lib/socrates/core/dispatcher.rb
166
+ - lib/socrates/core/state.rb
167
+ - lib/socrates/core/state_data.rb
168
+ - lib/socrates/logger.rb
169
+ - lib/socrates/sample_states.rb
170
+ - lib/socrates/storage/storage.rb
171
+ - lib/socrates/string_helpers.rb
172
+ - lib/socrates/version.rb
173
+ - sample-conversation-console.gif
174
+ - socrates.gemspec
175
+ homepage: https://github.com/carbonfive/socrates
176
+ licenses:
177
+ - MIT
178
+ metadata: {}
179
+ post_install_message:
180
+ rdoc_options: []
181
+ require_paths:
182
+ - lib
183
+ required_ruby_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: 2.3.0
188
+ required_rubygems_version: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - ">="
191
+ - !ruby/object:Gem::Version
192
+ version: '0'
193
+ requirements: []
194
+ rubyforge_project:
195
+ rubygems_version: 2.6.11
196
+ signing_key:
197
+ specification_version: 4
198
+ summary: A microframework for building stateful conversational bots.
199
+ test_files: []