waylon-core 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -5,6 +5,8 @@ module Waylon
5
5
  class SkillRegistry
6
6
  include Singleton
7
7
 
8
+ attr_reader :routes
9
+
8
10
  # A wrapper around the singleton #register method
9
11
  # @param name [String] The name of the skill in the registry
10
12
  # @param class_name [Class] The class to associate with the name
@@ -14,8 +16,24 @@ module Waylon
14
16
  instance.register(name, class_name, condition)
15
17
  end
16
18
 
17
- def default_route
18
- Routes::Default.new
19
+ def self.find_by_name(name)
20
+ [
21
+ *instance.routes,
22
+ Routes::PermissionDenied.new,
23
+ Routes::BlackHole.new,
24
+ Routes::Default.new
25
+ ].find { |r| r.name == name.to_s }
26
+ end
27
+
28
+ def self.route(message)
29
+ instance.route(message)
30
+ end
31
+
32
+ # Provides the default route based on the received message.
33
+ # @param message [Waylon::Message] The received message
34
+ # @return [Waylon::Route]
35
+ def default_route(message)
36
+ message.to_bot? ? Routes::Default.new : Routes::BlackHole.new
19
37
  end
20
38
 
21
39
  # Gathers a Hash of help data for all routes a user is permitted to access
@@ -23,9 +41,13 @@ module Waylon
23
41
  # @return [Hash]
24
42
  def help(user)
25
43
  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
44
+ @routes.select { |r| r.permits?(user) && r.mention_only? }.each do |permitted|
45
+ data[permitted.destination.component_namespace] ||= []
46
+ data[permitted.destination.component_namespace] << if permitted.help
47
+ { name: permitted.name, help: permitted.help }
48
+ else
49
+ { name: permitted.name }
50
+ end
29
51
  end
30
52
 
31
53
  data.reject { |_k, v| v.empty? }
@@ -53,14 +75,25 @@ module Waylon
53
75
  # Given a message, find a suitable skill Route for it (sorted by priority, highest first)
54
76
  # @param message [Waylon::Message] A Message instance
55
77
  # @return [Hash]
78
+ # rubocop:disable Metrics/AbcSize
79
+ # rubocop:disable Metrics/CyclomaticComplexity
80
+ # rubocop:disable Metrics/MethodLength
81
+ # rubocop:disable Metrics/PerceivedComplexity
56
82
  def route(message)
57
83
  route = nil
58
- message_text = message.text.strip
84
+ message_text = message.body.strip
59
85
  @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)
86
+ @routes.sort_by(&:priority).reverse.each do |this_route|
87
+ if this_route.permits?(message.author) &&
88
+ this_route.matches?(message_text) &&
89
+ (this_route.properly_mentions?(message) || message.private?)
90
+ route = this_route
91
+ elsif this_route.permits?(message.author) &&
92
+ this_route.matches?(message_text) &&
93
+ !this_route.properly_mentions?(message)
94
+ # Black hole these because they're not direct mentions
95
+ route = Routes::BlackHole.new
96
+ elsif this_route.matches?(message_text)
64
97
  route = Routes::PermissionDenied.new
65
98
  end
66
99
  if route
@@ -70,5 +103,9 @@ module Waylon
70
103
  end
71
104
  route
72
105
  end
106
+ # rubocop:enable Metrics/AbcSize
107
+ # rubocop:enable Metrics/CyclomaticComplexity
108
+ # rubocop:enable Metrics/MethodLength
109
+ # rubocop:enable Metrics/PerceivedComplexity
73
110
  end
74
111
  end
@@ -20,6 +20,11 @@ module Waylon
20
20
  reply("#{prefix} #{responses.sample} #{help_postfix}")
21
21
  end
22
22
 
23
+ # This action ignores messages
24
+ def ignore
25
+ log("Ignoring black-holed message from #{message.author.email}")
26
+ end
27
+
23
28
  # A useful addition to message to tell the User how to get help
24
29
  # @return [String]
