discordrb 3.3.0 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +152 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
  5. data/.github/pull_request_template.md +37 -0
  6. data/.github/workflows/codeql.yml +65 -0
  7. data/.markdownlint.json +4 -0
  8. data/.rubocop.yml +39 -36
  9. data/CHANGELOG.md +874 -552
  10. data/Gemfile +2 -0
  11. data/LICENSE.txt +1 -1
  12. data/README.md +80 -86
  13. data/Rakefile +2 -0
  14. data/bin/console +1 -0
  15. data/discordrb-webhooks.gemspec +9 -6
  16. data/discordrb.gemspec +21 -18
  17. data/lib/discordrb/allowed_mentions.rb +36 -0
  18. data/lib/discordrb/api/application.rb +202 -0
  19. data/lib/discordrb/api/channel.rb +236 -47
  20. data/lib/discordrb/api/interaction.rb +54 -0
  21. data/lib/discordrb/api/invite.rb +5 -5
  22. data/lib/discordrb/api/server.rb +94 -66
  23. data/lib/discordrb/api/user.rb +17 -11
  24. data/lib/discordrb/api/webhook.rb +63 -6
  25. data/lib/discordrb/api.rb +55 -16
  26. data/lib/discordrb/await.rb +0 -1
  27. data/lib/discordrb/bot.rb +480 -93
  28. data/lib/discordrb/cache.rb +31 -24
  29. data/lib/discordrb/colour_rgb.rb +43 -0
  30. data/lib/discordrb/commands/command_bot.rb +35 -12
  31. data/lib/discordrb/commands/container.rb +21 -24
  32. data/lib/discordrb/commands/parser.rb +20 -20
  33. data/lib/discordrb/commands/rate_limiter.rb +4 -3
  34. data/lib/discordrb/container.rb +209 -20
  35. data/lib/discordrb/data/activity.rb +271 -0
  36. data/lib/discordrb/data/application.rb +50 -0
  37. data/lib/discordrb/data/attachment.rb +71 -0
  38. data/lib/discordrb/data/audit_logs.rb +345 -0
  39. data/lib/discordrb/data/channel.rb +993 -0
  40. data/lib/discordrb/data/component.rb +229 -0
  41. data/lib/discordrb/data/embed.rb +251 -0
  42. data/lib/discordrb/data/emoji.rb +82 -0
  43. data/lib/discordrb/data/integration.rb +122 -0
  44. data/lib/discordrb/data/interaction.rb +800 -0
  45. data/lib/discordrb/data/invite.rb +137 -0
  46. data/lib/discordrb/data/member.rb +372 -0
  47. data/lib/discordrb/data/message.rb +414 -0
  48. data/lib/discordrb/data/overwrite.rb +108 -0
  49. data/lib/discordrb/data/profile.rb +91 -0
  50. data/lib/discordrb/data/reaction.rb +33 -0
  51. data/lib/discordrb/data/recipient.rb +34 -0
  52. data/lib/discordrb/data/role.rb +248 -0
  53. data/lib/discordrb/data/server.rb +1004 -0
  54. data/lib/discordrb/data/user.rb +264 -0
  55. data/lib/discordrb/data/voice_region.rb +45 -0
  56. data/lib/discordrb/data/voice_state.rb +41 -0
  57. data/lib/discordrb/data/webhook.rb +238 -0
  58. data/lib/discordrb/data.rb +28 -4180
  59. data/lib/discordrb/errors.rb +46 -4
  60. data/lib/discordrb/events/bans.rb +7 -5
  61. data/lib/discordrb/events/channels.rb +3 -1
  62. data/lib/discordrb/events/guilds.rb +16 -9
  63. data/lib/discordrb/events/interactions.rb +482 -0
  64. data/lib/discordrb/events/invites.rb +125 -0
  65. data/lib/discordrb/events/members.rb +6 -2
  66. data/lib/discordrb/events/message.rb +72 -27
  67. data/lib/discordrb/events/presence.rb +35 -18
  68. data/lib/discordrb/events/raw.rb +1 -3
  69. data/lib/discordrb/events/reactions.rb +49 -4
  70. data/lib/discordrb/events/threads.rb +96 -0
  71. data/lib/discordrb/events/typing.rb +6 -4
  72. data/lib/discordrb/events/voice_server_update.rb +47 -0
  73. data/lib/discordrb/events/voice_state_update.rb +15 -10
  74. data/lib/discordrb/events/webhooks.rb +9 -6
  75. data/lib/discordrb/gateway.rb +99 -71
  76. data/lib/discordrb/id_object.rb +39 -0
  77. data/lib/discordrb/light/integrations.rb +1 -1
  78. data/lib/discordrb/light/light_bot.rb +1 -1
  79. data/lib/discordrb/logger.rb +4 -4
  80. data/lib/discordrb/paginator.rb +57 -0
  81. data/lib/discordrb/permissions.rb +159 -39
  82. data/lib/discordrb/version.rb +1 -1
  83. data/lib/discordrb/voice/encoder.rb +16 -7
  84. data/lib/discordrb/voice/network.rb +99 -47
  85. data/lib/discordrb/voice/sodium.rb +98 -0
  86. data/lib/discordrb/voice/voice_bot.rb +33 -25
  87. data/lib/discordrb/webhooks.rb +2 -0
  88. data/lib/discordrb.rb +107 -1
  89. metadata +126 -54
  90. data/.codeclimate.yml +0 -16
  91. data/.travis.yml +0 -33
  92. data/bin/travis_build_docs.sh +0 -17
  93. /data/{CONTRIBUTING.md → .github/CONTRIBUTING.md} +0 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ module Discordrb::Voice
