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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # The global configuration
5
+ class Config
6
+ include Singleton
7
+ extend Forwardable
8
+
9
+ attr_accessor :schema
10
+
11
+ delegate delete: :@config
12
+
13
+ # Stores schema metadata about config items
14
+ # @api private
15
+ # @param key [String] The config key to define a schema for
16
+ # @param required [Boolean] Is this key required on startup?
17
+ # @param type [Class] The class type for the stored value
18
+ # @param default [Object] The optional default value
19
+ # @return [Boolean] Was the schema update successful?
20
+ def add_schema(key, default: nil, required: false, type: String)
21
+ @schema ||= {}
22
+ @schema[key] = { default: default, required: required, type: type }
23
+ true
24
+ end
25
+
26
+ # A list of emails specified via the CONF_GLOBAL_ADMINS environment variable
27
+ # @return [Array<String>] a list of emails
28
+ def admins
29
+ admin_emails = self["global.admins"]
30
+ admin_emails ? admin_emails.split(",") : []
31
+ end
32
+
33
+ # Load in the config from env variables
34
+ # @return [Boolean] Was the configuration loaded?
35
+ def load_env
36
+ @schema ||= {}
37
+ self["global.log.level"] = ENV.fetch("LOG_LEVEL", "info")
38
+ self["global.redis.host"] = ENV.fetch("REDIS_HOST", "redis")
39
+ self["global.redis.port"] = ENV.fetch("REDIS_PORT", "6379")
40
+ ENV.keys.grep(/CONF_/).each do |env_key|
41
+ conf_key = env_key.downcase.split("_")[1..].join(".")
42
+ ::Waylon::Logger.log("Attempting to set #{conf_key} from #{env_key}", :debug)
43
+ self[conf_key] = ENV[env_key]
44
+ end
45
+ true
46
+ end
47
+
48
+ # Provides the redis host used for most of Waylon's brain
49
+ # @return [String] The redis host
50
+ def redis_host
51
+ self["global.redis.host"]
52
+ end
53
+
54
+ # Provides the redis port used for most of Waylon's brain
55
+ # @return [String] The redis host
56
+ def redis_port
57
+ self["global.redis.port"]
58
+ end
59
+
60
+ # Clear the configuration
61
+ # @return [Boolean] Was the configuration reset?
62
+ def reset
63
+ @config = {}
64
+ true
65
+ end
66
+
67
+ # Check if a given key is explicitly set (not including defaults)
68
+ # @param key [String] The key to check
69
+ # @return [Boolean] Is the key set?
70
+ def key?(key)
71
+ @config ||= {}
72
+ @config.key?(key)
73
+ end
74
+
75
+ alias set? key?
76
+
77
+ # Check if a given key is has _any_ value (default or otherwise)
78
+ # @param key [String] The key to look for
79
+ # @return [Boolean] Does the key have a value?
80
+ def value?(key)
81
+ @config ||= {}
82
+ !self[key].nil?
83
+ end
84
+
85
+ # Set the value for a key
86
+ # @param key [String] The key to use for storing the value
87
+ # @param [Object] value The value for the key
88
+ def []=(key, value)
89
+ if (@schema[key] && validate_config(@schema[key], value)) || key.start_with?("global.")
90
+ @config ||= {}
91
+ @config[key] = value
92
+ elsif @schema[key]
93
+ ::Waylon::Logger.log("Ignoring invalid config value for key: #{key}", :warn)
94
+ else
95
+ ::Waylon::Logger.log("Ignoring unknown config key: #{key}", :warn)
96
+ end
97
+ end
98
+
99
+ # Retrieve the value for the given key
100
+ # @param key [String] The key to lookup
101
+ # @return [String,nil] The requested value
102
+ def [](key)
103
+ @config ||= {}
104
+ if @config.key?(key)
105
+ @config[key].dup
106
+ elsif @schema.key?(key) && @schema[key][:default]
107
+ @schema[key][:default].dup
108
+ end
109
+ end
110
+
111
+ # Is the state of the config valid?
112
+ # @return [Boolean]
113
+ def valid?
114
+ missing = @schema.select { |_k, v| v[:required] }.reject { |k, _v| @config[k] }
115
+ missing.each do |key, _value|
116
+ ::Waylon::Logger.log("Missing config: #{key}", :debug)
117
+ end
118
+ missing.empty?
119
+ end
120
+
121
+ # Checks if a value aligns with a schema
122
+ # @param schema [Hash] The schema definition for this value
123
+ # @param value The value to compare to the schema
124
+ # @return [Boolean]
125
+ def validate_config(schema, value)
126
+ type = schema[:type]
127
+ if type == :boolean
128
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
129
+ else
130
+ value.is_a?(type)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Standard Library dependencies
4
+ require "base64"
5
+ require "digest"
6
+ require "English"
7
+ require "fileutils"
8
+ require "forwardable"
9
+ require "logger"
10
+ require "yaml"
11
+ require "singleton"
12
+ require "ostruct"
13
+ require "timeout"
14
+ require "resolv"
15
+ require "set"
16
+
17
+ # External dependencies
18
+ require "addressable/uri"
19
+ require "faraday"
20
+ require "i18n"
21
+ require "json"
22
+ require "moneta"
23
+ require "resque"
24
+
25
+ # Internal requirements
26
+ require "waylon/version"
27
+ require "waylon/config"
28
+ require "waylon/generic_exception"
29
+ require "waylon/exceptions/not_implemented_error"
30
+ require "waylon/exceptions/validation_error"
31
+ require "waylon/group"
32
+ require "waylon/logger"
33
+ require "waylon/base_component"
34
+ require "waylon/condition"
35
+ require "waylon/message"
36
+ require "waylon/route"
37
+ require "waylon/sense_registry"
38
+ require "waylon/sense"
39
+ require "waylon/skill_registry"
40
+ require "waylon/skill"
41
+ require "waylon/user"
42
+ require "waylon/conditions/default"
43
+ require "waylon/conditions/permission_denied"
44
+ require "waylon/conditions/regex"
45
+ require "waylon/routes/default"
46
+ require "waylon/routes/permission_denied"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Exceptions
5
+ # Exception class used to signify a feature that hasn't been implemented
6
+ class NotImplementedError < GenericException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Exceptions
5
+ # Exception class used to signify invalid input
6
+ class ValidationError < GenericException
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # A generic exception super class
5
+ class GenericException < StandardError
6
+ end
7
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # The basic, built-in Group class. Relies on Redis for storage and is managed directly by Waylon.
5
+ # @note This class can be subclassed for external authentication mechanisms per Sense
6
+ class Group
7
+ attr_reader :name
8
+
9
+ # @param name [String,Symbol] The name of the group
10
+ def initialize(name)
11
+ @name = name.to_sym
12
+ end
13
+
14
+ # Add a user to the group
15
+ # @param user [User] User to add
16
+ # @return [Boolean]
17
+ def add(user)
18
+ return false if include?(user)
19
+
20
+ users = members
21
+ users << user.email.downcase
22
+ storage.store(key, users)
23
+ true
24
+ end
25
+
26
+ # Remove a user from the group
27
+ # @param user [User] User to remove
28
+ # @return [Boolean]
29
+ def remove(user)
30
+ return false unless include?(user)
31
+
32
+ users = members
33
+ users.delete(user)
34
+ storage.store(key, users)
35
+ true
36
+ end
37
+
38
+ # Waylon Users in this group
39
+ # @return [Array<User>] The members of this Group
40
+ def members
41
+ # all actions on Group funnel through here, so always make sure the key exists first
42
+ storage.store(key, []) unless storage.key?(key)
43
+
44
+ storage.load(key).sort.uniq
45
+ end
46
+
47
+ alias to_a members
48
+
49
+ # Checks if a user a member
50
+ # @param user [User] User to look for
51
+ # @return [Boolean]
52
+ def include?(user)
53
+ members.include?(user.email.downcase)
54
+ end
55
+
56
+ private
57
+
58
+ # Provides access to the top-level storage singleton
59
+ # @return [Waylon::Storage]
60
+ def storage
61
+ Waylon::Storage
62
+ end
63
+
64
+ # A quick way to find the config/storage key for this Group
65
+ # @return [String]
66
+ def key
67
+ "groups.#{name}"
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # A simple way to abstract logging
5
+ module Logger
6
+ # The log level as defined in the global Config singleton
7
+ # @return [String] The current log level
8
+ def self.level
9
+ Config.instance["global.log.level"]
10
+ end
11
+
12
+ # Abstraction for sending logs to the logger at some level
13
+ # @param [Symbol] level The log level this message corresponds to
14
+ # @param [String] message The message to log at this specified level
15
+ def self.log(message, level = :info)
16
+ logger.send(level, message)
17
+ end
18
+
19
+ # Provides an easy way to access the underlying logger
20
+ # @return [Logger] The Logger instance
21
+ def self.logger
22
+ return @logger if @logger
23
+
24
+ @logger = ::Logger.new($stderr)
25
+ @logger.level = level
26
+ @logger.progname = "Waylon"
27
+ @logger.formatter = proc do |severity, datetime, progname, msg|
28
+ json_data = JSON.dump(
29
+ ts: datetime,
30
+ severity: severity.ljust(5).to_s,
31
+ progname: progname,
32
+ pid: Process.pid,
33
+ message: msg,
34
+ v: Waylon::Core::VERSION
35
+ )
36
+ "#{json_data}\n"
37
+ end
38
+ @logger
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # Abstract Message module
5
+ # @abstract
6
+ module Message
7
+ # Message author (meant to be overwritten by mixing classes)
8
+ def author
9
+ nil
10
+ end
11
+
12
+ # Message channel (meant to be overwritten by mixing classes)
13
+ def channel
14
+ nil
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # A Route is a way of connecting a Sense to the right Skill, allowing for things like
5
+ # permissions, prioritization, and scopes.
6
+ class Route
7
+ extend Forwardable
8
+
9
+ attr_reader :name, :destination, :condition, :priority
10
+
11
+ # @param name [String] The name of this route (for use in logging and exceptions)
12
+ # @param destination [Class] The Skill subclass to send matching requests to
13
+ # @param condition [Condition] The Condition used to see if this Route matches a request
14
+ # @param priority [Integer] The priority value (for resolving conflicts). Highest value wins.
15
+ def initialize(name:, destination:, condition:, priority: 10)
16
+ validate_name(name)
17
+ validate_destination(destination)
18
+ validate_condition(condition)
19
+ validate_priority(priority)
20
+ @name = name
21
+ @destination = destination
22
+ @condition = condition
23
+ @priority = priority
24
+ end
25
+
26
+ delegate %i[action help matches? permits? tokens] => :@condition
27
+
28
+ private
29
+
30
+ # Validates the Condition
31
+ # @param condition [Condition]
32
+ # @raise [Exceptions::ValidationError]
33
+ # @return [Boolean]
34
+ def validate_condition(condition)
35
+ raise Exceptions::ValidationError, "Route condition must be a Condition" unless condition.is_a?(Condition)
36
+
37
+ true
38
+ end
39
+
40
+ # Validates the destination
41
+ # @param destination [Class]
42
+ # @raise [Exceptions::ValidationError]
43
+ # @return [Boolean]
44
+ def validate_destination(destination)
45
+ unless destination.ancestors.include?(Skill)
46
+ raise Exceptions::ValidationError, "Route destination must be a Skill"
47
+ end
48
+
49
+ true
50
+ end
51
+
52
+ # Validates the Route name
53
+ # @param name [String]
54
+ # @raise [Exceptions::ValidationError]
55
+ # @return [Boolean]
56
+ def validate_name(name)
57
+ raise Exceptions::ValidationError, "Route name must be a String" unless name.is_a?(String)
58
+ raise Exceptions::ValidationError, "Route name must not be empty" if name.empty?
59
+
60
+ true
61
+ end
62
+
63
+ # Validates the priority
64
+ # @param priority [Integer]
65
+ # @raise [Exceptions::ValidationError]
66
+ # @return [Boolean]
67
+ def validate_priority(priority)
68
+ raise Exceptions::ValidationError, "Route priority must be between 0 and 99" unless (0..99).include?(priority)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Routes
5
+ # The default route for unrouted messages
6
+ class Default < Route
7
+ def initialize(
8
+ name: "default_route",
9
+ destination: Skills::Default,
10
+ condition: Conditions::Default.new,
11
+ priority: 0
12
+ )
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Routes
5
+ # This route is used when a route exists but the current user doesn't have permission
6
+ class PermissionDenied < Route
7
+ def initialize(
8
+ name: "permission_denied",
9
+ destination: Skills::Default,
10
+ condition: Conditions::PermissionDenied.new,
11
+ priority: 99
12
+ )
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module RSpec
5
+ module Matchers
6
+ # RSpec matchers for Routes defined in Skills
7
+ module RouteMatcher
8
+ extend ::RSpec::Matchers::DSL
9
+
10
+ # Validates that the provided message is routable
11
+ matcher :route do |body|
12
+ match do
13
+ message = chatroom.post_message(body, from: testuser)
14
+
15
+ if defined?(@group)
16
+ # Add the test user to the group
17
+ Group.new(@group.to_s).add(testuser)
18
+ end
19
+
20
+ found_route = SkillRegistry.instance.route(message)
21
+
22
+ if defined?(@method_name)
23
+ # Verify that the route sends to the appropriate place
24
+ found_route &&
25
+ found_route.destination == described_class &&
26
+ found_route.action == @method_name.to_sym
27
+ else
28
+ found_route && found_route.destination == described_class
29
+ end
30
+ end
31
+
32
+ chain :as_member_of do |group|
33
+ @group = group
34
+ end
35
+
36
+ chain :to do |method_name|
37
+ @method_name = method_name
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,71 @@
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
+ module Skill
9
+ include Matchers::RouteMatcher
10
+
11
+ class << self
12
+ # Sets up the RSpec environment
13
+ def included(base)
14
+ base.send(:include, Waylon::RSpec)
15
+
16
+ init_let_blocks(base)
17
+ init_subject(base)
18
+ end
19
+
20
+ private
21
+
22
+ # Create common test objects.
23
+ def init_let_blocks(base)
24
+ base.class_eval do
25
+ let(:bot) { TestUser.new(0) }
26
+ let(:testuser) { TestUser.new(1) }
27
+ let(:chatroom) { TestChannel.new(0) }
28
+ let(:adminuser) do
29
+ @adminuser ||= TestUser.find_or_create(name: "Charles Montgomery Burns", handle: "monty")
30
+ Group.new("admins").add(@adminuser)
31
+ @adminuser
32
+ end
33
+ end
34
+ end
35
+
36
+ # Set up a working test subject.
37
+ def init_subject(base)
38
+ base.class_eval do
39
+ subject { described_class }
40
+ end
41
+ end
42
+ end
43
+
44
+ # An array of strings that have been sent by the bot during throughout a test
45
+ # @return [Array<String>] The replies.
46
+ def replies
47
+ TestSense.sent_messages
48
+ end
49
+
50
+ # Sends a message to the bot
51
+ # @param body [String] The message to send
52
+ # @param from [TestUser] The user sending the message
53
+ # @param channel [TestChannel] Where the message is received
54
+ # @param privately [Boolean] Is the message a DM
55
+ # @return [void]
56
+ def send_message(body, from: testuser, channel: nil, privately: false)
57
+ msg_details = { person_id: from.id, text: body, created_at: Time.now }
58
+ if privately
59
+ msg_details[:type] = :private
60
+ msg_details[:receiver_id] = robot.id
61
+ else
62
+ msg_details[:type] = :channel
63
+ msg_details[:channel_id] = channel ? channel.id : chatroom.id
64
+ end
65
+
66
+ TestSense.process(msg_details)
67
+ TestWorker.handle(TestSense.fake_queue)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module RSpec
5
+ # The TestChannel
6
+ class TestChannel
7
+ attr_reader :id
8
+
9
+ # Simple way to list all TestChannels
10
+ # @return [Array<TestChannel>]
11
+ def self.all
12
+ TestSense.channel_list.each_index.map { |id| new(id) }
13
+ end
14
+
15
+ # Always provides a TestChannel, either by finding an existing or creating a new one
16
+ # @return [TestChannel]
17
+ def self.find_or_create(name)
18
+ existing_channel = find_by_name(name)
19
+ if existing_channel
20
+ existing_channel
21
+ else
22
+ channel_details = { name: name, created_at: Time.now }
23
+ TestSense.channel_list << channel_details
24
+ new(TestSense.channel_list.size - 1)
25
+ end
26
+ end
27
+
28
+ # Looks up an existing TestChannel by name
29
+ # @return [TestChannel,nil]
30
+ def self.find_by_name(name)
31
+ channel_id = TestSense.channel_list.index { |channel| channel[:name] == name }
32
+ channel_id ? new(channel_id) : nil
33
+ end
34
+
35
+ # @param channel_id [Integer] The Channel ID for the new TestChannel
36
+ # @param details [Hash] Details (namely 'name' and 'created_at') for the new TestChannel
37
+ def initialize(channel_id, details = {})
38
+ @id = channel_id.to_i
39
+ @details = details
40
+ end
41
+
42
+ # Easy access to when the TestChannel was created
43
+ # @return [Time]
44
+ def created_at
45
+ details[:created_at]
46
+ end
47
+
48
+ # The name of the TestChannel
49
+ # @return [String]
50
+ def name
51
+ details[:name]
52
+ end
53
+
54
+ # Send a TestMessage to a TestChannel
55
+ # @param content [String,Message] The Message to send
56
+ # @return [Message] The sent Message object
57
+ def post_message(content, from: TestUser.whoami)
58
+ msg = if content.is_a?(Message)
59
+ content.text
60
+ else
61
+ content
62
+ end
63
+ msg_details = {
64
+ user_id: from.id,
65
+ text: msg,
66
+ type: :channel,
67
+ channel_id: id,
68
+ created_at: Time.now
69
+ }
70
+ TestSense.message_list << msg_details
71
+ TestMessage.new(TestSense.message_list.size - 1)
72
+ end
73
+
74
+ # Lazily provides the details for a TestUser
75
+ # @api private
76
+ # @return [Hash] details for this instance
77
+ def details
78
+ @details = TestSense.room_list[id] if @details.empty?
79
+ @details.dup
80
+ end
81
+ end
82
+ end
83
+ end