waylon-core 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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +11 -0
  3. data/.roxanne.yml +4 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +38 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +5 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +137 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +57 -0
  12. data/Rakefile +23 -0
  13. data/bin/console +17 -0
  14. data/bin/setup +8 -0
  15. data/lib/waylon/base_component.rb +124 -0
  16. data/lib/waylon/condition.rb +64 -0
  17. data/lib/waylon/conditions/default.rb +34 -0
  18. data/lib/waylon/conditions/permission_denied.rb +34 -0
  19. data/lib/waylon/conditions/regex.rb +22 -0
  20. data/lib/waylon/config.rb +134 -0
  21. data/lib/waylon/core.rb +46 -0
  22. data/lib/waylon/exceptions/not_implemented_error.rb +9 -0
  23. data/lib/waylon/exceptions/validation_error.rb +9 -0
  24. data/lib/waylon/generic_exception.rb +7 -0
  25. data/lib/waylon/group.rb +70 -0
  26. data/lib/waylon/logger.rb +41 -0
  27. data/lib/waylon/message.rb +17 -0
  28. data/lib/waylon/route.rb +71 -0
  29. data/lib/waylon/routes/default.rb +17 -0
  30. data/lib/waylon/routes/permission_denied.rb +17 -0
  31. data/lib/waylon/rspec/matchers/route_matcher.rb +43 -0
  32. data/lib/waylon/rspec/skill.rb +71 -0
  33. data/lib/waylon/rspec/test_channel.rb +83 -0
  34. data/lib/waylon/rspec/test_message.rb +63 -0
  35. data/lib/waylon/rspec/test_sense.rb +110 -0
  36. data/lib/waylon/rspec/test_server.rb +120 -0
  37. data/lib/waylon/rspec/test_user.rb +165 -0
  38. data/lib/waylon/rspec/test_worker.rb +15 -0
  39. data/lib/waylon/rspec.rb +50 -0
  40. data/lib/waylon/sense.rb +74 -0
  41. data/lib/waylon/sense_registry.rb +30 -0
  42. data/lib/waylon/skill.rb +132 -0
  43. data/lib/waylon/skill_registry.rb +74 -0
  44. data/lib/waylon/skills/default.rb +48 -0
  45. data/lib/waylon/skills/fun.rb +26 -0
  46. data/lib/waylon/user.rb +61 -0
  47. data/lib/waylon/version.rb +12 -0
  48. data/lib/waylon/webhook.rb +73 -0
  49. data/lib/waylon.rb +5 -0
  50. data/scripts/test.sh +5 -0
  51. data/waylon-core.gemspec +50 -0
  52. metadata +312 -0
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module RSpec
5
+ # A test Message class
6
+ class TestMessage
7
+ include Waylon::Message
8
+
9
+ attr_reader :id
10
+
11
+ # Simple way to list all TestMessages
12
+ # @return [Array<TestMessage>]
13
+ def self.all
14
+ TestSense.message_list.each_index.map { |id| new(id) }
15
+ end
16
+
17
+ # @param message_id [Integer] The Message ID for the new TestMessage
18
+ # @param details [Hash] Details hash for the new TestMessage
19
+ def initialize(message_id, details = {})
20
+ @id = message_id.to_i
21
+ @details = details
22
+ end
23
+
24
+ # Provides the user that authored the message
25
+ # @return [TestUser]
26
+ def author
27
+ TestUser.new(details[:user_id])
28
+ end
29
+
30
+ # Easy access to when the TestMessage was created
31
+ # @return [Time]
32
+ def created_at
33
+ details[:created_at]
34
+ end
35
+
36
+ # Is this a private message?
37
+ # @return [Boolean]
38
+ def private_message?
39
+ details[:type] == :private
40
+ end
41
+
42
+ # The TestChannel where this TestMessage lives
43
+ # @return [TestChannel]
44
+ def channel
45
+ TestChannel.new(details[:channel_id])
46
+ end
47
+
48
+ # The Message content
49
+ # @return [String]
50
+ def text
51
+ details[:text]
52
+ end
53
+
54
+ # Lazily provides the details for TestMessages
55
+ # @api private
56
+ # @return [Hash] The details for this TestMessage instance
57
+ def details
58
+ @details = TestSense.message_list[id] if @details.empty?
59
+ @details.dup
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "waylon/rspec/matchers/route_matcher"
4
+
5
+ module Waylon
6
+ module RSpec
7
+ # Extras for RSpec to facilitate testing Waylon Skills
8
+ class TestSense < Sense
9
+ features :reactions
10
+
11
+ def self.add_user_from_details(details)
12
+ user_id = user_list.size
13
+ user = user_class.new(user_id, details)
14
+ user_list << user
15
+ user
16
+ end
17
+
18
+ # The list of TestChannel IDs for this TestSense
19
+ # @return [Array<Integer>]
20
+ def self.channel_list
21
+ @channel_list ||= []
22
+ end
23
+
24
+ # Overrides the Sense.enqueue class method to avoid Resque
25
+ def self.enqueue(route, request_id, body)
26
+ details = {
27
+ "sense" => self,
28
+ "message" => request_id,
29
+ "tokens" => route.tokens(body.strip)
30
+ }
31
+
32
+ fake_queue.push [route.destination, route.action, details]
33
+ end
34
+
35
+ # Allows access to the fake version of Resque
36
+ # @return [Queue]
37
+ def self.fake_queue
38
+ @fake_queue ||= Queue.new
39
+ end
40
+
41
+ # Ensures we're using the TestMessage class for Messages
42
+ # @return [Class]
43
+ def self.message_class
44
+ RSpec::TestMessage
45
+ end
46
+
47
+ # The list of message details that were sent through this Sense
48
+ # @return [Array<Hash>]
49
+ def self.message_list
50
+ @message_list ||= []
51
+ end
52
+
53
+ # Receives incoming message details and places work on a queue to be performed by a Skill
54
+ # @param message_details [Hash] The details necessary for creating a TestMessage
55
+ # @return [void]
56
+ def self.process(message_details)
57
+ message_list << message_details
58
+ message_id = message_list.size - 1
59
+ msg = message_class.new(message_id)
60
+ route = SkillRegistry.instance.route(msg) || SkillRegistry.instance.default_route
61
+ enqueue(route, msg.id, msg.text)
62
+ end
63
+
64
+ # Emulates reactions by sending a message with the reaction type
65
+ # @param request [Integer] A reference (message ID) of the initial request
66
+ # @param type [Symbol,String] The type of reaction to send
67
+ # @return [void]
68
+ def self.react(request, type)
69
+ msg = message_class.new(request)
70
+ msg.channel.post_message(":#{type}:")
71
+ end
72
+
73
+ # Provides all message text sent _to_ Waylon
74
+ # @return [Array<String>]
75
+ def self.received_messages
76
+ message_list.reject { |m| m[:user_id] == TestUser.whoami.id }.map { |m| m[:text] }
77
+ end
78
+
79
+ # Posts a reply to the channel
80
+ # @param request [Integer] A reference (message ID) of the initial request
81
+ # @param text [String] The message content to send in response to the request
82
+ # @return [void]
83
+ def self.reply(request, text)
84
+ msg = message_class.new(request)
85
+ msg.channel.post_message(text)
86
+ end
87
+
88
+ # Provides all message text sent _by_ Waylon
89
+ # @return [Array<String>]
90
+ def self.sent_messages
91
+ message_list.select { |m| m[:user_id] == TestUser.whoami.id }.map { |m| m[:text] }
92
+ end
93
+
94
+ # Ensures we're using the TestUser class for Users
95
+ # @return [Class]
96
+ def self.user_class
97
+ RSpec::TestUser
98
+ end
99
+
100
+ # The list of Users for this TestSense
101
+ # @return [Array<User>]
102
+ def self.user_list
103
+ @user_list ||= []
104
+ end
105
+
106
+ # Automatically informs Waylon about this Sense
107
+ SenseRegistry.register(:test, self)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+ require "rspec/expectations"
5
+ require "rspec/mocks"
6
+
7
+ major, *_unused = RSpec::Core::Version::STRING.split(/\./)
8
+ abort "RSpec 3 or greater required" if major.to_i < 3
9
+
10
+ require "waylon/core"
11
+ require "waylon/skills/default"
12
+ require "waylon/rspec/skill"
13
+ require "waylon/rspec/test_channel"
14
+ require "waylon/rspec/test_message"
15
+ require "waylon/rspec/test_sense"
16
+ require "waylon/rspec/test_user"
17
+ require "waylon/rspec/test_worker"
18
+
19
+ config = Waylon::Config.instance
20
+ config.load_env
21
+
22
+ Waylon::Cache = Moneta.new(:Cookie)
23
+ Waylon::Storage = Moneta.new(:LRUHash)
24
+ Waylon::Logger.log("Found Global Admins: #{config.admins}")
25
+
26
+ Waylon::RSpec::TestUser.find_or_create(
27
+ name: "Waylon Smithers",
28
+ email: "waylon.smithers@example.com"
29
+ )
30
+
31
+ # This is the user for test chats
32
+ if ENV["USER_EMAIL"]
33
+ Waylon::RSpec::TestUser.find_or_create(
34
+ name: "Home Simpson",
35
+ email: ENV["USER_EMAIL"]
36
+ )
37
+ else
38
+ Waylon::RSpec::TestUser.find_or_create(name: "Homer Simpson")
39
+ end
40
+
41
+ Waylon::RSpec::TestChannel.find_or_create("random")
42
+
43
+ # Load demo skills here
44
+ require "waylon/skills/fun"
45
+
46
+ # Handle demo chat REPL
47
+ def adminuser
48
+ @adminuser ||= Waylon::RSpec::TestUser.find_or_create(name: "Charles Montgomery Burns", handle: "monty")
49
+ Waylon::Group.new("admins").add(@adminuser)
50
+ @adminuser
51
+ end
52
+
53
+ def chatroom
54
+ @chatroom ||= Waylon::RSpec::TestChannel.new(0)
55
+ end
56
+
57
+ def handle_exit_input
58
+ if @admin_enabled
59
+ puts "Switching back to a normal user."
60
+ @admin_enabled = false
61
+ else
62
+ puts "Talk to you later!"
63
+ exit
64
+ end
65
+ end
66
+
67
+ def msg_details(body, from, privately)
68
+ details = {
69
+ user_id: from.id,
70
+ text: body,
71
+ created_at: Time.now
72
+ }
73
+ if privately
74
+ details[:type] = :private
75
+ details[:receiver_id] = robot.id
76
+ else
77
+ details[:type] = :channel
78
+ details[:channel_id] = channel ? channel.id : chatroom.id
79
+ end
80
+ details
81
+ end
82
+
83
+ def robot
84
+ @robot ||= Waylon::RSpec::TestUser.new(0)
85
+ end
86
+
87
+ def testuser
88
+ @testuser ||= Waylon::RSpec::TestUser.new(1)
89
+ end
90
+
91
+ def this_user
92
+ @admin_enabled ? adminuser : testuser
93
+ end
94
+
95
+ def handle_input(body, from: this_user, privately: true)
96
+ if %w[bye exit leave quit].include?(body)
97
+ handle_exit_input
98
+ elsif ["su", "su -", "su admin", "su - admin"].include?(body)
99
+ puts 'Admin enabled! Use "exit" to go back to a normal user.'
100
+ @admin_enabled = true
101
+ else
102
+ message_count = Waylon::RSpec::TestSense.sent_messages.size
103
+
104
+ Waylon::RSpec::TestSense.process(msg_details(body, from, privately))
105
+ Waylon::RSpec::TestWorker.handle(Waylon::RSpec::TestSense.fake_queue)
106
+ result = Waylon::RSpec::TestSense.sent_messages[message_count..].join("\n")
107
+ puts("(@#{Waylon::RSpec::TestUser.whoami.handle}) >> #{result}")
108
+ end
109
+ end
110
+
111
+ # A lambda for a test chat server
112
+ repl = lambda do |prompt|
113
+ print prompt
114
+ handle_input($stdin.gets.chomp!)
115
+ end
116
+
117
+ loop do
118
+ @admin_enabled ||= false
119
+ repl["(@#{this_user.handle}) << "]
120
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module RSpec
5
+ # Extras for RSpec to facilitate testing Waylon (by creating fake Users)
6
+ class TestUser
7
+ include Waylon::User
8
+
9
+ attr_reader :id
10
+
11
+ # Looks up a User by their email
12
+ # @param email [String] The User's email
13
+ # @return [User,nil] The found User
14
+ def self.find_by_email(email)
15
+ TestSense.user_list.find { |user| user.email == email }
16
+ end
17
+
18
+ # Looks up a User by their IM handle
19
+ # @param handle [String] The User's handle
20
+ # @return [User,nil] The found User
21
+ def self.find_by_handle(handle)
22
+ TestSense.user_list.find { |user| user.handle == handle }
23
+ end
24
+
25
+ # Looks up a User by their full name
26
+ # @param name [String] The User's name
27
+ # @return [User,nil] The found User
28
+ def self.find_by_name(name)
29
+ TestSense.user_list.find { |user| user.display_name == name }
30
+ end
31
+
32
+ # Looks up existing or creates a new User based on their full name, email, or handle
33
+ # @param name [String] The full name of the User
34
+ # @param email [String] The User's email
35
+ # @param handle [String] The User's handle
36
+ # @return [User,Boolean]
37
+ # rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
38
+ def self.find_or_create(name: nil, email: nil, handle: nil)
39
+ return false unless name || email || handle # have to provide _something_
40
+
41
+ existing_user = find_by_email(email) || find_by_handle(handle) || find_by_name(name)
42
+ if existing_user
43
+ existing_user
44
+ else
45
+ this_name = name || random_name # if no name was provided, make one up
46
+ details = {
47
+ email: email || email_from_name(this_name),
48
+ handle: handle || handle_from_name(this_name),
49
+ name: this_name,
50
+ status: :online
51
+ }
52
+ # Need to give up if we've generated a duplicate
53
+ if find_by_email(details[:email]) || find_by_handle(details[:handle]) || find_by_name(details[:name])
54
+ return false
55
+ end
56
+
57
+ TestSense.add_user_from_details(details)
58
+ end
59
+ end
60
+ # rubocop:enable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
61
+
62
+ # Provides a random human-sounding full name for test users
63
+ # @return [String]
64
+ def self.random_name
65
+ first_names = %w[
66
+ Abraham Al Alex Barbara Barry Bob Brenda Chloe Chuck Daniel Dave Eliza Felicia Frank
67
+ Francis Glen Graham Greg Hal Jackie Jacob Jessica Jonathan Julie Maria Marcia Nikhil
68
+ Olivia Patrick Paul Reggie Robby Roger Sam Saul Sean Tim Todd Tristan Xavier Zack
69
+ ]
70
+ last_names = %w[
71
+ Adams Andrews Bailey Brooks Brown Bush Cervantes Chen Collins Crooks Dean Franz Harris
72
+ Jackson Jimenez Jones Jordan Laflor Lopez Gonzalez McDowell Miller Ng Odinson Reed
73
+ Roberts Rodriguez Sanders Schmidt Scott Smith Stewart Taylor Tesla Torres Turner
74
+ Walker Ward Warner White Williams Wilson Wong Young Zeta Zimmerman
75
+ ]
76
+
77
+ "#{first_names.sample} #{last_names.sample}"
78
+ end
79
+
80
+ # Gives back the TestUser for the bot
81
+ # @return [TestUser] Waylon's User instance
82
+ def self.whoami
83
+ find_by_email("waylon.smithers@example.com")
84
+ end
85
+
86
+ # @param user_id [Integer] The ID of the user in the TestSense's user list
87
+ # @param details [Hash] Optional User details (can be looked up later)
88
+ def initialize(user_id, details = {})
89
+ @id = user_id.to_i
90
+ @details = details
91
+ end
92
+
93
+ # The User's full name (:user from the details Hash)
94
+ # @return [String]
95
+ def display_name
96
+ details[:name]
97
+ end
98
+
99
+ # The User's email address
100
+ # @return [String]
101
+ def email
102
+ details[:email]
103
+ end
104
+
105
+ # Sends a direct TestMessage to a User
106
+ # @param content [String] The message content to send
107
+ # @return [TestMessage]
108
+ def private_message(content)
109
+ msg = {
110
+ user_id: self.class.whoami.id,
111
+ receiver_id: id,
112
+ text: content,
113
+ type: :private,
114
+ created_at: Time.now
115
+ }
116
+ TestSense.message_list << msg
117
+ TestMessage.new(TestSense.message_list.size - 1)
118
+ end
119
+
120
+ # The User's handle
121
+ # @return [String]
122
+ def handle
123
+ details[:handle]
124
+ end
125
+
126
+ # The User's current status
127
+ # @return [Symbol]
128
+ def status
129
+ details[:status]
130
+ end
131
+
132
+ # Is the User valid?
133
+ # @return [Boolean]
134
+ def valid?
135
+ true
136
+ end
137
+
138
+ # Lazily provides the details for a TestUser
139
+ # @api private
140
+ # @return [Hash] Details for this instance
141
+ def details
142
+ @details = TestSense.user_list[id].details if @details.empty?
143
+ @details.dup
144
+ end
145
+
146
+ # Creates an email address based on the name provided
147
+ # @api private
148
+ # @return [String] A generated email address
149
+ def self.email_from_name(name)
150
+ if ENV["USER_EMAIL"] && name == "homer.simpson"
151
+ ENV["USER_EMAIL"]
152
+ else
153
+ "#{name.downcase.gsub(/[\s_-]/, ".")}@example.com"
154
+ end
155
+ end
156
+
157
+ # Creates a handle from a name
158
+ # @api private
159
+ # @return [String] A generated user handle
160
+ def self.handle_from_name(name)
161
+ name.downcase.split(/[\s_-]/).first
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module RSpec
5
+ # A TestWorker to run queued Skills
6
+ class TestWorker
7
+ # Instructs the worker to grab an item off the Queue and run it
8
+ # @param queue [Queue] The queue that contains work to be done
9
+ def self.handle(queue)
10
+ skill, action, details = queue.pop
11
+ skill.perform(action, details)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec"
4
+ require "rspec/expectations"
5
+ require "rspec/mocks"
6
+
7
+ major, *_unused = RSpec::Core::Version::STRING.split(/\./)
8
+ abort "RSpec 3 or greater required" if major.to_i < 3
9
+
10
+ require "moneta"
11
+ require "set"
12
+
13
+ require "waylon/core"
14
+ require "waylon/skills/default"
15
+ require "waylon/rspec/skill"
16
+ require "waylon/rspec/test_channel"
17
+ require "waylon/rspec/test_message"
18
+ require "waylon/rspec/test_sense"
19
+ require "waylon/rspec/test_user"
20
+ require "waylon/rspec/test_worker"
21
+
22
+ module Waylon
23
+ # RSpec stuff that allows specialized Waylon testing
24
+ module RSpec
25
+ class << self
26
+ # @param base [Object] The class including the module.
27
+ # @return [void]
28
+ def included(base)
29
+ base.class_eval do
30
+ before do
31
+ config = Waylon::Config.instance
32
+ config.load_env
33
+ Waylon::Cache.clear
34
+ Waylon::Storage.clear
35
+
36
+ Waylon::RSpec::TestChannel.find_or_create("random")
37
+ Waylon::RSpec::TestUser.find_or_create(
38
+ name: "Waylon Smithers",
39
+ email: "waylon.smithers@example.com"
40
+ )
41
+ Waylon::RSpec::TestUser.find_or_create(name: "Homer Simpson")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ Waylon::Cache = Moneta.new(:Cookie)
50
+ Waylon::Storage = Moneta.new(:Cookie)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # Base class for Senses (Usually messaging providers like Slack)
5
+ class Sense
6
+ include BaseComponent
7
+
8
+ # Almost always meant to be overridden, this is how the Sense wraps text in a code block
9
+ # @param text [String] The string to codify
10
+ # @return [String] Properly wrapped content
11
+ def self.codify(text)
12
+ "```\n#{text}```"
13
+ end
14
+
15
+ # Config namespace for config keys
16
+ # @return [String] The namespace for config keys
17
+ def self.config_namespace
18
+ "senses.#{component_namespace}"
19
+ end
20
+
21
+ # The connection between Senses and Skills happens here, via a Route and a Hash of details
22
+ # @param route [Route] route The matching Route from the SkillRegistry
23
+ # @param request_id [String] The ID (from the messaging platform) of the request
24
+ # @param body [String] Message content for the Skill
25
+ # @api private
26
+ def self.enqueue(route, request_id, body)
27
+ details = {
28
+ "sense" => self,
29
+ "message" => request_id,
30
+ "tokens" => route.tokens(body.strip)
31
+ }
32
+
33
+ Resque.enqueue route.destination, route.action, details
34
+ end
35
+
36
+ # Provides a simple mechanism for referencing the Group subclass provided by this Sense
37
+ # @return [Class] A Group subclass
38
+ def self.group_class
39
+ Group
40
+ end
41
+
42
+ # "At-mention" a User via the Sense. This is usually overridden on Sense subclasses.
43
+ # @param user [Waylon::User] The User to mention
44
+ # @return [String]
45
+ def self.mention(user)
46
+ "@#{user.handle}"
47
+ end
48
+
49
+ # Provides a simple mechanism for referencing the Message subclass provided by this Sense
50
+ # @return [Class] A Message subclass
51
+ def self.message_class
52
+ Message
53
+ end
54
+
55
+ # Called by Resque to actually use this BaseComponent. Hands off to run() method
56
+ # @param content [Hash] The payload hash for use in processing the message
57
+ def self.perform(content)
58
+ run(content)
59
+ end
60
+
61
+ # Redis/Resque queue name
62
+ # @api private
63
+ # @return [Symbol]
64
+ def self.queue
65
+ :senses
66
+ end
67
+
68
+ # Provides a simple mechanism for referencing the User subclass provided by this Sense
69
+ # @return [Class] A User subclass
70
+ def self.user_class
71
+ User
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # Registry of Sense subclasses known to Waylon
5
+ class SenseRegistry
6
+ include Singleton
7
+
8
+ # A convenience wrapper around the singleton instance #register method
9
+ # @param (see #register)
10
+ # @return (see #register)
11
+ def self.register(name, class_name)
12
+ instance.register(name, class_name)
13
+ end
14
+
15
+ # Add the provided Sense class to the registry under `name`
16
+ # @param name [String] The name of the Sense in the registry
17
+ # @param class_name [Class] The Sense subclass to add
18
+ # @return [Class] The Sense subclass
19
+ def register(name, class_name)
20
+ @senses ||= {}
21
+ @senses[name.to_s] = class_name
22
+ end
23
+
24
+ # Provides a Hash version of this registry
25
+ # @return [Hash]
26
+ def to_hash
27
+ (@senses || {}).dup
28
+ end
29
+ end
30
+ end