6
+ # @!visibility private
7
+ module Sodium
8
+ extend ::FFI::Library
9
+
10
+ ffi_lib(['sodium', 'libsodium.so.18', 'libsodium.so.23'])
11
+
12
+ # Encryption & decryption
13
+ attach_function(:crypto_secretbox_xsalsa20poly1305, %i[pointer pointer ulong_long pointer pointer], :int)
14
+ attach_function(:crypto_secretbox_xsalsa20poly1305_open, %i[pointer pointer ulong_long pointer pointer], :int)
15
+
16
+ # Constants
17
+ attach_function(:crypto_secretbox_xsalsa20poly1305_keybytes, [], :size_t)
18
+ attach_function(:crypto_secretbox_xsalsa20poly1305_noncebytes, [], :size_t)
19
+ attach_function(:crypto_secretbox_xsalsa20poly1305_zerobytes, [], :size_t)
20
+ attach_function(:crypto_secretbox_xsalsa20poly1305_boxzerobytes, [], :size_t)
21
+ end
22
+
23
+ # Utility class for interacting with required `xsalsa20poly1305` functions for voice transmission
24
+ # @!visibility private
25
+ class SecretBox
26
+ # Exception raised when a key or nonce with invalid length is used
27
+ class LengthError < RuntimeError
28
+ end
29
+
30
+ # Exception raised when encryption or decryption fails
31
+ class CryptoError < RuntimeError
32
+ end
33
+
34
+ # Required key length
35
+ KEY_LENGTH = Sodium.crypto_secretbox_xsalsa20poly1305_keybytes
36
+
37
+ # Required nonce length
38
+ NONCE_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_noncebytes
39
+
40
+ # Zero byte padding for encryption
41
+ ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes
42
+
43
+ # Zero byte padding for decryption
44
+ BOX_ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes
45
+
46
+ # @param key [String] Crypto key of length {KEY_LENGTH}
47
+ def initialize(key)
48
+ raise(LengthError, 'Key length') if key.bytesize != KEY_LENGTH
49
+
50
+ @key = key
51
+ end
52
+
53
+ # Encrypts a message using this box's key
54
+ # @param nonce [String] encryption nonce for this message
55
+ # @param message [String] message to be encrypted
56
+ def box(nonce, message)
57
+ raise(LengthError, 'Nonce length') if nonce.bytesize != NONCE_BYTES
58
+
59
+ message_padded = prepend_zeroes(ZERO_BYTES, message)
60
+ buffer = zero_string(message_padded.bytesize)
61
+
62
+ success = Sodium.crypto_secretbox_xsalsa20poly1305(buffer, message_padded, message_padded.bytesize, nonce, @key)
63
+ raise(CryptoError, "Encryption failed (#{success})") unless success.zero?
64
+
65
+ remove_zeroes(BOX_ZERO_BYTES, buffer)
66
+ end
67
+
68
+ # Decrypts the given ciphertext using this box's key
69
+ # @param nonce [String] encryption nonce for this ciphertext
70
+ # @param ciphertext [String] ciphertext to decrypt
71
+ def open(nonce, ciphertext)
72
+ raise(LengthError, 'Nonce length') if nonce.bytesize != NONCE_BYTES
73
+
74
+ ct_padded = prepend_zeroes(BOX_ZERO_BYTES, ciphertext)
75
+ buffer = zero_string(ct_padded.bytesize)
76
+
77
+ success = Sodium.crypto_secretbox_xsalsa20poly1305_open(buffer, ct_padded, ct_padded.bytesize, nonce, @key)
78
+ raise(CryptoError, "Decryption failed (#{success})") unless success.zero?
79
+
80
+ remove_zeroes(ZERO_BYTES, buffer)
81
+ end
82
+
83
+ private
84
+
85
+ def zero_string(size)
86
+ str = "\0" * size
87
+ str.force_encoding('ASCII-8BIT') if str.respond_to?(:force_encoding)
88
+ end
89
+
90
+ def prepend_zeroes(size, string)
91
+ zero_string(size) + string
92
+ end
93
+
94
+ def remove_zeroes(size, string)
95
+ string.slice!(size, string.bytesize - size)
96
+ end
97
+ end
98
+ end
@@ -6,7 +6,7 @@ require 'discordrb/logger'
6
6
 
