waylon-core 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.roxanne.yml +4 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +137 -0
- data/LICENSE.txt +21 -0
- data/README.md +57 -0
- data/Rakefile +23 -0
- data/bin/console +17 -0
- data/bin/setup +8 -0
- data/lib/waylon/base_component.rb +124 -0
- data/lib/waylon/condition.rb +64 -0
- data/lib/waylon/conditions/default.rb +34 -0
- data/lib/waylon/conditions/permission_denied.rb +34 -0
- data/lib/waylon/conditions/regex.rb +22 -0
- data/lib/waylon/config.rb +134 -0
- data/lib/waylon/core.rb +46 -0
- data/lib/waylon/exceptions/not_implemented_error.rb +9 -0
- data/lib/waylon/exceptions/validation_error.rb +9 -0
- data/lib/waylon/generic_exception.rb +7 -0
- data/lib/waylon/group.rb +70 -0
- data/lib/waylon/logger.rb +41 -0
- data/lib/waylon/message.rb +17 -0
- data/lib/waylon/route.rb +71 -0
- data/lib/waylon/routes/default.rb +17 -0
- data/lib/waylon/routes/permission_denied.rb +17 -0
- data/lib/waylon/rspec/matchers/route_matcher.rb +43 -0
- data/lib/waylon/rspec/skill.rb +71 -0
- data/lib/waylon/rspec/test_channel.rb +83 -0
- data/lib/waylon/rspec/test_message.rb +63 -0
- data/lib/waylon/rspec/test_sense.rb +110 -0
- data/lib/waylon/rspec/test_server.rb +120 -0
- data/lib/waylon/rspec/test_user.rb +165 -0
- data/lib/waylon/rspec/test_worker.rb +15 -0
- data/lib/waylon/rspec.rb +50 -0
- data/lib/waylon/sense.rb +74 -0
- data/lib/waylon/sense_registry.rb +30 -0
- data/lib/waylon/skill.rb +132 -0
- data/lib/waylon/skill_registry.rb +74 -0
- data/lib/waylon/skills/default.rb +48 -0
- data/lib/waylon/skills/fun.rb +26 -0
- data/lib/waylon/user.rb +61 -0
- data/lib/waylon/version.rb +12 -0
- data/lib/waylon/webhook.rb +73 -0
- data/lib/waylon.rb +5 -0
- data/scripts/test.sh +5 -0
- data/waylon-core.gemspec +50 -0
- metadata +312 -0
data/lib/waylon/skill.rb
ADDED
@@ -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
|
data/lib/waylon/user.rb
ADDED
@@ -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,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
data/scripts/test.sh
ADDED
data/waylon-core.gemspec
ADDED
@@ -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
|