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,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