7
7
  # Voice support
8
8
  module Discordrb::Voice
9
- # How long one voice packet should ideally be (20 ms as defined by Discord)
9
+ # How long one voice packet should ideally be (20ms as defined by Discord)
10
10
  IDEAL_LENGTH = 20.0
11
11
 
12
12
  # How many bytes of data to read (1920 bytes * 2 channels) from audio PCM data
@@ -24,6 +24,9 @@ module Discordrb::Voice
24
24
  # @return [Channel] the current voice channel
25
25
  attr_reader :channel
26
26
 
27
+ # @!visibility private
28
+ attr_writer :channel
29
+
27
30
  # @return [Integer, nil] the amount of time the stream has been playing, or `nil` if nothing has been played yet.
28
31
  attr_reader :stream_time
29
32
 
@@ -36,7 +39,7 @@ module Discordrb::Voice
36
39
  # play parts back too fast. How often these measurements should be done depends a lot on the system, and if it's
37
40
  # done too quickly, especially on slow connections, the playback speed will vary wildly; if it's done too slowly
38
41
  # however, small errors will cause quality problems for a longer time.
39
- # @return [Integer] how frequently audio length adjustments should be done, in ideal packets (20 ms).
42
+ # @return [Integer] how frequently audio length adjustments should be done, in ideal packets (20ms).
40
43
  attr_accessor :adjust_interval
41
44
 
42
45
  # This particular value is also important because ffmpeg may take longer to process the first few packets. It is
@@ -44,7 +47,7 @@ module Discordrb::Voice
44
47
  # shouldn't be any higher than {#adjust_interval}, otherwise no adjustments will take place. If {#adjust_interval}
45
48
  # is at a value higher than 10, this value should not be changed at all.
46
49
  # @see #adjust_interval
47
- # @return [Integer] the packet number (1 packet = 20 ms) at which length adjustments should start.
50
+ # @return [Integer] the packet number (1 packet = 20ms) at which length adjustments should start.
48
51
  attr_accessor :adjust_offset
49
52
 
50
53
  # This value determines whether or not the adjustment length should be averaged with the previous value. This may
@@ -60,8 +63,8 @@ module Discordrb::Voice
60
63
  attr_accessor :adjust_debug
61
64
 
62
65
  # If this value is set, no length adjustments will ever be done and this value will always be used as the length
63
- # (i. e. packets will be sent every N seconds). Be careful not to set it too low as to not spam Discord's servers.
64
- # The ideal length is 20 ms (accessible by the {Discordrb::Voice::IDEAL_LENGTH} constant), this value should be
66
+ # (i.e. packets will be sent every N seconds). Be careful not to set it too low as to not spam Discord's servers.
67
+ # The ideal length is 20ms (accessible by the {Discordrb::Voice::IDEAL_LENGTH} constant), this value should be
65
68
  # slightly lower than that because encoding + sending takes time. Note that sending DCA files is significantly
66
69
  # faster than sending regular audio files (usually about four times as fast), so you might want to set this value
67
70
  # to something else if you're sending a DCA file.
@@ -74,13 +77,12 @@ module Discordrb::Voice
74
77
  attr_accessor :volume
75
78
 
76
79
  # @!visibility private
77
- def initialize(channel, bot, token, session, endpoint, encrypted)
80
+ def initialize(channel, bot, token, session, endpoint)
78
81
  @bot = bot
79
82
  @channel = channel
80
83
 
81
84
  @ws = VoiceWS.new(channel, bot, token, session, endpoint)
82
85
  @udp = @ws.udp
