waylon-core 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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