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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # Skills are what Waylon can do based on inputs from Senses
5
+ class Skill
6
+ include BaseComponent
7
+
8
+ attr_reader :sense, :tokens, :request
9
+
10
+ # Config namespace for config keys
11
+ # @return [String] The namespace for config keys
12
+ def self.config_namespace
13
+ "skills.#{component_namespace}"
14
+ end
15
+
16
+ # Resque uses this to execute the Skill. Just defers to the `action` subclass method
17
+ # @param action [Symbol,String] The method on the Skill subclass to call
18
+ # @param details [Hash] Input details about the message for running a Skill action
19
+ def self.perform(action, details)
20
+ new(details["sense"], details["tokens"], details["message"], details["meta"])
21
+ .send(action.to_sym)
22
+ end
23
+
24
+ # Redis/Resque queue name
25
+ # @api private
26
+ # @return [Symbol]
27
+ def self.queue
28
+ :senses
29
+ end
30
+
31
+ # Adds skills to the SkillRegistry
32
+ # @param condition [Condition,Regexp] The condition that determines if this route applies
33
+ # @param action [Symbol,String] The method on the Skill subclass to call
34
+ # @param allowed_groups [Symbol,Array<Symbol>] The group or list of groups allowed to use this
35
+ # @param help [String] A description of how to use the skill
36
+ def self.route(condition, action, allowed_groups: :everyone, help: nil, name: nil)
37
+ name ||= "#{to_s.split("::").last.downcase}##{action}"
38
+ real_cond = case condition
39
+ when Condition
40
+ condition
41
+ when Regexp
42
+ Conditions::Regex.new(condition, action, allowed_groups, help)
43
+ else
44
+ log("Unknown condition for route for #{name}##{action}", :warn)
45
+ nil
46
+ end
47
+ SkillRegistry.instance.register(name, self, real_cond) if real_cond
48
+ end
49
+
50
+ # @param sense [Class,String] Class (or Class name) of the source Sense
51
+ # @param tokens [Array<String>] Tokenized message content for use in the Skill
52
+ # @param request [String,Integer] Reference to the request from the Sense provider (usually an ID)
53
+ # @param meta [Hash] Optional meta data that can be passed along from a Sense for use in Skills
54
+ def initialize(sense, tokens, request, meta)
55
+ @sense = sense.is_a?(Class) ? sense : Module.const_get(sense)
56
+ @tokens = tokens || []
57
+ @request = request
58
+ @meta = meta
59
+ end
60
+
61
+ # Provides a random entry from a list of canned acknowledgements
62
+ # @return [String]
63
+ def acknowledgement
64
+ responses = [
65
+ "I'll get back to you in just a sec.",
66
+ "You got it!",
67
+ "As you wish.",
68
+ "Certainly!",
69
+ "Sure thing!",
70
+ "Absolutely!",
71
+ "No problem.",
72
+ "Consider it done.",
73
+ "Of course!",
74
+ "I'd be delighted!",
75
+ "Right away!",
76
+ "Gladly",
77
+ "All right.",
78
+ "I'm all over it.",
79
+ "I'm on it!",
80
+ "Let me see what I can do.",
81
+ "Will do!"
82
+ ]
83
+ responses.sample
84
+ end
85
+
86
+ # Provides a simple way to convert some text to a code block in a way the Sense understands
87
+ # @param text [String] The string to codify
88
+ # @return [String] Properly wrapped content
89
+ def codify(text)
90
+ sense.codify(text)
91
+ end
92
+
93
+ # The details used to call this Skill
94
+ # @return [Hash]
95
+ def details
96
+ {
97
+ sense: @sense,
98
+ message: @request,
99
+ tokens: @tokens,
100
+ meta: @meta
101
+ }
102
+ end
103
+
104
+ # Defers to the Sense to determine how to mention a user
105
+ # @param [Waylon::User] user The user to mention
106
+ # @return [String]
107
+ def mention(user)
108
+ sense.mention(user)
109
+ end
110
+
111
+ # Provides a wrapped message for responding to the received Sense
112
+ # @return [Waylon::Message]
113
+ def message
114
+ sense.message_class.new(request)
115
+ end
116
+
117
+ # Defers to the Sense to react to a message
118
+ # @param [String] type The type of reaction to send
119
+ # @return [Boolean,String]
120
+ def react(type)
121
+ return false unless sense.supports?(:reactions)
122
+
123
+ sense.react(request, type)
124
+ end
125
+
126
+ # Defers to the Sense to determine how to reply to a message
127
+ # @param [String] text The reply text
128
+ def reply(text)
129
+ sense.reply(request, text)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # A place to track all skills known to this instance of Waylon
5
+ class SkillRegistry
6
+ include Singleton
7
+
8
+ # A wrapper around the singleton #register method
9
+ # @param name [String] The name of the skill in the registry
10
+ # @param class_name [Class] The class to associate with the name
11
+ # @param condition [Condition] Criteria for whether or not the given subclass applies
12
+ # @return [Route] The created Route instance
13
+ def self.register(name, class_name, condition)
14
+ instance.register(name, class_name, condition)
15
+ end
16
+
17
+ def default_route
18
+ Routes::Default.new
19
+ end
20
+
21
+ # Gathers a Hash of help data for all routes a user is permitted to access
22
+ # @param user [User] The user asking for help
23
+ # @return [Hash]
24
+ def help(user)
25
+ data = {}
26
+ @routes.select { |r| r.permits?(user) }.each do |permitted|
27
+ data[permitted.destination.config_namespace] ||= []
28
+ data[permitted.destination.config_namespace] << { name: permitted.name, help: permitted.help } if permitted.help
29
+ end
30
+
31
+ data.reject { |_k, v| v.empty? }
32
+ end
33
+
34
+ # Simple pass-through for logging through the Waylon Logger
35
+ # @param [String] message The message to log
36
+ # @param [String,Symbol] level The log level for this message
37
+ def log(message, level = :info)
38
+ ::Waylon::Logger.log(message, level)
39
+ end
40
+
41
+ # Add the provided Skill subclass to the registry under `name`
42
+ # @param name [String] The name of the component in the registry
43
+ # @param skill_class [Class] The class to associate with the name
44
+ # @param condition [Condition] Criteria for whether or not the given subclass applies
45
+ # @return [Route] The created Route instance
46
+ def register(name, skill_class, condition)
47
+ raise Exceptions::ValidationError, "Must be a kind of Skill" unless skill_class.ancestors.include?(Skill)
48
+
49
+ @routes ||= []
50
+ @routes << Route.new(name: name.to_s, destination: skill_class, condition: condition)
51
+ end
52
+
53
+ # Given a message, find a suitable skill Route for it (sorted by priority, highest first)
54
+ # @param message [Waylon::Message] A Message instance
55
+ # @return [Hash]
56
+ def route(message)
57
+ route = nil
58
+ message_text = message.text.strip
59
+ @routes ||= []
60
+ @routes.sort_by(&:priority).reverse.each do |candidate_route|
61
+ if candidate_route.permits?(message.author) && candidate_route.matches?(message_text)
62
+ route = candidate_route
63
+ elsif candidate_route.matches?(message_text)
64
+ route = Routes::PermissionDenied.new
65
+ end
66
+ if route
67
+ log("Using route '#{route.name}' based on '#{message_text}' from '#{message.author.email}'", :debug)
68
+ break
69
+ end
70
+ end
71
+ route
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Skills
5
+ # The default skill for Waylon, mostly for fallback actions like permissions issues
6
+ class Default < Skill
7
+ # This action is performed when a User tries to run something they aren't allowed to
8
+ def denied
9
+ log("Denied '#{tokens.first}' from #{message.author.email}")
10
+ prefix = message.private_message? ? "" : "#{mention(message.author)},"
11
+
12
+ react :lock
13
+
14
+ responses = [
15
+ "I can't do that. You'll need an admin adjust your permissions.",
16
+ "I know what you'd like to do, but you don't have permission for that.",
17
+ "You don't have permission to do that."
18
+ ]
19
+
20
+ reply("#{prefix} #{responses.sample} #{help_postfix}")
21
+ end
22
+
23
+ # A useful addition to message to tell the User how to get help
24
+ # @return [String]
25
+ def help_postfix
26
+ "Use `help` to see what you're allowed to do."
27
+ end
28
+
29
+ # This is run for unroutable messages (meaning no Skill has claimed them)
30
+ def unknown
31
+ log("Unroutable message '#{tokens.first}' from #{message.author.email}")
32
+
33
+ prefix = message.private_message? ? "" : "#{mention(message.author)},"
34
+
35
+ react :shrug
36
+
37
+ responses = [
38
+ "Sorry, I'm not sure what you mean by that.",
39
+ "I don't have the ability to handle that request, but PRs are welcome!",
40
+ "I don't know what that means.",
41
+ "My AI and NLP is only so good... Maybe try rephrasing that request?"
42
+ ]
43
+
44
+ reply("#{prefix} #{responses.sample} #{help_postfix}")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Skills
5
+ # A place for some builtin fun
6
+ class Fun < Skill
7
+ # Say hello to Waylon
8
+ route(
9
+ /^(hello|hi)$/i,
10
+ :hello
11
+ )
12
+
13
+ # Responds to "hello" in less boring ways
14
+ def hello
15
+ responses = [
16
+ "Hello there!",
17
+ "Hi!",
18
+ "Hi, how's it going?",
19
+ "How can I be of service?"
20
+ ]
21
+
22
+ reply responses.sample
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # Abstract User module
5
+ # @abstract
6
+ module User
7
+ # Extends the base class when included
8
+ def self.included(base)
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ include Comparable
13
+
14
+ # Class-level methods to be added to User subclasses
15
+ module ClassMethods
16
+ # This should be overridden by subclasses to provide a mechanism for looking up Users
17
+ def find_by_email(_email)
18
+ raise NotImplementedError, "find_by_email isn't implemented"
19
+ end
20
+
21
+ # Provides a simple mechanism for referencing User subclass's Sense
22
+ # @return [Class] A Sense subclass
23
+ def sense
24
+ Sense
25
+ end
26
+
27
+ # This should be overridden by subclasses to provide a mechanism for the bot to get its own User
28
+ def whoami
29
+ raise NotImplementedError, "whoami isn't implemented"
30
+ end
31
+ end
32
+
33
+ def initialize(id)
34
+ @id = id
35
+ end
36
+
37
+ def <=>(other)
38
+ id <=> other.id
39
+ end
40
+
41
+ # Meant to provide the User's email
42
+ def email
43
+ nil
44
+ end
45
+
46
+ # Meant to provide the User's handle
47
+ def handle
48
+ nil
49
+ end
50
+
51
+ # Meant to provide the User's id from the underlying Sense platform
52
+ def id
53
+ @id
54
+ end
55
+
56
+ # Meant to determine if the User's is "real" per the Sense platform
57
+ def valid?
58
+ true
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Core
5
+ # The Waylon::Core version
6
+ VERSION = [
7
+ 0, # Major
8
+ 1, # Minor
9
+ 0 # Patch
10
+ ].join(".")
11
+ end
12
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ # Base class for Webhooks
5
+ class Webhook < Sinatra::Base
6
+ include BaseComponent
7
+
8
+ # Config namespace for config keys
9
+ # @return [String] The namespace for config keys
10
+ def self.config_namespace
11
+ "webhooks.#{component_namespace}"
12
+ end
13
+
14
+ # Places the incoming request body onto the Senses queue for processing by workers
15
+ # @param [Hash] content The verified request body
16
+ def enqueue(content)
17
+ Resque.enqueue sense_class, content
18
+ end
19
+
20
+ # Provides the Sense class that corresponds to this Webhook, with some sensible assumptions
21
+ # @return [Class] The name of the corresponding Sense class
22
+ def sense_class
23
+ last = self.class.name.split("::").last
24
+ Module.const_get("Senses::#{last}")
25
+ end
26
+
27
+ # This must be implemented on every Webhook to provide a mechanism to ensure received payloads are legit
28
+ # @param [String] _payload The raw, unparsed request body
29
+ # @param [Hash] _headers The raw, unparsed request headers as a Hash
30
+ # @return [Boolean]
31
+ def verify(_payload, _headers)
32
+ raise GenericException, "Not Implemented"
33
+ end
34
+
35
+ configure do
36
+ set :protection, except: :http_origin
37
+ set :logging, ::Waylon::Logger
38
+ end
39
+
40
+ before do
41
+ content_type "application/json"
42
+
43
+ begin
44
+ unless request.get? || request.options?
45
+ request.body.rewind
46
+ @parsed_body = JSON.parse(request.body.read, symbolize_names: true)
47
+ end
48
+ rescue StandardError => e
49
+ halt(400, { error: "Request must be JSON: #{e.message}" }.to_json)
50
+ end
51
+ end
52
+
53
+ after do
54
+ request.options? && headers("Access-Control-Allow-Methods" => @allowed_types || %w[OPTIONS POST])
55
+ end
56
+
57
+ post "/" do
58
+ begin
59
+ request.body.rewind
60
+ verify request.body.read, request.env
61
+ enqueue @parsed_body
62
+ rescue StandardError => e
63
+ halt(422, { error: "Unprocessable entity: #{e.message}" }.to_json)
64
+ end
65
+
66
+ { status: :ok }.to_json
67
+ end
68
+
69
+ options "/" do
70
+ halt 200
71
+ end
72
+ end
73
+ end
data/lib/waylon.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "waylon/core"
4
+ require "waylon/skills/default"
5
+ require "waylon/skills/fun"
data/scripts/test.sh ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+
3
+ gem install bundler -v '~> 2.2'
4
+ bundle install
5
+ bundle exec rake
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/waylon/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "waylon-core"
7
+ spec.version = Waylon::Core::VERSION
8
+ spec.authors = ["Jonathan Gnagy"]
9
+ spec.email = ["jonathan@therubyist.org"]
10
+
11
+ spec.summary = "Core library for the Waylon bot framework"
12
+ spec.description = "The core components of the Waylon bot framework for Ruby"
13
+ spec.homepage = "https://github.com/jgnagy/waylon-core"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = "~> 3.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/jgnagy/waylon-core"
19
+ spec.metadata["changelog_uri"] = "https://github.com/jgnagy/waylon-core/blob/main/CHANGELOG.md"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
25
+ end
26
+ spec.bindir = "exe"
27
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
28
+ spec.require_paths = ["lib"]
29
+
30
+ # Uncomment to register a new dependency of your gem
31
+ spec.add_dependency "addressable", "~> 2.8"
32
+ spec.add_dependency "faraday", "~> 1.8"
33
+ spec.add_dependency "i18n", "~> 1.8"
34
+ spec.add_dependency "json", "~> 2.6"
35
+ spec.add_dependency "moneta", "~> 1.4"
36
+ spec.add_dependency "puma", "~> 5.5"
37
+ spec.add_dependency "resque", "~> 2.2"
38
+
39
+ spec.add_development_dependency "bundler", "~> 2.2"
40
+ spec.add_development_dependency "rake", "~> 13.0"
41
+ spec.add_development_dependency "rspec", "~> 3.10"
42
+ spec.add_development_dependency "rubocop", "~> 1.23"
43
+ spec.add_development_dependency "rubocop-rake", "~> 0.6"
44
+ spec.add_development_dependency "rubocop-rspec", "~> 2.6"
45
+ spec.add_development_dependency "simplecov", "~> 0.21"
46
+ spec.add_development_dependency "yard", "~> 0.9", ">= 0.9.27"
47
+
48
+ # For more information and examples about making a new gem, checkout our
49
+ # guide at: https://bundler.io/guides/creating_gem.html
50
+ end