83
- @udp.encrypted = encrypted
84
86
 
85
87
  @sequence = @time = 0
86
88
  @skips = 0
@@ -95,14 +97,15 @@ module Discordrb::Voice
95
97
 
96
98
  @encoder = Encoder.new
97
99
  @ws.connect
98
- rescue => e
100
+ rescue StandardError => e
99
101
  Discordrb::LOGGER.log_exception(e)
100
102
  raise
101
103
  end
102
104
 
103
105
  # @return [true, false] whether audio data sent will be encrypted.
106
+ # @deprecated Discord no longer supports unencrypted voice communication.
104
107
  def encrypted?
105
- @udp.encrypted?
108
+ true
106
109
  end
107
110
 
108
111
  # Set the filter volume. This volume is applied as a filter for decoded audio data. It has the advantage that using
@@ -132,7 +135,7 @@ module Discordrb::Voice
132
135
 
133
136
  alias_method :isplaying?, :playing?
134
137
 
135
- # Continue playback. This change may take up to 100 ms to take effect, which is usually negligible.
138
+ # Continue playback. This change may take up to 100ms to take effect, which is usually negligible.
136
139
  def continue
137
140
  @paused = false
138
141
  end
@@ -145,7 +148,8 @@ module Discordrb::Voice
145
148
  end
146
149
 
147
150
  # Sets whether or not the bot is speaking (green circle around user).
148
- # @param value [true, false] whether or not the bot should be speaking.
151
+ # @param value [true, false, Integer] whether or not the bot should be speaking, or a bitmask denoting the audio type
152
+ # @note https://discord.com/developers/docs/topics/voice-connections#speaking for information on the speaking bitmask
149
153
  def speaking=(value)
150
154
  @playing = value
151
155
  @ws.send_speaking(value)
@@ -161,6 +165,7 @@ module Discordrb::Voice
161
165
  sleep IDEAL_LENGTH / 1000.0 if @was_playing_before
162
166
 
163
167
  return unless wait_for_confirmation
168
+
164
169
  @has_stopped_playing = false
165
170
  sleep IDEAL_LENGTH / 1000.0 until @has_stopped_playing
166
171
  @has_stopped_playing = false
@@ -173,7 +178,7 @@ module Discordrb::Voice
173
178
  @ws.destroy
174
179
  end
175
180
 
176
- # Plays a stream of raw data to the channel. All playback methods are blocking, i. e. they wait for the playback to
181
+ # Plays a stream of raw data to the channel. All playback methods are blocking, i.e. they wait for the playback to
177
182
  # finish before exiting the method. This doesn't cause a problem if you just use discordrb events/commands to
178
183
  # play stuff, as these are fully threaded, but if you don't want this behaviour anyway, be sure to call these
179
184
  # methods in separate threads.
@@ -206,7 +211,7 @@ module Discordrb::Voice
206
211
  end
207
212
 
208
213
  # Adjust volume
209
- buf = @encoder.adjust_volume(buf, @volume) if @volume != 1.0
214
+ buf = @encoder.adjust_volume(buf, @volume) if @volume != 1.0 # rubocop:disable Lint/FloatComparison
210
215
 
211
216
  @first_packet = false
212
217
 
@@ -215,12 +220,15 @@ module Discordrb::Voice
215
220
  end
216
221
 
217
222
  # If the stream is a process, kill it
218
- if encoded_io.respond_to? :pid
223
+ if encoded_io&.pid
219
224
  Discordrb::LOGGER.debug("Killing ffmpeg process with pid #{encoded_io.pid.inspect}")
220
225
 
221
226
  begin
222
- Process.kill('TERM', encoded_io.pid)
223
- rescue => e
227
+ pid = encoded_io.pid
228
+ # Windows does not support TERM as a kill signal, so we use KILL. `Process.waitpid` verifies that our
229
+ # child process has not already completed.
230
+ Process.kill(Gem.win_platform? ? 'KILL' : 'TERM', pid) if Process.waitpid(pid, Process::WNOHANG).nil?
231
+ rescue StandardError => e
224
232
  Discordrb::LOGGER.warn('Failed to kill ffmpeg process! You *might* have a process leak now.')
225
233
  Discordrb::LOGGER.warn("Reason: #{e}")
226
234
  end
@@ -255,13 +263,13 @@ module Discordrb::Voice
255
263
  stop_playing(true) if @playing
256
264
 
257
265
  @bot.debug "Reading DCA file #{file}"