25
30
  def help_postfix
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Skills
5
+ # Built-in info routes
6
+ class Diagnostics < Skill
7
+ # Say hello to Waylon
8
+ route(
9
+ /^diagnostics|status$/i,
10
+ :status,
11
+ help: {
12
+ usage: "diagnostics|status",
13
+ description: "Retrieve this bot's current status"
14
+ }
15
+ )
16
+
17
+ # Provides info about Waylon's status
18
+ def status # rubocop:disable Metrics/AbcSize
19
+ response = []
20
+ response << "*Framework Version:* Waylon v#{Waylon::Core::VERSION}"
21
+ response << "*Sense plugins:*"
22
+ loaded_senses.each { |c| response << " - #{c}" }
23
+ response << "*Skill plugins:*"
24
+ loaded_routes.each { |d| response << " - #{d}" }
25
+ response << "*Redis:*"
26
+ state, read_time, write_time = test_redis
27
+ response << " - *Test Result:* #{state ? "Success" : "Error"}"
28
+ response << " - *Read time:* #{read_time}s"
29
+ response << " - *Write time:* #{write_time}s"
30
+ response << "*Queue Monitoring:*"
31
+ response << " - Failed jobs: #{Resque::Failure.count}"
32
+
33
+ reply response.join("\n")
34
+ end
35
+
36
+ def loaded_routes
37
+ SkillRegistry.instance.routes.map { |r| r.destination.name }.sort.uniq
38
+ end
39
+
40
+ def loaded_senses
41
+ SenseRegistry.instance.senses.map { |_s, c| c.name }.sort.uniq
42
+ end
43
+
44
+ def test_redis
45
+ test_key = ("a".."z").to_a.sample(10).join
46
+ test_value = (0..1000).to_a.sample(20).map(&:to_s).join
47
+ test_result = nil
48
+
49
+ write_time = Benchmark.realtime { db.store(test_key, test_value) }
50
+ read_time = Benchmark.realtime { test_result = db.load(test_key) }
51
+
52
+ db.delete(test_key)
53
+
54
+ [(test_value == test_result), read_time.round(6), write_time.round(6)]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -6,8 +6,9 @@ module Waylon
6
6
  class Fun < Skill
7
7
  # Say hello to Waylon
8
8
  route(
9
- /^(hello|hi)$/i,
10
- :hello
9
+ /^(hello|hi)([.!]+)?$/i,
10
+ :hello,
11
+ help: "hi|hello"
11
12
  )
12
13
 
13
14
  # Responds to "hello" in less boring ways
@@ -19,6 +20,8 @@ module Waylon
19
20
  "How can I be of service?"
20
21
  ]
21
22
 
23
+ react :wave
24
+
22
25
  reply responses.sample
23
26
  end
24
27
  end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Skills
