socrates 0.1.10 → 0.1.11
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 +4 -4
- data/lib/socrates/adapters/console.rb +20 -19
- data/lib/socrates/adapters/memory.rb +42 -20
- data/lib/socrates/adapters/slack.rb +25 -10
- data/lib/socrates/adapters/stubs.rb +43 -2
- data/lib/socrates/core/dispatcher.rb +61 -25
- data/lib/socrates/core/state.rb +6 -10
- data/lib/socrates/core/state_data.rb +5 -6
- data/lib/socrates/sample_states.rb +2 -2
- data/lib/socrates/storage/memory.rb +4 -0
- data/lib/socrates/version.rb +1 -1
- data/socrates.gemspec +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5888e238834056167ec5db70cabf1a25c59aa1f7
|
4
|
+
data.tar.gz: 360b99fbb350f111fadd4c9ad8596e3c3f7351fc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3404c0d12161fa105efaa44085f47af4780490102f4ca836ac1c2d5db0995c21a565c6e9185ee9cec9f65fcc027bfc92ff449924f385529550b3bcc1b64d787c
|
7
|
+
data.tar.gz: 51192c1becf7d53506895b13e9a4f0fc933996f786bfa34c74075c1ee62a0c08a1a1bf07223ade0a9182b1a015f75e764c35f86b25ce23ef89eb0f8b7d86b143
|
@@ -3,24 +3,37 @@ require "socrates/adapters/stubs"
|
|
3
3
|
module Socrates
|
4
4
|
module Adapters
|
5
5
|
class Console
|
6
|
-
|
6
|
+
include StubUserDirectory
|
7
7
|
|
8
|
-
|
8
|
+
CLIENT_ID = "CONSOLE"
|
9
|
+
CHANNEL = "C1"
|
9
10
|
|
10
11
|
def initialize(name: "@socrates")
|
11
|
-
|
12
|
-
@
|
12
|
+
super()
|
13
|
+
@name = name
|
13
14
|
end
|
14
15
|
|
15
|
-
def
|
16
|
+
def client_id_from(context: nil, user: nil)
|
17
|
+
raise ArgumentError, "Must provide one of :context or :user" if context.nil? && user.nil?
|
18
|
+
|
16
19
|
CLIENT_ID
|
17
20
|
end
|
18
21
|
|
19
|
-
def
|
22
|
+
def channel_from(context: nil, user: nil)
|
23
|
+
raise ArgumentError, "Must provide one of :context or :user" if context.nil? && user.nil?
|
24
|
+
|
25
|
+
CHANNEL
|
26
|
+
end
|
27
|
+
|
28
|
+
def send_message(message, channel)
|
29
|
+
raise ArgumentError, "Channel is required" unless channel.present?
|
30
|
+
|
20
31
|
puts "\n#{colorize(@name, "32;1")}: #{message}"
|
21
32
|
end
|
22
33
|
|
23
|
-
def send_direct_message(message, user
|
34
|
+
def send_direct_message(message, user)
|
35
|
+
raise ArgumentError, "User is required" unless user.present?
|
36
|
+
|
24
37
|
name =
|
25
38
|
if user.respond_to?(:name)
|
26
39
|
user.name
|
@@ -33,18 +46,6 @@ module Socrates
|
|
33
46
|
puts "\n[DM] #{colorize(name, "34;1")}: #{message}"
|
34
47
|
end
|
35
48
|
|
36
|
-
def add_user(id: nil, name: nil, first: nil, last: nil, email: nil)
|
37
|
-
users << User.new(id, name, Profile.new(first, last, email))
|
38
|
-
end
|
39
|
-
|
40
|
-
def users_list(*)
|
41
|
-
Response.new(users)
|
42
|
-
end
|
43
|
-
|
44
|
-
def lookup_email(*)
|
45
|
-
email
|
46
|
-
end
|
47
|
-
|
48
49
|
private
|
49
50
|
|
50
51
|
def colorize(str, color_code)
|
@@ -3,45 +3,67 @@ require "socrates/adapters/stubs"
|
|
3
3
|
module Socrates
|
4
4
|
module Adapters
|
5
5
|
class Memory
|
6
|
+
include StubUserDirectory
|
7
|
+
|
6
8
|
CLIENT_ID = "MEMORY"
|
9
|
+
CHANNEL = "C1"
|
7
10
|
|
8
|
-
attr_reader :history
|
9
|
-
attr_accessor :
|
11
|
+
attr_reader :history
|
12
|
+
attr_accessor :client_id
|
10
13
|
|
11
14
|
def initialize
|
12
|
-
|
13
|
-
@
|
14
|
-
|
15
|
+
super()
|
16
|
+
@history = Hash.new { |hash, key| hash[key] = [] }
|
17
|
+
end
|
18
|
+
|
19
|
+
def client_id_from(context: nil, user: nil)
|
20
|
+
raise ArgumentError, "Must provide one of :context or :user" if context.nil? && user.nil?
|
21
|
+
|
22
|
+
@client_id || CLIENT_ID
|
23
|
+
end
|
24
|
+
|
25
|
+
def channel_from(context: nil, user: nil)
|
26
|
+
raise ArgumentError, "Must provide one of :context or :user" if context.nil? && user.nil?
|
27
|
+
|
28
|
+
user.nil? ? CHANNEL : users_channel(user)
|
15
29
|
end
|
16
30
|
|
17
|
-
def
|
18
|
-
|
31
|
+
def send_message(message, channel)
|
32
|
+
raise ArgumentError, "Channel is required" unless channel.present?
|
33
|
+
|
34
|
+
@history[channel] << message
|
19
35
|
end
|
20
36
|
|
21
|
-
def
|
22
|
-
|
37
|
+
def send_direct_message(message, user)
|
38
|
+
raise ArgumentError, "User is required" unless user.present?
|
39
|
+
|
40
|
+
@history[users_channel(user)] << message
|
23
41
|
end
|
24
42
|
|
25
|
-
|
26
|
-
|
43
|
+
#
|
44
|
+
# Methods for fetching messages and dms in specs...
|
45
|
+
#
|
27
46
|
|
28
|
-
|
47
|
+
def msgs
|
48
|
+
@history[CHANNEL]
|
29
49
|
end
|
30
50
|
|
31
|
-
def
|
32
|
-
|
51
|
+
def last_msg
|
52
|
+
msgs[-1]
|
33
53
|
end
|
34
54
|
|
35
|
-
def
|
36
|
-
|
55
|
+
def dms(user)
|
56
|
+
@history[users_channel(user)]
|
37
57
|
end
|
38
58
|
|
39
|
-
def
|
40
|
-
|
59
|
+
def last_dm(user)
|
60
|
+
dms(user)[-1]
|
41
61
|
end
|
42
62
|
|
43
|
-
|
44
|
-
|
63
|
+
private
|
64
|
+
|
65
|
+
def users_channel(user)
|
66
|
+
user.respond_to?(:id) ? user.id : user
|
45
67
|
end
|
46
68
|
end
|
47
69
|
end
|
@@ -7,20 +7,35 @@ module Socrates
|
|
7
7
|
@real_time_client = real_time_client
|
8
8
|
end
|
9
9
|
|
10
|
-
def
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
10
|
+
def client_id_from(context: nil, user: nil)
|
11
|
+
unless context.nil?
|
12
|
+
raise ArgumentError, "Expected :context to respond to :user" unless context.respond_to?(:user)
|
13
|
+
return context.user
|
14
|
+
end
|
15
|
+
unless user.nil?
|
16
|
+
raise ArgumentError, "Expected :user to respond to :id" unless user.respond_to?(:id)
|
17
|
+
return user.id
|
18
|
+
end
|
19
|
+
raise ArgumentError, "Must provide one of :context or :user"
|
15
20
|
end
|
16
21
|
|
17
|
-
def
|
18
|
-
|
22
|
+
def channel_from(context: nil, user: nil)
|
23
|
+
unless context.nil?
|
24
|
+
raise ArgumentError, "Expected :context to respond to :channel" unless context.respond_to?(:channel)
|
25
|
+
return context.channel
|
26
|
+
end
|
27
|
+
unless user.nil?
|
28
|
+
# raise ArgumentError, "Expected :user to respond to :id" unless user.respond_to?(:id)
|
29
|
+
return lookup_im_channel(user)
|
30
|
+
end
|
31
|
+
raise ArgumentError, "Must provide one of :context or :user"
|
32
|
+
end
|
19
33
|
|
20
|
-
|
34
|
+
def send_message(message, channel)
|
35
|
+
@real_time_client.message(text: message, channel: channel)
|
21
36
|
end
|
22
37
|
|
23
|
-
def send_direct_message(message, user
|
38
|
+
def send_direct_message(message, user)
|
24
39
|
raise ArgumentError, "Expected user to respond to :id" unless user.respond_to?(:id)
|
25
40
|
|
26
41
|
im_channel = lookup_im_channel(user)
|
@@ -38,7 +53,7 @@ module Socrates
|
|
38
53
|
end
|
39
54
|
|
40
55
|
def lookup_email(context:)
|
41
|
-
raise ArgumentError, "Expected context to respond to :user" unless context.respond_to?(:user)
|
56
|
+
raise ArgumentError, "Expected :context to respond to :user" unless context.respond_to?(:user)
|
42
57
|
|
43
58
|
client = @real_time_client.web_client
|
44
59
|
info = client.users_info(user: context.user)
|
@@ -1,7 +1,48 @@
|
|
1
1
|
module Socrates
|
2
2
|
module Adapters
|
3
|
+
#
|
4
|
+
# Response, User, Profile are POROs that represent keys concepts that exist in Slack (or other chat systems).
|
5
|
+
#
|
3
6
|
Response = Struct.new(:members)
|
4
|
-
|
5
|
-
|
7
|
+
|
8
|
+
User = Struct.new(:id, :name, :tz_offset, :profile) do
|
9
|
+
def real_name
|
10
|
+
return "" if profile.nil?
|
11
|
+
|
12
|
+
"#{profile.first_name} #{profile.last_name}"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
Profile = Struct.new(:first_name, :last_name, :email)
|
17
|
+
|
18
|
+
#
|
19
|
+
# StubUserDirectory provides some simple stub behavior for adding stubbed users and querying against them. This are
|
20
|
+
# to be used by stubbed versions of adapters (like Console, Memory, etc).
|
21
|
+
#
|
22
|
+
module StubUserDirectory
|
23
|
+
attr_accessor :email, :users
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@users = []
|
27
|
+
end
|
28
|
+
|
29
|
+
# rubocop:disable Metrics/ParameterLists
|
30
|
+
def add_user(id: nil, name: nil, first: nil, last: nil, email: nil, tz_offset: 0)
|
31
|
+
users << User.new(id, name, tz_offset, Profile.new(first, last, email))
|
32
|
+
end
|
33
|
+
# rubocop:enable Metrics/ParameterLists
|
34
|
+
|
35
|
+
def users_list(*)
|
36
|
+
Response.new(users)
|
37
|
+
end
|
38
|
+
|
39
|
+
def lookup_user(email:)
|
40
|
+
users.find { |user| email == user.profile&.email }
|
41
|
+
end
|
42
|
+
|
43
|
+
def lookup_email(*)
|
44
|
+
email
|
45
|
+
end
|
46
|
+
end
|
6
47
|
end
|
7
48
|
end
|
@@ -20,31 +20,72 @@ module Socrates
|
|
20
20
|
@error_message = Socrates.config.error_message || DEFAULT_ERROR_MESSAGE
|
21
21
|
end
|
22
22
|
|
23
|
-
# rubocop:disable Metrics/AbcSize
|
24
23
|
def dispatch(message, context: {})
|
25
|
-
|
24
|
+
client_id = @adapter.client_id_from(context: context)
|
25
|
+
channel = @adapter.channel_from(context: context)
|
26
26
|
|
27
|
-
client_id
|
27
|
+
do_dispatch(message, client_id, channel)
|
28
|
+
end
|
29
|
+
|
30
|
+
def start_conversation(user, state_id)
|
31
|
+
client_id = @adapter.client_id_from(user: user)
|
32
|
+
channel = @adapter.channel_from(user: user)
|
33
|
+
|
34
|
+
return false unless conversation_state(user).nil?
|
35
|
+
|
36
|
+
# Create state data to match the request.
|
37
|
+
state_data = Socrates::Core::StateData.new(state_id: state_id, state_action: :ask)
|
38
|
+
|
39
|
+
persist_state_data(client_id, state_data)
|
40
|
+
|
41
|
+
do_dispatch(nil, client_id, channel)
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
def conversation_state(user)
|
46
|
+
client_id = @adapter.client_id_from(user: user)
|
47
|
+
|
48
|
+
return nil unless @storage.has_key?(client_id)
|
28
49
|
|
29
|
-
|
50
|
+
begin
|
51
|
+
snapshot = @storage.get(client_id)
|
52
|
+
state_data = StateData.deserialize(snapshot)
|
53
|
+
state_data = nil if state_data_expired?(state_data)
|
54
|
+
rescue => e
|
55
|
+
@logger.warn "Error while fetching state_data for client id '#{client_id}'."
|
56
|
+
@logger.warn e
|
57
|
+
state_data = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
state_data
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
DEFAULT_ERROR_MESSAGE = "Sorry, an error occurred. We'll have to start over..."
|
66
|
+
|
67
|
+
def do_dispatch(message, client_id, channel)
|
68
|
+
message = message&.strip
|
69
|
+
|
70
|
+
@logger.info %Q(#{client_id} recv: "#{message}")
|
30
71
|
|
31
72
|
# In many cases, a two actions will run in this loop: :listen => :ask, but it's possible that a chain of 2 or
|
32
73
|
# more :ask actions could run, before stopping at a :listen (and waiting for the next input).
|
33
74
|
loop do
|
34
|
-
state_data =
|
35
|
-
state = instantiate_state(state_data,
|
75
|
+
state_data = fetch_state_data(client_id)
|
76
|
+
state = instantiate_state(state_data, channel)
|
36
77
|
|
37
78
|
args = [state.data.state_action]
|
38
79
|
args << message if state.data.state_action == :listen
|
39
80
|
|
40
|
-
msg =
|
41
|
-
msg += %( / message: "#{args.second}") if args.count > 1
|
81
|
+
msg = "#{client_id} processing :#{state.data.state_id} / :#{args.first}"
|
82
|
+
msg += %Q( / message: "#{args.second}") if args.count > 1
|
42
83
|
@logger.debug msg
|
43
84
|
|
44
85
|
begin
|
45
86
|
state.send(*args)
|
46
87
|
rescue => e
|
47
|
-
handle_action_error(e, client_id, state,
|
88
|
+
handle_action_error(e, client_id, state, channel)
|
48
89
|
return
|
49
90
|
end
|
50
91
|
|
@@ -52,28 +93,23 @@ module Socrates
|
|
52
93
|
state.data.state_id = state.next_state_id
|
53
94
|
state.data.state_action = state.next_state_action
|
54
95
|
|
55
|
-
@logger.debug
|
96
|
+
@logger.debug "#{client_id} transition to :#{state.data.state_id} / :#{state.data.state_action}"
|
56
97
|
|
57
|
-
|
98
|
+
persist_state_data(client_id, state.data)
|
58
99
|
|
59
100
|
# Break from the loop if there's nothing left to do, i.e. no more state transitions.
|
60
101
|
break if done_transitioning?(state)
|
61
102
|
end
|
62
103
|
end
|
63
|
-
# rubocop:enable Metrics/AbcSize
|
64
|
-
|
65
|
-
private
|
66
|
-
|
67
|
-
DEFAULT_ERROR_MESSAGE = "Sorry, an error occurred. We'll have to start over..."
|
68
104
|
|
69
105
|
# rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
70
|
-
def
|
106
|
+
def fetch_state_data(client_id)
|
71
107
|
if @storage.has_key?(client_id)
|
72
108
|
begin
|
73
109
|
snapshot = @storage.get(client_id)
|
74
110
|
state_data = StateData.deserialize(snapshot)
|
75
111
|
rescue => e
|
76
|
-
@logger.warn "Error while fetching
|
112
|
+
@logger.warn "Error while fetching state_data for client id '#{client_id}', resetting state: #{e.message}"
|
77
113
|
@logger.warn e
|
78
114
|
end
|
79
115
|
end
|
@@ -100,19 +136,19 @@ module Socrates
|
|
100
136
|
end
|
101
137
|
# rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
|
102
138
|
|
103
|
-
def
|
139
|
+
def persist_state_data(client_id, state_data)
|
104
140
|
state_data.reset_elapsed_time
|
105
141
|
@storage.put(client_id, state_data.serialize)
|
106
142
|
end
|
107
143
|
|
108
144
|
def state_data_expired?(state_data)
|
109
|
-
return unless state_data.
|
145
|
+
return false unless state_data.last_interaction_timestamp.present?
|
110
146
|
|
111
147
|
state_data.elapsed_time > (Socrates.config.expired_timeout || 30.minutes)
|
112
148
|
end
|
113
149
|
|
114
|
-
def instantiate_state(state_data,
|
115
|
-
@state_factory.build(state_data: state_data, adapter: @adapter,
|
150
|
+
def instantiate_state(state_data, channel)
|
151
|
+
@state_factory.build(state_data: state_data, adapter: @adapter, channel: channel)
|
116
152
|
end
|
117
153
|
|
118
154
|
def done_transitioning?(state)
|
@@ -123,16 +159,16 @@ module Socrates
|
|
123
159
|
state.data.state_id.nil? || state.data.state_id == State::END_OF_CONVERSATION
|
124
160
|
end
|
125
161
|
|
126
|
-
def handle_action_error(e, client_id, state,
|
162
|
+
def handle_action_error(e, client_id, state, channel)
|
127
163
|
@logger.warn "Error while processing action #{state.data.state_id}/#{state.data.state_action}: #{e.message}"
|
128
164
|
@logger.warn e
|
129
165
|
|
130
|
-
@adapter.send_message(@error_message,
|
166
|
+
@adapter.send_message(@error_message, channel)
|
131
167
|
state.data.clear
|
132
168
|
state.data.state_id = nil
|
133
169
|
state.data.state_action = nil
|
134
170
|
|
135
|
-
|
171
|
+
persist_state_data(client_id, state.data)
|
136
172
|
end
|
137
173
|
end
|
138
174
|
end
|
data/lib/socrates/core/state.rb
CHANGED
@@ -11,10 +11,10 @@ module Socrates
|
|
11
11
|
module State
|
12
12
|
attr_reader :data, :context
|
13
13
|
|
14
|
-
def initialize(data: StateData.new, adapter:,
|
14
|
+
def initialize(data: StateData.new, adapter:, channel:)
|
15
15
|
@data = data
|
16
16
|
@adapter = adapter
|
17
|
-
@
|
17
|
+
@channel = channel
|
18
18
|
@next_state_id = nil
|
19
19
|
@next_state_action = nil
|
20
20
|
@logger = Socrates.config.logger || Socrates::Logger.default
|
@@ -46,15 +46,15 @@ module Socrates
|
|
46
46
|
|
47
47
|
return if message.empty?
|
48
48
|
|
49
|
-
@logger.info %(#{
|
50
|
-
@adapter.send_message(message,
|
49
|
+
@logger.info %Q(#{@channel} send: "#{format_for_logging(message)}")
|
50
|
+
@adapter.send_message(message, @channel)
|
51
51
|
end
|
52
52
|
|
53
53
|
def send_message(to:, message:)
|
54
54
|
displayable_to = to.respond_to?(:id) ? to.id : to
|
55
55
|
|
56
|
-
@logger.info %(#{
|
57
|
-
@adapter.send_direct_message(message, to
|
56
|
+
@logger.info %Q(#{@channel} send direct to #{displayable_to}: "#{format_for_logging(message)}")
|
57
|
+
@adapter.send_direct_message(message, to)
|
58
58
|
end
|
59
59
|
|
60
60
|
def transition_to(state_id, action: nil, data: {})
|
@@ -102,10 +102,6 @@ module Socrates
|
|
102
102
|
(%i[ask listen] - [current_action]).first
|
103
103
|
end
|
104
104
|
|
105
|
-
def client_id
|
106
|
-
@adapter.client_id_from_context(@context)
|
107
|
-
end
|
108
|
-
|
109
105
|
def format_for_logging(message)
|
110
106
|
message.gsub("\n", "\\n")
|
111
107
|
end
|
@@ -6,22 +6,21 @@ require "active_support/core_ext/time"
|
|
6
6
|
module Socrates
|
7
7
|
module Core
|
8
8
|
class StateData
|
9
|
-
attr_accessor :state_id, :state_action, :
|
9
|
+
attr_accessor :state_id, :state_action, :last_interaction_timestamp
|
10
10
|
|
11
|
-
def initialize(state_id: nil, state_action: nil, data: {}
|
11
|
+
def initialize(state_id: nil, state_action: nil, data: {})
|
12
12
|
@state_id = state_id
|
13
13
|
@state_action = state_action
|
14
|
-
@timestamp = Time.current
|
15
14
|
@data = data
|
16
|
-
@temporary_keys =
|
15
|
+
@temporary_keys = []
|
17
16
|
end
|
18
17
|
|
19
18
|
def elapsed_time
|
20
|
-
Time.current - @
|
19
|
+
Time.current - @last_interaction_timestamp
|
21
20
|
end
|
22
21
|
|
23
22
|
def reset_elapsed_time
|
24
|
-
@
|
23
|
+
@last_interaction_timestamp = Time.current
|
25
24
|
end
|
26
25
|
|
27
26
|
def keys
|
@@ -14,11 +14,11 @@ module Socrates
|
|
14
14
|
:expired
|
15
15
|
end
|
16
16
|
|
17
|
-
def build(state_data:, adapter:,
|
17
|
+
def build(state_data:, adapter:, channel:)
|
18
18
|
classname = StringHelpers.underscore_to_classname(state_data.state_id)
|
19
19
|
|
20
20
|
Object.const_get("Socrates::SampleStates::#{classname}")
|
21
|
-
.new(data: state_data, adapter: adapter,
|
21
|
+
.new(data: state_data, adapter: adapter, channel: channel)
|
22
22
|
end
|
23
23
|
end
|
24
24
|
|
data/lib/socrates/version.rb
CHANGED
data/socrates.gemspec
CHANGED
@@ -28,7 +28,7 @@ Gem::Specification.new do |spec|
|
|
28
28
|
spec.add_development_dependency "bundler", "~> 1.14"
|
29
29
|
spec.add_development_dependency "rake", "~> 10.5"
|
30
30
|
spec.add_development_dependency "rspec", "~> 3.5"
|
31
|
-
spec.add_development_dependency "rubocop", "~> 0.
|
31
|
+
spec.add_development_dependency "rubocop", "~> 0.49.1"
|
32
32
|
spec.add_development_dependency "timecop", "~> 0.8.1"
|
33
33
|
|
34
34
|
spec.add_dependency "activesupport", ">= 5.0.2"
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: socrates
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.11
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Christian Nelson
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-
|
11
|
+
date: 2017-06-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -58,14 +58,14 @@ dependencies:
|
|
58
58
|
requirements:
|
59
59
|
- - "~>"
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: 0.
|
61
|
+
version: 0.49.1
|
62
62
|
type: :development
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
|
-
version: 0.
|
68
|
+
version: 0.49.1
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
70
|
name: timecop
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|