258
- input_stream = open(file)
266
+ input_stream = File.open(file)
259
267
 
260
268
  magic = input_stream.read(4)
261
269
  raise ArgumentError, 'Not a DCA1 file! The file might have been corrupted, please recreate it.' unless magic == 'DCA1'
262
270
 
263
271
  # Read the metadata header, then read the metadata and discard it as we don't care about it
264
- metadata_header = input_stream.read(4).unpack('l<')[0]
272
+ metadata_header = input_stream.read(4).unpack1('l<')
265
273
  input_stream.read(metadata_header)
266
274
 
267
275
  # Play the data, without re-encoding it to opus
@@ -275,9 +283,9 @@ module Discordrb::Voice
275
283
  next :stop
276
284
  end
277
285
 
278
- header = header_str.unpack('s<')[0]
286
+ header = header_str.unpack1('s<')
279
287
 
280
- raise 'Negative header in DCA file! Your file is likely corrupted.' if header < 0
288
+ raise 'Negative header in DCA file! Your file is likely corrupted.' if header.negative?
281
289
  rescue EOFError
282
290
  @bot.debug 'Finished DCA parsing (EOFError)'
283
291
  next :stop
@@ -292,7 +300,7 @@ module Discordrb::Voice
292
300
 
293
301
  private
294
302
 
295
- # Plays the data from the @io stream as Discord requires it
303
+ # Plays the data from the IO stream as Discord requires it
296
304
  def play_internal
297
305
  count = 0
298
306
  @playing = true
@@ -311,7 +319,7 @@ module Discordrb::Voice
311
319
  break unless @playing
312
320
 
313
321
  # If we should skip, get some data, discard it and go to the next iteration
314
- if @skips > 0
322
+ if @skips.positive?
315
323
  @skips -= 1
316
324
  yield
317
325
  next
@@ -364,7 +372,7 @@ module Discordrb::Voice
364
372
  # If paused, wait
365
373
  sleep 0.1 while @paused
366
374
 
367
- if @length > 0
375
+ if @length.positive?
368
376
  # Wait `length` ms, then send the next packet
369
377
  sleep @length / 1000.0
370
378
  else
@@ -378,13 +386,13 @@ module Discordrb::Voice
378
386
  increment_packet_headers
379
387
  @udp.send_audio(Encoder::OPUS_SILENCE, @sequence, @time)
380
388
 
381
- # Length adjustments don't matter here, we can just wait 20 ms since nobody is going to hear it anyway
389
+ # Length adjustments don't matter here, we can just wait 20ms since nobody is going to hear it anyway
382
390
  sleep IDEAL_LENGTH / 1000.0
383
391
  end
384
392
 
385
393
  @bot.debug('Performing final cleanup after stream ended')
386
394
 
387
- # Final cleanup
395
+ # Final clean-up
388
396
  stop_playing
389
397
 
390
398
  # Notify any stop_playing methods running right now that we have actually stopped
@@ -4,6 +4,8 @@ require 'discordrb/webhooks/version'
4
4
  require 'discordrb/webhooks/embeds'
5
5
  require 'discordrb/webhooks/client'
6
6
  require 'discordrb/webhooks/builder'
7
+ require 'discordrb/webhooks/view'
8
+ require 'discordrb/webhooks/modal'
7
9
 
8
10
  module Discordrb
9
11
  # Webhook client
data/lib/discordrb.rb CHANGED
@@ -10,7 +10,113 @@ module Discordrb
10
10
  Thread.current[:discordrb_name] = 'main'
11
11
 
12
12
  # The default debug logger used by discordrb.
