waylon-core 0.1.1 → 0.1.2

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