waylon-core 0.1.1 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
data/lib/waylon/sense.rb CHANGED
@@ -20,17 +20,16 @@ module Waylon
20
20
 
21
21
  # The connection between Senses and Skills happens here, via a Route and a Hash of details
22
22
  # @param route [Route] route The matching Route from the SkillRegistry
23
- # @param request_id [String] The ID (from the messaging platform) of the request
24
- # @param body [String] Message content for the Skill
23
+ # @param request [String] The request message (or its ID) from the messaging platform
25
24
  # @api private
26
- def self.enqueue(route, request_id, body)
25
+ def self.enqueue(route, request)
27
26
  details = {
28
27
  "sense" => self,
29
- "message" => request_id,
30
- "tokens" => route.tokens(body.strip)
28
+ "request" => request,
29
+ "route" => route.name
31
30
  }
32
31
 
33
- Resque.enqueue route.destination, route.action, details
32
+ Resque.enqueue route.destination, details
34
33
  end
35
34
 
36
35
  # Provides a simple mechanism for referencing the Group subclass provided by this Sense
@@ -5,6 +5,8 @@ module Waylon
5
5
  class SenseRegistry
6
6
  include Singleton
7
7
 
8
+ attr_reader :senses
9
+
8
10
  # A convenience wrapper around the singleton instance #register method
9
11
  # @param (see #register)
10
12
  # @return (see #register)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waylon
4
+ module Services
5
+ # A Ping Service for monitoring
6
+ class Ping < Sinatra::Base
7
+ configure do
8
+ set :protection, except: :http_origin
9
+ set :logging, ::Waylon::Logger
10
+ end
11
+
12
+ before do
13
+ content_type "application/json"
14
+ halt 403 unless request.get? || request.options?
15
+ end
16
+
17
+ after do
18
+ headers "Access-Control-Allow-Methods" => %w[OPTIONS GET] if request.options?
19
+ end
20
+
21
+ get "/" do
22
+ { status: :ok }.to_json
23
+ end
24
+
25
+ options "/" do
26
+ halt 200
27
+ end
28
+ end
29
+ end
30
+ end
data/lib/waylon/skill.rb CHANGED
@@ -5,7 +5,7 @@ module Waylon
5
5
  class Skill
6
6
  include BaseComponent
7
7
 
8
- attr_reader :sense, :tokens, :request
8
+ attr_reader :sense, :tokens, :request, :route
9
9
 
10
10
  # Config namespace for config keys
11
11
  # @return [String] The namespace for config keys
@@ -14,32 +14,31 @@ module Waylon
14
14
  end
15
15
 
16
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
17
  # @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)
18
+ def self.perform(details)
19
+ skill = new(details["sense"], details["route"], details["request"], details["meta"])
20
+ skill.send(skill.route.action.to_sym)
22
21
  end
23
22
 
24
23
  # Redis/Resque queue name
25
24
  # @api private
26
25
  # @return [Symbol]
27
26
  def self.queue
28
- :senses
27
+ :skills
29
28
  end
30
29
 
31
30
  # Adds skills to the SkillRegistry
32
31
  # @param condition [Condition,Regexp] The condition that determines if this route applies
33
32
  # @param action [Symbol,String] The method on the Skill subclass to call
34
33
  # @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)
34
+ # @param help [String,Hash] A description of how to use the skill
35
+ def self.route(condition, action, allowed_groups: :everyone, help: nil, name: nil, mention_only: true)
37
36
  name ||= "#{to_s.split("::").last.downcase}##{action}"
38
37
  real_cond = case condition
39
38
  when Condition
40
39
  condition
41
40
  when Regexp
42
- Conditions::Regex.new(condition, action, allowed_groups, help)
41
+ Conditions::Regex.new(condition, action, allowed_groups, help, mention_only)
43
42
  else
44
43
  log("Unknown condition for route for #{name}##{action}", :warn)
45
44
  nil
@@ -48,13 +47,13 @@ module Waylon
48
47
  end
49
48
 
50
49
  # @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)
50
+ # @param request [String,Integer] Reference to the request from the Sense provider (usually ID or message itself)
53
51
  # @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)
52
+ def initialize(sense, route, request, meta)
55
53
  @sense = sense.is_a?(Class) ? sense : Module.const_get(sense)
56
- @tokens = tokens || []
54
+ @route = SkillRegistry.find_by_name(route)
57
55
  @request = request
56
+ @tokens = @route.tokens(message.body) || []
58
57
  @meta = meta
59
58
  end
60
59
 
@@ -62,7 +61,6 @@ module Waylon
62
61
  # @return [String]
63
62
  def acknowledgement
