waylon-core 0.1.1 → 0.1.4

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