13
- LOGGER = Logger.new(ENV['DISCORDRB_FANCY_LOG'])
13
+ LOGGER = Logger.new(ENV.fetch('DISCORDRB_FANCY_LOG', false))
14
+
15
+ # The Unix timestamp Discord IDs are based on
16
+ DISCORD_EPOCH = 1_420_070_400_000
17
+
18
+ # Used to declare what events you wish to recieve from Discord.
19
+ # @see https://discord.com/developers/docs/topics/gateway#gateway-intents
20
+ INTENTS = {
21
+ servers: 1 << 0,
22
+ server_members: 1 << 1,
23
+ server_bans: 1 << 2,
24
+ server_emojis: 1 << 3,
25
+ server_integrations: 1 << 4,
26
+ server_webhooks: 1 << 5,
27
+ server_invites: 1 << 6,
28
+ server_voice_states: 1 << 7,
29
+ server_presences: 1 << 8,
30
+ server_messages: 1 << 9,
31
+ server_message_reactions: 1 << 10,
32
+ server_message_typing: 1 << 11,
33
+ direct_messages: 1 << 12,
34
+ direct_message_reactions: 1 << 13,
35
+ direct_message_typing: 1 << 14
36
+ }.freeze
37
+
38
+ # All available intents
39
+ ALL_INTENTS = INTENTS.values.reduce(&:|)
40
+
41
+ # All unprivileged intents
42
+ # @see https://discord.com/developers/docs/topics/gateway#privileged-intents
43
+ UNPRIVILEGED_INTENTS = ALL_INTENTS & ~(INTENTS[:server_members] | INTENTS[:server_presences])
44
+
45
+ # No intents
46
+ NO_INTENTS = 0
47
+
48
+ # Compares two objects based on IDs - either the objects' IDs are equal, or one object is equal to the other's ID.
49
+ def self.id_compare(one_id, other)
50
+ other.respond_to?(:resolve_id) ? (one_id.resolve_id == other.resolve_id) : (one_id == other)
51
+ end
52
+
53
+ # The maximum length a Discord message can have
54
+ CHARACTER_LIMIT = 2000
55
+
56
+ # For creating timestamps with {timestamp}
57
+ # @see https://discord.com/developers/docs/reference#message-formatting-timestamp-styles
58
+ TIMESTAMP_STYLES = {
59
+ short_time: 't', # 16:20
60
+ long_time: 'T', # 16:20:30
61
+ short_date: 'd', # 20/04/2021
62
+ long_date: 'D', # 20 April 2021
63
+ short_datetime: 'f', # 20 April 2021 16:20
64
+ long_datetime: 'F', # Tuesday, 20 April 2021 16:20
65
+ relative: 'R' # 2 months ago
66
+ }.freeze
67
+
68
+ # Splits a message into chunks of 2000 characters. Attempts to split by lines if possible.
69
+ # @param msg [String] The message to split.
70
+ # @return [Array<String>] the message split into chunks
71
+ def self.split_message(msg)
72
+ # If the messages is empty, return an empty array
73
+ return [] if msg.empty?
74
+
75
+ # Split the message into lines
76
+ lines = msg.lines
77
+
78
+ # Turn the message into a "triangle" of consecutively longer slices, for example the array [1,2,3,4] would become
79
+ # [
80
+ # [1],
81
+ # [1, 2],
82
+ # [1, 2, 3],
83
+ # [1, 2, 3, 4]
84
+ # ]
85
+ tri = (0...lines.length).map { |i| lines.combination(i + 1).first }
86
+
87
+ # Join the individual elements together to get an array of strings with consecutively more lines
88
+ joined = tri.map(&:join)
89
+
90
+ # Find the largest element that is still below the character limit, or if none such element exists return the first
91
+ ideal = joined.max_by { |e| e.length > CHARACTER_LIMIT ? -1 : e.length }
92
+
93
+ # If it's still larger than the character limit (none was smaller than it) split it into the largest chunk without
94
+ # cutting words apart, breaking on the nearest space within character limit, otherwise just return an array with one element
95
+ ideal_ary = ideal.length > CHARACTER_LIMIT ? ideal.split(/(.{1,#{CHARACTER_LIMIT}}\b|.{1,#{CHARACTER_LIMIT}})/o).reject(&:empty?) : [ideal]
96
+
97
+ # Slice off the ideal part and strip newlines
98
+ rest = msg[ideal.length..].strip
99
+
100
+ # If none remains, return an empty array -> we're done
101
+ return [] unless rest
102
+
103
+ # Otherwise, call the method recursively to split the rest of the string and add it onto the ideal array
104
+ ideal_ary + split_message(rest)
105
+ end
106
+
107
+ # @param time [Time, Integer] The time to create the timestamp from, or a unix timestamp integer.
108
+ # @param style [Symbol, String] One of the keys from {TIMESTAMP_STYLES} or a string with the style.
109
+ # @return [String]
110
+ # @example
111
+ # Discordrb.timestamp(Time.now, :short_time)
112
+ # # => "<t:1632146954:t>"
113
+ def self.timestamp(time, style = nil)
114
+ if style.nil?
115
+ "<t:#{time.to_i}>"
116
+ else
117
+ "<t:#{time.to_i}:#{TIMESTAMP_STYLES[style] || style}>"
118
+ end
119
+ end
14
120
  end
15
121
 
16
122
  # In discordrb, Integer and {String} are monkey-patched to allow for easy resolution of IDs