discordrb 1.6.6 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of discordrb might be problematic. Click here for more details.

@@ -2,18 +2,56 @@ require 'discordrb/bot'
2
2
  require 'discordrb/data'
3
3
  require 'discordrb/commands/parser'
4
4
  require 'discordrb/commands/events'
5
+ require 'discordrb/commands/container'
6
+ require 'discordrb/commands/rate_limiter'
5
7
 
6
8
  # Specialized bot to run commands
7
9
 
8
10
  module Discordrb::Commands
9
11
  # Bot that supports commands and command chains
10
12
  class CommandBot < Discordrb::Bot
11
- attr_reader :attributes, :prefix
12
-
13
+ # @return [Hash] this bot's attributes.
14
+ attr_reader :attributes
15
+
16
+ # @return [String] the prefix commands are triggered with.
17
+ attr_reader :prefix
18
+
19
+ include CommandContainer
20
+
21
+ # Creates a new CommandBot and logs in to Discord.
22
+ # @param email [String] The email to use to log in.
23
+ # @param password [String] The password corresponding to the email.
24
+ # @param prefix [String] The prefix that should trigger this bot's commands. Can be any string (including the empty
25
+ # string), but note that it will be literal - if the prefix is "hi" then the corresponding trigger string for
26
+ # a command called "test" would be "hitest". Don't forget to put spaces in if you need them!
27
+ # @param attributes [Hash] The attributes to initialize the CommandBot with.
28
+ # @param debug [true, false] Whether or not debug mode should be used - debug mode logs tons of extra stuff to the
29
+ # console that may be useful in development.
30
+ # @option attributes [true, false] :advanced_functionality Whether to enable advanced functionality (very powerful
31
+ # way to nest commands into chains, see https://github.com/meew0/discordrb/wiki/Commands#command-chain-syntax
32
+ # for info. Default is true.
33
+ # @option attributes [Symbol, Array<Symbol>] :help_command The name of the command that displays info for other
34
+ # commands. Use an array if you want to have aliases. Default is "help".
35
+ # @option attributes [String] :command_doesnt_exist_message The message that should be displayed if a user attempts
36
+ # to use a command that does not exist. If none is specified, no message will be displayed. In the message, you
37
+ # can use the string '%command%' that will be replaced with the name of the command.
38
+ # @option attributes [String] :previous Character that should designate the result of the previous command in
39
+ # a command chain (see :advanced_functionality). Default is '~'.
40
+ # @option attributes [String] :chain_delimiter Character that should designate that a new command begins in the
41
+ # command chain (see :advanced_functionality). Default is '>'.
42
+ # @option attributes [String] :chain_args_delim Character that should separate the command chain arguments from the
43
+ # chain itself (see :advanced_functionality). Default is ':'.
44
+ # @option attributes [String] :sub_chain_start Character that should start a sub-chain (see
45
+ # :advanced_functionality). Default is '['.
46
+ # @option attributes [String] :sub_chain_end Character that should end a sub-chain (see
47
+ # :advanced_functionality). Default is ']'.
48
+ # @option attributes [String] :quote_start Character that should start a quoted string (see
49
+ # :advanced_functionality). Default is '"'.
50
+ # @option attributes [String] :quote_end Character that should end a quoted string (see
51
+ # :advanced_functionality). Default is '"'.
13
52
  def initialize(email, password, prefix, attributes = {}, debug = false)
14
53
  super(email, password, debug)
15
54
  @prefix = prefix
