discordrb 3.3.0 → 3.5.0

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