5
+ # Built-in skills for managing groups
6
+ class Groups < Skill
7
+ route(
8
+ /^add (.+) to (.+)$/,
9
+ :add_to_group,
10
+ help: {
11
+ usage: "add USER[,USER] to GROUP",
12
+ description: "Add USER(s) to a GROUP"
13
+ },
14
+ allowed_groups: %i[admins group_admins]
15
+ )
16
+
17
+ route(
18
+ /^remove (.+) from (.+)$/,
19
+ :remove_from_group,
20
+ help: {
21
+ usage: "remove USER[,USER] from GROUP",
22
+ description: "Remove USER(s) from a GROUP"
23
+ },
24
+ allowed_groups: %i[admins group_admins]
25
+ )
26
+
27
+ route(
28
+ /^(describe|list|print|show) (all )?(groups|group memberships)$/,
29
+ :list_all_groups,
30
+ help: {
31
+ usage: "list all groups",
32
+ description: "List all groups and their members"
33
+ },
34
+ allowed_groups: %i[admins group_admins]
35
+ )
36
+
37
+ route(
38
+ /^cleanup groups$/,
39
+ :cleanup_groups,
40
+ help: {
41
+ usage: "cleanup groups",
42
+ description: "Remove empty groups"
43
+ },
44
+ allowed_groups: %i[admins group_admins]
45
+ )
46
+
47
+ route(
48
+ /^((list )?my )?groups$/,
49
+ :list_my_groups,
50
+ help: {
51
+ usage: "list my groups",
52
+ description: "List my group memberships"
53
+ }
54
+ )
55
+
56
+ def add_to_group # rubocop:disable Metrics/AbcSize
57
+ user_list = tokens.first
58
+ group_name = tokens.last
59
+
60
+ if group_name == "global admins"
61
+ reply "Sorry, I can't manipulate global admins this way..."
62
+ return
63
+ end
64
+
65
+ group = Group.new(group_name)
66
+
67
+ log "Adding #{user_list} to group #{group}"
68
+
69
+ failures = []
70
+ user_list.split(",").each do |this_user|
71
+ found = found_user(this_user)
72
+ failures << found unless group.add(found)
73
+ end
74
+
75
+ unless failures.empty?
76
+ text = failures.size > 1 ? "were already members" : "was already a member"
77
+ reply "Looks like [#{failures.map { |u| mention(u) }.join(", ")}] #{text} of #{group_name}"
78
+ end
79
+
80
+ reply("Done adding users to #{group_name}!")
81
+ end
82
+
83
+ def cleanup_groups
84
+ # perform a key scan in Redis for all group keys and find empty groups
85
+ group_keys = all_group_keys.select do |group|
86
+ name = group.split(".").last
87
+ Group.new(name).to_a.empty?
88
+ end
89
+
90
+ # delete the empty group keys
91
+ group_keys.each { |g| db.delete(g) }
92
+
93
+ group_names = group_keys.map { |g| g.split(".").last }
94
+
95
+ reply "I removed these empty groups: #{group_names.join(", ")}"
96
+ end
97
+
98
+ def remove_from_group # rubocop:disable Metrics/AbcSize
99
+ user_list = tokens.first
100
+ group_name = tokens.last
101
+
102
+ if group_name == "global admins"
103
+ reply "Sorry, I can't manipulate global admins this way..."
104
+ return
105
+ end
106
+
107
+ group = Group.new(group_name)
108
+
109
+ log "Removing #{user_list} from group '#{group_name}'", :debug
110
+
111
+ failures = []
112
+ user_list.split(",").each do |this_user|
113
+ found = found_user(this_user)
114
+ failures << found unless group.remove(found)
115
+ end
116
+
117
+ unless failures.empty?
118
+ text = failures.size > 1 ? "were members" : "was a member"
119
+ reply("I don't think [#{failures.map { |u| mention(u) }.join(", ")}] #{text} of #{group_name}")
120
+ end
121
+ reply("Done removing users from groups!")
122
+ end
123
+
124
+ def list_all_groups
125
+ groups = {}
126
+ groups["global admins"] = global_admins unless global_admins.empty?
127
+ all_group_keys.each do |group|
128
+ name = group.split(".").last
129
+ groups[name] = Group.new(name).members
130
+ end
131
+
132
+ reply(codify(groups.to_yaml))
133
+ end
134
+
135
+ def list_my_groups
136
+ groups = []
137
+ groups << "global admins" if global_admins.include?(message.author.email)
138
+ all_group_keys.each do |group|
139
+ name = group.split(".").last
140
+ groups << name if Group.new(name).include?(message.author)
141
+ end
142
+
143
+ reply(codify(groups.to_yaml))
144
+ end
145
+
146
+ private
147
+
148
+ def all_group_keys
149
+ db.adapter.backend.keys("groups.*")
150
+ end
151
+
152
+ def found_user(user_string)
153
+ if user_string =~ /^.+@.+/
154
+ # If provided an email
155
+ sense.user_class.find_by_email(user_string)
156
+ else
157
+ # Otherwise assume we're provided their user ID
158
+ sense.user_class.from_mention(user_string)
159
+ end
160
+ end
161
+
162
+ def global_admins
163
+ Config.instance.admins
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Skills
5
+ # A Skill for providing help
6
+ class Help < Skill
7
+ # Ask for some help.
8
+ # Defaults to all things a user has access to, but allows specifying either a Skill name or a Skill and Route
9
+ route(
10
+ /^help(?<skill_clause>\s+(?<skill>\w+)(?<action_clause>#(?<action>\w+)|\s+(?<action>\w+))?)?$/i,
11
+ :help,
12
+ help: {
13
+ usage: "help [skill [action]]",
14
+ description: "Allows asking for help, either for all skills or for a particular skill or action"
15
+ }
16
+ )
17
+
18
+ # Responds to "help" requests
19
+ def help
20
+ skill = named_tokens[:skill]
21
+ action = named_tokens[:action]
22
+
23
+ react :book
24
+
25
+ immediate_responses = [
26
+ "I'll send you a DM to go over that with you.",
27
+ "I'll DM you the details.",
28
+ "Look for a private message with those details.",
29
+ "You should have a private message with that information shortly."
30
+ ]
31
+
32
+ # Only send this if you aren't already in a DM
33
+ threaded_reply "#{acknowledgement} #{immediate_responses.sample}" unless message.private?
34
+
35
+ if sense.supports?(:blocks)
36
+ reply_with_blocks(help_blocks(skill, action), private: true)
37
+ else
38
+ reply(help_text(skill, action), private: true)
39
+ end
40
+ end
41
+
42
+ def help_text(skill = nil, action = nil) # rubocop:disable Metrics/AbcSize
43
+ allowed_routes = SkillRegistry.instance.help(message.author)
44
+ resp = []
45
+ if skill
46
+ if action
47
+ resp << "## Help for #{skill}##{action}:"
48
+ this_route = allowed_routes[skill].find do |r|
49
+ r[:name].to_s == "#{skill}##{action}"
50
+ end
51
+ return "I couldn't find #{action} on #{skill}..." unless this_route
52
+
53
+ resp << build_help_text(this_route)
54
+ else
55
+ resp << "## Help for #{skill}:\n"
56
+ routes = allowed_routes[skill]
57
+ (routes || []).each { |r| resp << build_help_text(r) }
58
+ end
59
+ else
60
+ help_text_for_all(allowed_routes)
61
+ end
62
+ end
63
+
64
+ def help_blocks(skill = nil, action = nil) # rubocop:disable Metrics/AbcSize
65
+ allowed_routes = SkillRegistry.instance.help(message.author)
66
+ if skill
67
+ if action
68
+ this_route = allowed_routes[skill].find do |r|
69
+ r[:name].to_s == "#{skill}##{action}"
70
+ end
71
+
72
+ return not_found_block(skill, action) unless this_route
73
+
74
+ [build_header_block(skill, action), build_help_block(this_route)]
75
+ else
76
+ routes = allowed_routes[skill]
77
+ return not_found_block(skill, nil) unless routes
78
+
79
+ resp = [build_header_block(skill, nil)]
80
+ routes.each { |r| resp << build_help_block(r) }
81
+ resp
82
+ end
83
+ else
84
+ allowed_routes = SkillRegistry.instance.help(message.author)
85
+ help_blocks_for_all(allowed_routes)
86
+ end
87
+ end
88
+
89
+ def help_blocks_for_all(routes)
90
+ resp = [
91
+ { type: "header", text: { type: "plain_text", text: "All known actions:", emoji: true } }
92
+ ]
93
+
94
+ routes.each do |k, v|
95
+ resp += [
96
+ { type: "divider" },
97
+ {
98
+ type: "section",
99
+ text: {
100
+ type: "mrkdwn",
101
+ text: "*Actions for '#{k}':*"
102
+ }
103
+ }
104
+ ]
105
+ v.each { |r| resp << build_help_block(r) }
106
+ end
107
+ resp
108
+ end
109
+
110
+ def help_text_for_all(routes)
111
+ resp = []
112
+ resp << "*All known actions:*\n"
113
+ routes.each do |k, v|
114
+ resp << "* *#{k}*:"
115
+ v.each do |r|
116
+ resp << build_help_text(r)
117
+ end
118
+ resp << " --- "
119
+ end
120
+ resp.join("\n")
121
+ end
122
+
123
+ def build_header_block(skill, action)
124
+ text = if action
125
+ "Help for #{skill}##{action}:"
126
+ else
127
+ "Help for #{skill}:"
128
+ end
129
+ { type: "header", text: { type: "plain_text", text: text, emoji: true } }
130
+ end
131
+
132
+ def build_help_block(this_route)
133
+ {
134
+ type: "section",
135
+ text: {
136
+ type: "mrkdwn",
137
+ text: " *#{this_route[:name]}*\n#{build_help_text(this_route)}"
138
+ }
139
+ }
140
+ end
141
+
142
+ def build_help_text(this_route)
143
+ route_help_text = []
144
+ case this_route[:help]
145
+ when String
146
+ route_help_text << " *Usage:* #{this_route[:help]}"
147
+ when Hash
148
+ route_help_text << " *Usage:* #{this_route.dig(:help, :usage)}"
149
+ if this_route.dig(:help, :description)
150
+ route_help_text << "\n *Description:* #{this_route.dig(:help, :description)}"
151
+ end
152
+ end
153
+ route_help_text.join
154
+ end
155
+
156
+ def not_found_block(skill, action)
157
+ sentence = if action
158
+ "I couldn't find any '#{action}' action on the '#{skill}' skill..."
159
+ else
160
+ "I couldn't find any routes related to a '#{skill}' skill..."
161
+ end
162
+ [
163
+ {
164
+ type: "section",
165
+ text: {
166
+ type: "mrkdwn",
167
+ text: sentence
168
+ }
169
+ }
170
+ ]
171
+ end
172
+ end
173
+ end
174
+ end
data/lib/waylon/user.rb CHANGED
@@ -18,6 +18,11 @@ module Waylon
18
18
  raise NotImplementedError, "find_by_handle isn't implemented"
19
19
  end
20
20
 
21
+ # This should be overridden by subclasses to provide a mechanism for looking up Users based on mention strings
22
+ def from_mention(_mention_string)
23
+ raise NotImplementedError, "from_mention isn't implemented"
24
+ end
25
+
21
26
  # Provides a simple mechanism for referencing User subclass's Sense
22
27
  # @return [Class] A Sense subclass
23
28
  def sense
@@ -6,7 +6,7 @@ module Waylon
6
6
  VERSION = [
7
7
  0, # Major
8
8
  1, # Minor
9
- 1 # Patch
9
+ 2 # Patch
10
10
  ].join(".")
11
11
  end
12
12
  end
@@ -21,7 +21,7 @@ module Waylon
21
21
  # @return [Class] The name of the corresponding Sense class
22
22
  def sense_class
23
23
  last = self.class.name.split("::").last
24
- Module.const_get("Senses::#{last}")
24
+ Module.const_get("Waylon::Senses::#{last}")
25
25
  end
26
26
 
27
27
  # This must be implemented on every Webhook to provide a mechanism to ensure received payloads are legit
@@ -38,36 +38,35 @@ module Waylon
38
38
  end
39
39
 
40
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)
41
+ unless request.get? || request.options?
42
+ request.body.rewind
43
+ @parsed_body = JSON.parse(request.body.read, symbolize_names: true)
50
44
  end
45
+ rescue StandardError => e
46
+ content_type "application/json"
47
+ halt(400, { error: "Request must be JSON: #{e.message}" }.to_json)
51
48
  end
52
49
 
53
50
  after do
54
51
  request.options? && headers("Access-Control-Allow-Methods" => @allowed_types || %w[OPTIONS POST])
55
52
  end
56
53
 
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
54
+ ## Example incoming webhook
55
+ #
56
+ # post "/" do
57
+ # begin
58
+ # request.body.rewind
59
+ # verify request.body.read, request.env
60
+ # enqueue @parsed_body
61
+ # rescue StandardError => e
62
+ # halt(422, { error: "Unprocessable entity: #{e.message}" }.to_json)
63
+ # end
64
+ #
65
+ # { status: :ok }.to_json
66
+ # end
67
+ #
68
+ # options "/" do
69
+ # halt 200
70
+ # end
72
71
  end
73
72
  end
data/lib/waylon.rb CHANGED
@@ -2,4 +2,7 @@
2
2
 
3
3
  require "waylon/core"
4
4
  require "waylon/skills/default"
5
+ require "waylon/skills/diagnostics"
5
6
  require "waylon/skills/fun"
7
+ require "waylon/skills/groups"
8
+ require "waylon/skills/help"
data/waylon-core.gemspec CHANGED
@@ -35,7 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency "puma", "~> 5.5"
36
36
  spec.add_dependency "resque", "~> 2.2"
37
37
 
38
- spec.add_development_dependency "bundler", "~> 2.2"
38
+ spec.add_development_dependency "bundler", "~> 2.3"
39
39
  spec.add_development_dependency "rake", "~> 13.0"
40
40
  spec.add_development_dependency "rspec", "~> 3.10"
41
41
  spec.add_development_dependency "rubocop", "~> 1.23"