16
- @commands = {}
17
55
  @attributes = {
18
56
  # Whether advanced functionality such as command chains are enabled
19
57
  advanced_functionality: attributes[:advanced_functionality].nil? ? true : attributes[:advanced_functionality],
@@ -81,16 +119,13 @@ module Discordrb::Commands
81
119
  end
82
120
  end
83
121
 
84
- def command(name, attributes = {}, &block)
85
- if name.is_a? Array
86
- new_command = Command.new(name[0], attributes, &block)
87
- name.each { |n| @commands[n] = new_command }
88
- new_command
89
- else
90
- @commands[name] = Command.new(name, attributes, &block)
91
- end
92
- end
93
-
122
+ # Executes a particular command on the bot. Mostly useful for internal stuff, but one can never know.
123
+ # @param name [Symbol] The command to execute.
124
+ # @param event [CommandEvent] The event to pass to the command.
125
+ # @param arguments [Array<String>] The arguments to pass to the command.
126
+ # @param chained [true, false] Whether or not it should be executed as part of a command chain. If this is false,
127
+ # commands that have chain_usable set to false will not work.
128
+ # @return [String, nil] the command's result, if there is any.
94
129
  def execute_command(name, event, arguments, chained = false)
95
130
  debug("Executing command #{name} with arguments #{arguments}")
96
131
  command = @commands[name]
@@ -107,12 +142,45 @@ module Discordrb::Commands
107
142
  end
108
143
  end
109
144
 
145
+ # Executes a command in a simple manner, without command chains or permissions.
146
+ # @param chain [String] The command with its arguments separated by spaces.
147
+ # @param event [CommandEvent] The event to pass to the command.
148
+ # @return [String, nil] the command's result, if there is any.
110
149
  def simple_execute(chain, event)
111
150
  return nil if chain.empty?
112
151
  args = chain.split(' ')
113
152
  execute_command(args[0].to_sym, event, args[1..-1])
114
153
  end
115
154
 
155
+ # Sets the permission level of a user
156
+ # @param id [Integer] the ID of the user whose level to set
157
+ # @param level [Integer] the level to set the permission to
158
+ def set_user_permission(id, level)
159
+ @permissions[:users][id] = level
160
+ end
161
+
162
+ # Sets the permission level of a role - this applies to all users in the role
163
+ # @param id [Integer] the ID of the role whose level to set
164
+ # @param level [Integer] the level to set the permission to
165
+ def set_role_permission(id, level)
166
+ @permissions[:roles][id] = level
167
+ end
168
+
169
+ # Check if a user has permission to do something
170
+ # @param user [User] The user to check
171
+ # @param level [Integer] The minimum permission level the user should have (inclusive)
172
+ # @param server [Server] The server on which to check
173
+ # @return [true, false] whether or not the user has the given permission
174
+ def permission?(user, level, server)
175
+ determined_level = server.nil? ? 0 : user.roles[server.id].each.reduce(0) do |memo, role|
176
+ [@permissions[:roles][role.id] || 0, memo].max
177
+ end
178
+ [@permissions[:users][user.id] || 0, determined_level].max >= level
179
+ end
180
+
181
+ private
182
+
183
+ # Internal handler for MESSAGE_CREATE that is overwritten to allow for command handling
116
184
  def create_message(data)
117
185
  message = Discordrb::Message.new(data, self)
118
186
  event = CommandEvent.new(message, self)
@@ -144,20 +212,5 @@ module Discordrb::Commands
144
212
  end
145
213
  end
146
214
  end
147
-
148
- def set_user_permission(id, level)
149
- @permissions[:users][id] = level
150
- end
151
-
152
- def set_role_permission(id, level)
153
- @permissions[:roles][id] = level
154
- end
155
-
156
- def permission?(user, level, server)
157
- determined_level = server.nil? ? 0 : user.roles[server.id].each.reduce(0) do |memo, role|
158
- [@permissions[:roles][role.id] || 0, memo].max
159
- end
160
- [@permissions[:users][user.id] || 0, determined_level].max >= level
161
- end
162
215
  end
163
216
  end
@@ -0,0 +1,78 @@
1
+ require 'discordrb/container'
2
+ require 'discordrb/commands/rate_limiter'
3
+
4
+ module Discordrb::Commands
5
+ # This module holds a collection of commands that can be easily added to by calling the {CommandContainer#command}
6
+ # function. Other containers can be included into it as well. This allows for modularization of command bots.
7
+ module CommandContainer
8
+ include RateLimiter
9
+
10
+ # Adds a new command to the container.
11
+ # @param name [Symbol] The name of the command to add.
12
+ # @param attributes [Hash] The attributes to initialize the command with.
13
+ # @option attributes [Integer] :permission_level The minimum permission level that can use this command, inclusive.
14
+ # See {CommandBot#set_user_permission} and {CommandBot#set_role_permission}.
15
+ # @option attributes [true, false] :chain_usable Whether this command is able to be used inside of a command chain
16
+ # or sub-chain. Typically used for administrative commands that shouldn't be done carelessly.
17
+ # @option attributes [true, false] :help_available Whether this command is visible in the help command. See the
18
+ # :help_command attribute of {CommandBot#initialize}.
19
+ # @option attributes [String] :description A short description of what this command does. Will be shown in the help
20
+ # command if the user asks for it.
21
+ # @option attributes [String] :usage A short description of how this command should be used. Will be displayed in
22
+ # the help command or if the user uses it wrong.
23
+ # @option attributes [Integer] :min_args The minimum number of arguments this command should have. If a user
24
+ # attempts to call the command with fewer arguments, the usage information will be displayed, if it exists.
25
+ # @option attributes [Integer] :max_args The maximum number of arguments the command should have.
26
+ # @option attributes [String] :rate_limit_message The message that should be displayed if the command hits a rate
27
+ # limit. None if unspecified or nil. %time% in the message will be replaced with the time in seconds when the
28
+ # command will be available again.
29
+ # @option attributes [Symbol] :bucket The rate limit bucket that should be used for rate limiting. No rate limiting
30
+ # will be done if unspecified or nil.
31
+ # @yield The block is executed when the command is executed.
32
+ # @yieldparam event [CommandEvent] The event of the message that contained the command.
33
+ # @return [Command] The command that was added.
34
+ def command(name, attributes = {}, &block)
35
+ @commands ||= {}
36
+ if name.is_a? Array
37
+ new_command = Command.new(name[0], attributes, &block)
38
+ name.each { |n| @commands[n] = new_command }
39
+ new_command
40
+ else
41
+ @commands[name] = Command.new(name, attributes, &block)
42
+ end
43
+ end
44
+
45
+ # Removes a specific command from this container.
46
+ # @param name [Symbol] The command to remove.
47
+ def remove_command(name)
48
+ @commands ||= {}
49
+ @commands.delete name
50
+ end
51
+
52
+ # Adds all commands from another container into this one. Existing commands will be overwritten.
53
+ # @param container [Module] A module that `extend`s {CommandContainer} from which the commands will be added.
54
+ def include_commands(container)
55
+ handlers = container.instance_variable_get '@commands'
56
+ @commands ||= {}
57
+ @commands.merge! handlers
58
+ end
59
+
60
+ # Includes another container into this one.
61
+ # @param container [Module] An EventContainer or CommandContainer that will be included if it can.
62
+ def include!(container)
63
+ container_modules = container.singleton_class.included_modules
64
+
65
+ # If the container is an EventContainer and we can include it, then do that
66
+ if container_modules.include?(Discordrb::EventContainer) && respond_to?(:include_events)
67
+ include_events(container)
68
+ end
69
+
70
+ if container_modules.include? Discordrb::Commands::CommandContainer
71
+ include_commands(container)
72
+ include_buckets(container)
73
+ elsif !container_modules.include? Discordrb::EventContainer
74
+ fail "Could not include! this particular container - ancestors: #{container_modules}"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,19 +1,9 @@
1
1
  require 'discordrb/events/message'
2
2
 
3
3
  module Discordrb::Commands
4
- # Extension of MessageEvent for commands that contains the command called, makes the bot readable and adds a message to be saved
4
+ # Extension of MessageEvent for commands that contains the command called and makes the bot readable
5
5
  class CommandEvent < Discordrb::Events::MessageEvent
6
- attr_reader :bot, :saved_message
6
+ attr_reader :bot
7
7
  attr_accessor :command
8
-
9
- def initialize(message, bot)
10
- super(message, bot)
11
- @saved_message = ''
12
- end
13
-
14
- def <<(message)
15
- @saved_message += "#{message}\n"
16
- nil
17
- end
18
8
  end
19
9
  end
@@ -1,8 +1,13 @@
1
1
  module Discordrb::Commands
2
2
  # Command that can be called in a chain
3
3
  class Command
4
- attr_reader :attributes, :name
4
+ # @return [Hash] the attributes the command was initialized with
5
+ attr_reader :attributes
5
6
 
7
+ # @return [Symbol] the name of this command
8
+ attr_reader :name
9
+
10
+ # @!visibility private
6
11
  def initialize(name, attributes = {}, &block)
7
12
  @name = name
8
13
  @attributes = {
@@ -25,12 +30,24 @@ module Discordrb::Commands
25
30
  min_args: attributes[:min_args] || 0,
26
31
 
27
32
  # Maximum number of arguments (-1 for no limit)
28
- max_args: attributes[:max_args] || -1
33
+ max_args: attributes[:max_args] || -1,
34
+
35
+ # Message to display upon rate limiting (%time% in the message for the remaining time until the next possible
36
+ # request, nil for no message)
37
+ rate_limit_message: attributes[:rate_limit_message],
38
+
39
+ # Rate limiting bucket (nil for no rate limiting)
40
+ bucket: attributes[:bucket]
29
41
  }
30
42
 
31
43
  @block = block
32
44
  end
33
45
 
46
+ # Calls this command and executes the code inside.
47
+ # @param event [CommandEvent] The event to call the command with.
48
+ # @param arguments [Array<String>] The attributes for the command.
49
+ # @param chained [true, false] Whether or not this command is part of a command chain.
50
+ # @return [String] the result of the execution.
34
51
  def call(event, arguments, chained = false)
35
52
  if arguments.length < @attributes[:min_args]
36
53
  event.respond "Too few arguments for command `#{name}`!"
@@ -48,12 +65,26 @@ module Discordrb::Commands
48
65
  return
49
66
  end
50
67
  end
68
+
69
+ rate_limited = event.bot.rate_limited?(@attributes[:bucket], event.author)
70
+ if @attributes[:bucket] && rate_limited
71
+ if @attributes[:rate_limit_message]
72
+ event.respond @attributes[:rate_limit_message].gsub('%time%', rate_limited.round(2).to_s)
73
+ end
74
+ return
75
+ end
76
+
51
77
  @block.call(event, *arguments)
78
+ rescue LocalJumpError # occurs when breaking
79
+ nil
52
80
  end
53
81
  end
54
82
 
55
83
  # Command chain, may have multiple commands, nested and commands
56
84
  class CommandChain
85
+ # @param chain [String] The string the chain should be parsed from.
86
+ # @param bot [CommandBot] The bot that executes this command chain.
87
+ # @param subchain [true, false] Whether this chain is a sub chain of another chain.
57
88
  def initialize(chain, bot, subchain = false)
58
89
  @attributes = bot.attributes
59
90
  @chain = chain
@@ -61,6 +92,10 @@ module Discordrb::Commands
61
92
  @subchain = subchain
62
93
  end
63
94
 
95
+ # Parses the command chain itself, including sub-chains, and executes it. Executes only the command chain, without
96
+ # its chain arguments.
97
+ # @param event [CommandEvent] The event to execute the chain with.
98
+ # @return [String] the result of the execution.
64
99
  def execute_bare(event)
65
100
  b_start = -1
66
101
  b_level = 0
@@ -160,6 +195,9 @@ module Discordrb::Commands
160
195
  prev
161
196
  end
162
197
 
198
+ # Divides the command chain into chain arguments and command chain, then executes them both.
199
+ # @param event [CommandEvent] The event to execute the command with.
200
+ # @return [String] the result of the command chain execution.
163
201
  def execute(event)
164
202
  old_chain = @chain
165
203
  @bot.debug 'Executing bare chain'
@@ -181,8 +219,6 @@ module Discordrb::Commands
181
219
 
182
220
  result = new_result
183
221
  # TODO: more chain arguments
184
- else
185
- # ignore
186
222
  end
187
223
  end
188
224
 
@@ -0,0 +1,141 @@
1
+ module Discordrb::Commands
2
+ # This class represents a bucket for rate limiting - it keeps track of how many requests have been made and when
3
+ # exactly the user should be rate limited.
4
+ class Bucket
5
+ # Makes a new bucket
6
+ # @param limit [Integer, nil] How many requests the user may perform in the given time_span, or nil if there should be no limit.
7
+ # @param time_span [Integer, nil] The time span after which the request count is reset, in seconds, or nil if the bucket should never be reset. (If this is nil, limit should be nil too)
8
+ # @param delay [Integer, nil] The delay for which the user has to wait after performing a request, in seconds, or nil if the user shouldn't have to wait.
9
+ def initialize(limit, time_span, delay)
10
+ fail ArgumentError, '`limit` and `time_span` have to either both be set or both be nil!' if !limit != !time_span
11
+
12
+ @limit = limit
13
+ @time_span = time_span
14
+ @delay = delay
15
+
16
+ @bucket = {}
17
+ end
18
+
19
+ # Cleans the bucket, removing all elements that aren't necessary anymore
20
+ # @param rate_limit_time [Time] The time to base the cleaning on, only useful for testing.
21
+ def clean(rate_limit_time = nil)
22
+ rate_limit_time ||= Time.now
23
+
24
+ @bucket.delete_if do |_, limit_hash|
25
+ # Time limit has not run out
26
+ return false if @time_span && rate_limit_time < (limit_hash[:set_time] + @time_span)
27
+
28
+ # Delay has not run out
29
+ return false if @delay && rate_limit_time < (limit_hash[:last_time] + @delay)
30
+
31
+ true
32
+ end
33
+ end
34
+
35
+ # Performs a rate limiting request
36
+ # @param thing [#resolve_id, Integer, Symbol] The particular thing that should be rate-limited (usually a user/channel, but you can also choose arbitrary integers or symbols)
37
+ # @param rate_limit_time [Time] The time to base the rate limiting on, only useful for testing.
38
+ # @return [Integer, false] the waiting time until the next request, in seconds, or false if the request succeeded
39
+ def rate_limited?(thing, rate_limit_time = nil)
40
+ key = resolve_key thing
41
+ limit_hash = @bucket[key]
42
+
43
+ # First case: limit_hash doesn't exist yet
44
+ unless limit_hash
45
+ @bucket[key] = {
46
+ last_time: Time.now,
47
+ set_time: Time.now,
48
+ count: 1
49
+ }
50
+
51
+ return false
52
+ end
53
+
54
+ # Define the time at which we're being rate limited once so it doesn't get inaccurate
55
+ rate_limit_time ||= Time.now
56
+
57
+ if @limit && (limit_hash[:count] + 1) > @limit
58
+ if @time_span && rate_limit_time < (limit_hash[:set_time] + @time_span)
59
+ # Second case: Count is over the limit and the time has not run out yet
60
+ return (limit_hash[:set_time] + @time_span) - rate_limit_time
61
+ else
62
+ # Third case: Count is over the limit but the time has run out
63
+ # Don't return anything here because there may still be delay-based limiting
64
+ limit_hash[:set_time] = rate_limit_time
65
+ limit_hash[:count] = 0
66
+ end
67
+ end
68
+
69
+ if @delay && rate_limit_time < (limit_hash[:last_time] + @delay)
70
+ # Fourth case: we're being delayed
71
+ (limit_hash[:last_time] + @delay) - rate_limit_time
72
+ else
73
+ # Fifth case: no rate limiting at all! Increment the count, set the last_time, and return false
74
+ limit_hash[:last_time] = rate_limit_time
75
+ limit_hash[:count] += 1
76
+ false
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def resolve_key(thing)
83
+ return thing.resolve_id if thing.respond_to?(:resolve_id) && !thing.is_a?(String)
84
+ return thing if thing.is_a?(Integer) || thing.is_a?(Symbol)
85
+ fail ArgumentError, "Cannot use a #{thing.class} as a rate limiting key!"
86
+ end
87
+ end
88
+
89
+ # Represents a collection of {Bucket}s.
90
+ module RateLimiter
91
+ # Defines a new bucket for this rate limiter.
92
+ # @param key [Symbol] The name for this new bucket.
93
+ # @param attributes [Hash] The attributes to initialize the bucket with.
94
+ # @option attributes [Integer] :limit The limit of requests to perform in the given time span.
95
+ # @option attributes [Integer] :time_span How many seconds until the limit should be reset.
96
+ # @option attributes [Integer] :delay How many seconds the user has to wait after each request.
97
+ # @see Bucket#initialize
98
+ # @return [Bucket] the created bucket.
99
+ def bucket(key, attributes)
100
+ @buckets ||= {}
101
+ @buckets[key] = Bucket.new(attributes[:limit], attributes[:time_span], attributes[:delay])
102
+ end
103
+
104
+ # Performs a rate limit request.
105
+ # @param key [Symbol] Which bucket to perform the request for.
106
+ # @param thing [#resolve_id, Integer, Symbol] What should be rate-limited.
107
+ # @see Bucket#rate_limited?
108
+ # @return [Integer, false] How much time to wait or false if the request succeeded.
109
+ def rate_limited?(key, thing)
110
+ # Check whether the bucket actually exists
111
+ return false unless @buckets && @buckets[key]
112
+
113
+ @buckets[key].rate_limited?(thing)
114
+ end
115
+
116
+ # Cleans all buckets
117
+ # @see Bucket#clean
118
+ def clean
119
+ @buckets.each(&:clean)
120
+ end
121
+
122
+ # Adds all the buckets from another RateLimiter onto this one.
123
+ # @param limiter [Module] Another {RateLimiter} module
124
+ def include_buckets(limiter)
125
+ buckets = limiter.instance_variable_get('@buckets') || {}
126
+ @buckets ||= {}
127
+ @buckets.merge! buckets
128
+ end
129
+ end
130
+
131
+ # This class provides a convenient way to do rate-limiting on non-command events.
132
+ # @see RateLimiter
133
+ class SimpleRateLimiter
134
+ include RateLimiter
135
+
136
+ # Makes a new rate limiter
137
+ def initialize
138
+ @buckets = {}
139
+ end
140
+ end
141
+ end