64
63
  responses = [
65
- "I'll get back to you in just a sec.",
66
64
  "You got it!",
67
65
  "As you wish.",
68
66
  "Certainly!",
@@ -73,11 +71,10 @@ module Waylon
73
71
  "Of course!",
74
72
  "I'd be delighted!",
75
73
  "Right away!",
76
- "Gladly",
74
+ "Gladly!",
77
75
  "All right.",
78
76
  "I'm all over it.",
79
77
  "I'm on it!",
80
- "Let me see what I can do.",
81
78
  "Will do!"
82
79
  ]
83
80
  responses.sample
@@ -97,7 +94,8 @@ module Waylon
97
94
  sense: @sense,
98
95
  message: @request,
99
96
  tokens: @tokens,
100
- meta: @meta
97
+ meta: @meta,
98
+ route: route.name
101
99
  }
102
100
  end
103
101
 
@@ -111,7 +109,11 @@ module Waylon
111
109
  # Provides a wrapped message for responding to the received Sense
112
110
  # @return [Waylon::Message]
113
111
  def message
114
- sense.message_class.new(request)
112
+ sense.message_from_request(request)
113
+ end
114
+
115
+ def named_tokens
116
+ @named_tokens ||= @route.named_tokens(message.body) || {}
115
117
  end
116
118
 
117
119
  # Defers to the Sense to react to a message
@@ -124,9 +126,40 @@ module Waylon
124
126
  end
125
127
 
126
128
  # 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)
129
+ # @param text [String] The reply text
130
+ def reply(text, private: false)
131
+ if private && sense.supports?(:private_messages)
132
+ sense.private_reply(request, text)
133
+ else
134
+ log("Unable to send private message for Sense #{sense.name}, replying instead", :debug) if private
135
+ sense.reply(request, text)
136
+ end
137
+ end
138
+
139
+ # Defers to the Sense to determine how to reply to a message with rich content
140
+ # @param blocks [String] The reply blocks
141
+ def reply_with_blocks(blocks, private: false)
142
+ unless sense.supports?(:blocks)
143
+ log("Unable to use blocks with Sense #{sense.name}")
144
+ return false
145
+ end
146
+ if private
147
+ sense.private_reply_with_blocks(request, blocks)
148
+ else
149
+ log("Unable to send private message for Sense #{sense.name}, replying instead", :debug) if private
150
+ sense.reply_with_blocks(request, blocks)
151
+ end
152
+ end
153
+
154
+ # Defers to the Sense to do threaded replies if it can, otherwise it falls back to normal replies
155
+ # @param text [String] The reply text
156
+ def threaded_reply(text)
157
+ if sense.supports?(:threads)
158
+ sense.threaded_reply(request, text)
159
+ else
160
+ log("Unable to reply in theads for Sense #{sense.name}, replying instead", :debug)
161
+ sense.reply(request, text)
162
+ end
130
163
  end
131
164
  end
132
165
  end
@@ -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,60 @@
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
+ if Resque.redis.connected?
31
+ response << "*Queue Monitoring:*"
32
+ response << " - Failed jobs: #{Resque::Failure.count}"
33
+ end
34
+
35
+ reply response.join("\n")
36
+ end
37
+
38
+ def loaded_routes
39
+ SkillRegistry.instance.routes.map { |r| r.destination.name }.sort.uniq
40
+ end
41
+
42
+ def loaded_senses
43
+ SenseRegistry.instance.senses.map { |_s, c| c.name }.sort.uniq
44
+ end
45
+
46
+ def test_redis
47
+ test_key = ("a".."z").to_a.sample(10).join
48
+ test_value = (0..1000).to_a.sample(20).map(&:to_s).join
49
+ test_result = nil
50
+
51
+ write_time = Benchmark.realtime { db.store(test_key, test_value) }
52
+ read_time = Benchmark.realtime { test_result = db.load(test_key) }
53
+
54
+ db.delete(test_key)
55
+
56
+ [(test_value == test_result), read_time.round(6), write_time.round(6)]
57
+ end
58
+ end
59
+ end
60
+ 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,173 @@
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
+ if db.adapter.instance_of?(Moneta::Adapters::Redis)
150
+ db.adapter.backend.keys("groups.*")
151
+ else
152
+ groups = []
153
+ db.each_key { |key| groups << key if key.start_with?("groups.") }
154
+ groups
155
+ end
156
+ end
157
+
158
+ def found_user(user_string)
159
+ if user_string =~ /^.+@.+/
160
+ # If provided an email
161
+ sense.user_class.find_by_email(user_string)
162
+ else
163
+ # Otherwise assume we're provided their user ID
164
+ sense.user_class.from_mention(user_string)
165
+ end
166
+ end
167
+
168
+ def global_admins
169
+ Config.instance.admins
170
+ end
171
+ end
172
+ end
173
+ end