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 +7 -0
- data/.gitignore +14 -0
- data/.rspec +1 -0
- data/.rubocop.yml +5 -0
- data/.ruby-version +1 -0
- data/Gemfile +4 -0
- data/README.md +160 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/circle.yml +3 -0
- data/exe/socrates +12 -0
- data/lib/socrates/adapters/console_adapter.rb +34 -0
- data/lib/socrates/adapters/memory_adapter.rb +29 -0
- data/lib/socrates/adapters/slack_adapter.rb +33 -0
- data/lib/socrates/bots/cli_bot.rb +20 -0
- data/lib/socrates/bots/slack_bot.rb +35 -0
- data/lib/socrates/config.rb +19 -0
- data/lib/socrates/core/dispatcher.rb +121 -0
- data/lib/socrates/core/state.rb +111 -0
- data/lib/socrates/core/state_data.rb +78 -0
- data/lib/socrates/logger.rb +13 -0
- data/lib/socrates/sample_states.rb +161 -0
- data/lib/socrates/storage/storage.rb +50 -0
- data/lib/socrates/string_helpers.rb +23 -0
- data/lib/socrates/version.rb +3 -0
- data/lib/socrates.rb +19 -0
- data/sample-conversation-console.gif +0 -0
- data/socrates.gemspec +38 -0
- metadata +199 -0
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
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.4.1
|
data/Gemfile
ADDED
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
|
+

|
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
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
data/circle.yml
ADDED
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,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
|
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: []
|