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