rubycord 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/lib/rubycord/allowed_mentions.rb +34 -0
  3. data/lib/rubycord/api/application.rb +200 -0
  4. data/lib/rubycord/api/channel.rb +597 -0
  5. data/lib/rubycord/api/interaction.rb +52 -0
  6. data/lib/rubycord/api/invite.rb +42 -0
  7. data/lib/rubycord/api/server.rb +557 -0
  8. data/lib/rubycord/api/user.rb +153 -0
  9. data/lib/rubycord/api/webhook.rb +138 -0
  10. data/lib/rubycord/api.rb +356 -0
  11. data/lib/rubycord/await.rb +49 -0
  12. data/lib/rubycord/bot.rb +1757 -0
  13. data/lib/rubycord/cache.rb +259 -0
  14. data/lib/rubycord/colour_rgb.rb +41 -0
  15. data/lib/rubycord/commands/command_bot.rb +519 -0
  16. data/lib/rubycord/commands/container.rb +110 -0
  17. data/lib/rubycord/commands/events.rb +9 -0
  18. data/lib/rubycord/commands/parser.rb +325 -0
  19. data/lib/rubycord/commands/rate_limiter.rb +142 -0
  20. data/lib/rubycord/container.rb +753 -0
  21. data/lib/rubycord/data/activity.rb +269 -0
  22. data/lib/rubycord/data/application.rb +48 -0
  23. data/lib/rubycord/data/attachment.rb +109 -0
  24. data/lib/rubycord/data/audit_logs.rb +343 -0
  25. data/lib/rubycord/data/channel.rb +996 -0
  26. data/lib/rubycord/data/component.rb +227 -0
  27. data/lib/rubycord/data/embed.rb +249 -0
  28. data/lib/rubycord/data/emoji.rb +80 -0
  29. data/lib/rubycord/data/integration.rb +120 -0
  30. data/lib/rubycord/data/interaction.rb +798 -0
  31. data/lib/rubycord/data/invite.rb +135 -0
  32. data/lib/rubycord/data/member.rb +370 -0
  33. data/lib/rubycord/data/message.rb +412 -0
  34. data/lib/rubycord/data/overwrite.rb +106 -0
  35. data/lib/rubycord/data/profile.rb +89 -0
  36. data/lib/rubycord/data/reaction.rb +31 -0
  37. data/lib/rubycord/data/recipient.rb +32 -0
  38. data/lib/rubycord/data/role.rb +246 -0
  39. data/lib/rubycord/data/server.rb +1002 -0
  40. data/lib/rubycord/data/user.rb +261 -0
  41. data/lib/rubycord/data/voice_region.rb +43 -0
  42. data/lib/rubycord/data/voice_state.rb +39 -0
  43. data/lib/rubycord/data/webhook.rb +232 -0
  44. data/lib/rubycord/data.rb +40 -0
  45. data/lib/rubycord/errors.rb +737 -0
  46. data/lib/rubycord/events/await.rb +46 -0
  47. data/lib/rubycord/events/bans.rb +58 -0
  48. data/lib/rubycord/events/channels.rb +186 -0
  49. data/lib/rubycord/events/generic.rb +126 -0
  50. data/lib/rubycord/events/guilds.rb +191 -0
  51. data/lib/rubycord/events/interactions.rb +480 -0
  52. data/lib/rubycord/events/invites.rb +123 -0
  53. data/lib/rubycord/events/lifetime.rb +29 -0
  54. data/lib/rubycord/events/members.rb +91 -0
  55. data/lib/rubycord/events/message.rb +337 -0
  56. data/lib/rubycord/events/presence.rb +127 -0
  57. data/lib/rubycord/events/raw.rb +45 -0
  58. data/lib/rubycord/events/reactions.rb +156 -0
  59. data/lib/rubycord/events/roles.rb +86 -0
  60. data/lib/rubycord/events/threads.rb +94 -0
  61. data/lib/rubycord/events/typing.rb +70 -0
  62. data/lib/rubycord/events/voice_server_update.rb +45 -0
  63. data/lib/rubycord/events/voice_state_update.rb +103 -0
  64. data/lib/rubycord/events/webhooks.rb +62 -0
  65. data/lib/rubycord/gateway.rb +867 -0
  66. data/lib/rubycord/id_object.rb +37 -0
  67. data/lib/rubycord/light/data.rb +60 -0
  68. data/lib/rubycord/light/integrations.rb +71 -0
  69. data/lib/rubycord/light/light_bot.rb +56 -0
  70. data/lib/rubycord/light.rb +6 -0
  71. data/lib/rubycord/logger.rb +118 -0
  72. data/lib/rubycord/paginator.rb +55 -0
  73. data/lib/rubycord/permissions.rb +251 -0
  74. data/lib/rubycord/version.rb +5 -0
  75. data/lib/rubycord/voice/encoder.rb +113 -0
  76. data/lib/rubycord/voice/network.rb +366 -0
  77. data/lib/rubycord/voice/sodium.rb +96 -0
  78. data/lib/rubycord/voice/voice_bot.rb +408 -0
  79. data/lib/rubycord/webhooks/builder.rb +100 -0
  80. data/lib/rubycord/webhooks/client.rb +132 -0
  81. data/lib/rubycord/webhooks/embeds.rb +248 -0
  82. data/lib/rubycord/webhooks/modal.rb +78 -0
  83. data/lib/rubycord/webhooks/version.rb +7 -0
  84. data/lib/rubycord/webhooks/view.rb +192 -0
  85. data/lib/rubycord/webhooks.rb +12 -0
  86. data/lib/rubycord/websocket.rb +70 -0
  87. data/lib/rubycord.rb +140 -0
  88. metadata +231 -0
@@ -0,0 +1,408 @@
1
+ require "bigdecimal/util"
2
+
3
+ require "rubycord/voice/encoder"
4
+ require "rubycord/voice/network"
5
+ require "rubycord/logger"
6
+
7
+ # Voice support
8
+ module Rubycord::Voice
9
+ # How long one voice packet should ideally be (20ms as defined by Discord)
10
+ IDEAL_LENGTH = 20.0
11
+
12
+ # How many bytes of data to read (1920 bytes * 2 channels) from audio PCM data
13
+ DATA_LENGTH = 1920 * 2
14
+
15
+ # This class represents a connection to a Discord voice server and channel. It can be used to play audio files and
16
+ # streams and to control playback on currently playing tracks. The method {Bot#voice_connect} can be used to connect
17
+ # to a voice channel.
18
+ #
19
+ # rubycord does latency adjustments every now and then to improve playback quality. I made sure to put useful
20
+ # defaults for the adjustment parameters, but if the sound is patchy or too fast (or the speed varies a lot) you
21
+ # should check the parameters and adjust them to your connection: {VoiceBot#adjust_interval},
22
+ # {VoiceBot#adjust_offset}, and {VoiceBot#adjust_average}.
23
+ class VoiceBot
24
+ # @return [Channel] the current voice channel
25
+ attr_reader :channel
26
+
27
+ # @!visibility private
28
+ attr_writer :channel
29
+
30
+ # @return [Integer, nil] the amount of time the stream has been playing, or `nil` if nothing has been played yet.
31
+ attr_reader :stream_time
32
+
33
+ # @return [Encoder] the encoder used to encode audio files into the format required by Discord.
34
+ attr_reader :encoder
35
+
36
+ # rubycord will occasionally measure the time it takes to send a packet, and adjust future delay times based
37
+ # on that data. This makes voice playback more smooth, because if packets are sent too slowly, the audio will
38
+ # sound patchy, and if they're sent too quickly, packets will "pile up" and occasionally skip some data or
39
+ # play parts back too fast. How often these measurements should be done depends a lot on the system, and if it's
40
+ # done too quickly, especially on slow connections, the playback speed will vary wildly; if it's done too slowly
41
+ # however, small errors will cause quality problems for a longer time.
42
+ # @return [Integer] how frequently audio length adjustments should be done, in ideal packets (20ms).
43
+ attr_accessor :adjust_interval
44
+
45
+ # This particular value is also important because ffmpeg may take longer to process the first few packets. It is
46
+ # recommended to set this to 10 at maximum, otherwise it will take too long to make the first adjustment, but it
47
+ # shouldn't be any higher than {#adjust_interval}, otherwise no adjustments will take place. If {#adjust_interval}
48
+ # is at a value higher than 10, this value should not be changed at all.
49
+ # @see #adjust_interval
50
+ # @return [Integer] the packet number (1 packet = 20ms) at which length adjustments should start.
51
+ attr_accessor :adjust_offset
52
+
53
+ # This value determines whether or not the adjustment length should be averaged with the previous value. This may
54
+ # be useful on slower connections where latencies vary a lot. In general, it will make adjustments more smooth,
55
+ # but whether that is desired behaviour should be tried on a case-by-case basis.
56
+ # @see #adjust_interval
57
+ # @return [true, false] whether adjustment lengths should be averaged with the respective previous value.
58
+ attr_accessor :adjust_average
59
+
60
+ # Disable the debug message for length adjustment specifically, as it can get quite spammy with very low intervals
61
+ # @see #adjust_interval
62
+ # @return [true, false] whether length adjustment debug messages should be printed
63
+ attr_accessor :adjust_debug
64
+
65
+ # If this value is set, no length adjustments will ever be done and this value will always be used as the length
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 {Rubycord::Voice::IDEAL_LENGTH} constant), this value should be
68
+ # slightly lower than that because encoding + sending takes time. Note that sending DCA files is significantly
69
+ # faster than sending regular audio files (usually about four times as fast), so you might want to set this value
70
+ # to something else if you're sending a DCA file.
71
+ # @return [Float] the packet length that should be used instead of calculating it during the adjustments, in ms.
72
+ attr_accessor :length_override
73
+
74
+ # The factor the audio's volume should be multiplied with. `1` is no change in volume, `0` is completely silent,
75
+ # `0.5` is half the default volume and `2` is twice the default.
76
+ # @return [Float] the volume for audio playback, `1.0` by default.
77
+ attr_accessor :volume
78
+
79
+ # @!visibility private
80
+ def initialize(channel, bot, token, session, endpoint)
81
+ @bot = bot
82
+ @channel = channel
83
+
84
+ @ws = VoiceWS.new(channel, bot, token, session, endpoint)
85
+ @udp = @ws.udp
86
+
87
+ @sequence = @time = 0
88
+ @skips = 0
89
+
90
+ @adjust_interval = 100
91
+ @adjust_offset = 10
92
+ @adjust_average = false
93
+ @adjust_debug = true
94
+
95
+ @volume = 1.0
96
+ @playing = false
97
+
98
+ @encoder = Encoder.new
99
+ @ws.connect
100
+ rescue => e
101
+ Rubycord::LOGGER.log_exception(e)
102
+ raise
103
+ end
104
+
105
+ # @return [true, false] whether audio data sent will be encrypted.
106
+ # @deprecated Discord no longer supports unencrypted voice communication.
107
+ def encrypted?
108
+ true
109
+ end
110
+
111
+ # Set the filter volume. This volume is applied as a filter for decoded audio data. It has the advantage that using
112
+ # it is much faster than regular volume, but it can only be changed before starting to play something.
113
+ # @param value [Integer] The value to set the volume to. For possible values, see {#volume}
114
+ def filter_volume=(value)
115
+ @encoder.filter_volume = value
116
+ end
117
+
118
+ # @see #filter_volume=
119
+ # @return [Integer] the volume used as a filter for ffmpeg/avconv.
120
+ def filter_volume
121
+ @encoder.filter_volume
122
+ end
123
+
124
+ # Pause playback. This is not instant; it may take up to 20 ms for this change to take effect. (This is usually
125
+ # negligible.)
126
+ def pause
127
+ @paused = true
128
+ end
129
+
130
+ # @see #play
131
+ # @return [true, false] Whether it is playing sound or not.
132
+ def playing?
133
+ @playing
134
+ end
135
+
136
+ alias_method :isplaying?, :playing?
137
+
138
+ # Continue playback. This change may take up to 100ms to take effect, which is usually negligible.
139
+ def continue
140
+ @paused = false
141
+ end
142
+
143
+ # Skips to a later time in the song. It's impossible to go back without replaying the song.
144
+ # @param secs [Float] How many seconds to skip forwards. Skipping will always be done in discrete intervals of
145
+ # 0.05 seconds, so if the given amount is smaller than that, it will be rounded up.
146
+ def skip(secs)
147
+ @skips += (secs * (1000 / IDEAL_LENGTH)).ceil
148
+ end
149
+
150
+ # Sets whether or not the bot is speaking (green circle around user).
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
153
+ def speaking=(value)
154
+ @playing = value
155
+ @ws.send_speaking(value)
156
+ end
157
+
158
+ # Stops the current playback entirely.
159
+ # @param wait_for_confirmation [true, false] Whether the method should wait for confirmation from the playback
160
+ # method that the playback has actually stopped.
161
+ def stop_playing(wait_for_confirmation = false)
162
+ @was_playing_before = @playing
163
+ @speaking = false
164
+ @playing = false
165
+ sleep IDEAL_LENGTH / 1000.0 if @was_playing_before
166
+
167
+ return unless wait_for_confirmation
168
+
169
+ @has_stopped_playing = false
170
+ sleep IDEAL_LENGTH / 1000.0 until @has_stopped_playing
171
+ @has_stopped_playing = false
172
+ end
173
+
174
+ # Permanently disconnects from the voice channel; to reconnect you will have to call {Bot#voice_connect} again.
175
+ def destroy
176
+ stop_playing
177
+ @bot.voice_destroy(@channel.server.id, false)
178
+ @ws.destroy
179
+ end
180
+
181
+ # Plays a stream of raw data to the channel. All playback methods are blocking, i.e. they wait for the playback to
182
+ # finish before exiting the method. This doesn't cause a problem if you just use rubycord events/commands to
183
+ # play stuff, as these are fully threaded, but if you don't want this behaviour anyway, be sure to call these
184
+ # methods in separate threads.
185
+ # @param encoded_io [IO] A stream of raw PCM data (s16le)
186
+ def play(encoded_io)
187
+ stop_playing(true) if @playing
188
+ @retry_attempts = 3
189
+ @first_packet = true
190
+
191
+ play_internal do
192
+ buf = nil
193
+
194
+ # Read some data from the buffer
195
+ begin
196
+ buf = encoded_io.readpartial(DATA_LENGTH) if encoded_io
197
+ rescue EOFError
198
+ raise IOError, "File or stream not found!" if @first_packet
199
+
200
+ @bot.debug("EOF while reading, breaking immediately")
201
+ next :stop
202
+ end
203
+
204
+ # Check whether the buffer has enough data
205
+ if !buf || buf.length != DATA_LENGTH
206
+ @bot.debug("No data is available! Retrying #{@retry_attempts} more times")
207
+ next :stop if @retry_attempts.zero?
208
+
209
+ @retry_attempts -= 1
210
+ next
211
+ end
212
+
213
+ # Adjust volume
214
+ buf = @encoder.adjust_volume(buf, @volume) if @volume.to_d != BigDecimal("1.0")
215
+
216
+ @first_packet = false
217
+
218
+ # Encode data
219
+ @encoder.encode(buf)
220
+ end
221
+
222
+ # If the stream is a process, kill it
223
+ if encoded_io&.pid
224
+ Rubycord::LOGGER.debug("Killing ffmpeg process with pid #{encoded_io.pid.inspect}")
225
+
226
+ begin
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 => e
232
+ Rubycord::LOGGER.warn("Failed to kill ffmpeg process! You *might* have a process leak now.")
233
+ Rubycord::LOGGER.warn("Reason: #{e}")
234
+ end
235
+ end
236
+
237
+ # Close the stream
238
+ encoded_io.close
239
+ end
240
+
241
+ # Plays an encoded audio file of arbitrary format to the channel.
242
+ # @see Encoder#encode_file
243
+ # @see #play
244
+ def play_file(file, options = "")
245
+ play @encoder.encode_file(file, options)
246
+ end
247
+
248
+ # Plays a stream of encoded audio data of arbitrary format to the channel.
249
+ # @see Encoder#encode_io
250
+ # @see #play
251
+ def play_io(io, options = "")
252
+ play @encoder.encode_io(io, options)
253
+ end
254
+
255
+ # Plays a stream of audio data in the DCA format. This format has the advantage that no recoding has to be
256
+ # done - the file contains the data exactly as Discord needs it.
257
+ # @note DCA playback will not be affected by the volume modifier ({#volume}) because the modifier operates on raw
258
+ # PCM, not opus data. Modifying the volume of DCA data would involve decoding it, multiplying the samples and
259
+ # re-encoding it, which defeats its entire purpose (no recoding).
260
+ # @see https://github.com/bwmarrin/dca
261
+ # @see #play
262
+ def play_dca(file)
263
+ stop_playing(true) if @playing
264
+
265
+ @bot.debug "Reading DCA file #{file}"
266
+ input_stream = File.open(file)
267
+
268
+ magic = input_stream.read(4)
269
+ raise ArgumentError, "Not a DCA1 file! The file might have been corrupted, please recreate it." unless magic == "DCA1"
270
+
271
+ # Read the metadata header, then read the metadata and discard it as we don't care about it
272
+ metadata_header = input_stream.read(4).unpack1("l<")
273
+ input_stream.read(metadata_header)
274
+
275
+ # Play the data, without re-encoding it to opus
276
+ play_internal do
277
+ begin
278
+ # Read header
279
+ header_str = input_stream.read(2)
280
+
281
+ unless header_str
282
+ @bot.debug "Finished DCA parsing (header is nil)"
283
+ next :stop
284
+ end
285
+
286
+ header = header_str.unpack1("s<")
287
+
288
+ raise "Negative header in DCA file! Your file is likely corrupted." if header.negative?
289
+ rescue EOFError
290
+ @bot.debug "Finished DCA parsing (EOFError)"
291
+ next :stop
292
+ end
293
+
294
+ # Read bytes
295
+ input_stream.read(header)
296
+ end
297
+ end
298
+
299
+ alias_method :play_stream, :play_io
300
+
301
+ private
302
+
303
+ # Plays the data from the IO stream as Discord requires it
304
+ def play_internal
305
+ count = 0
306
+ @playing = true
307
+
308
+ # Default play length (ms), will be adjusted later
309
+ @length = IDEAL_LENGTH
310
+
311
+ self.speaking = true
312
+ loop do
313
+ # Starting from the tenth packet, perform length adjustment every 100 packets (2 seconds)
314
+ should_adjust_this_packet = (count % @adjust_interval == @adjust_offset)
315
+
316
+ # If we should adjust, start now
317
+ @length_adjust = Time.now.nsec if should_adjust_this_packet
318
+
319
+ break unless @playing
320
+
321
+ # If we should skip, get some data, discard it and go to the next iteration
322
+ if @skips.positive?
323
+ @skips -= 1
324
+ yield
325
+ next
326
+ end
327
+
328
+ # Track packet count, sequence and time (Discord requires this)
329
+ count += 1
330
+ increment_packet_headers
331
+
332
+ # Get packet data
333
+ buf = yield
334
+
335
+ # Stop doing anything if the stop signal was sent
336
+ break if buf == :stop
337
+
338
+ # Proceed to the next packet if we got nil
339
+ next unless buf
340
+
341
+ # Track intermediate adjustment so we can measure how much encoding contributes to the total time
342
+ @intermediate_adjust = Time.now.nsec if should_adjust_this_packet
343
+
344
+ # Send the packet
345
+ @udp.send_audio(buf, @sequence, @time)
346
+
347
+ # Set the stream time (for tracking how long we've been playing)
348
+ @stream_time = count * @length / 1000
349
+
350
+ if @length_override # Don't do adjustment because the user has manually specified an override value
351
+ @length = @length_override
352
+ elsif @length_adjust # Perform length adjustment
353
+ # Define the time once so it doesn't get inaccurate
354
+ now = Time.now.nsec
355
+
356
+ # Difference between length_adjust and now in ms
357
+ ms_diff = (now - @length_adjust) / 1_000_000.0
358
+ if ms_diff >= 0
359
+ @length = if @adjust_average
360
+ (IDEAL_LENGTH - ms_diff + @length) / 2.0
361
+ else
362
+ IDEAL_LENGTH - ms_diff
363
+ end
364
+
365
+ # Track the time it took to encode
366
+ encode_ms = (@intermediate_adjust - @length_adjust) / 1_000_000.0
367
+ @bot.debug("Length adjustment: new length #{@length} (measured #{ms_diff}, #{(100 * encode_ms) / ms_diff}% encoding)") if @adjust_debug
368
+ end
369
+ @length_adjust = nil
370
+ end
371
+
372
+ # If paused, wait
373
+ sleep 0.1 while @paused
374
+
375
+ if @length.positive?
376
+ # Wait `length` ms, then send the next packet
377
+ sleep @length / 1000.0
378
+ else
379
+ Rubycord::LOGGER.warn("Audio encoding and sending together took longer than Discord expects one packet to be (20 ms)! This may be indicative of network problems.")
380
+ end
381
+ end
382
+
383
+ @bot.debug("Sending five silent frames to clear out buffers")
384
+
385
+ 5.times do
386
+ increment_packet_headers
387
+ @udp.send_audio(Encoder::OPUS_SILENCE, @sequence, @time)
388
+
389
+ # Length adjustments don't matter here, we can just wait 20ms since nobody is going to hear it anyway
390
+ sleep IDEAL_LENGTH / 1000.0
391
+ end
392
+
393
+ @bot.debug("Performing final cleanup after stream ended")
394
+
395
+ # Final clean-up
396
+ stop_playing
397
+
398
+ # Notify any stop_playing methods running right now that we have actually stopped
399
+ @has_stopped_playing = true
400
+ end
401
+
402
+ # Increment sequence and time
403
+ def increment_packet_headers
404
+ (@sequence + 10 < 65_535) ? @sequence += 1 : @sequence = 0
405
+ (@time + 9600 < 4_294_967_295) ? @time += 960 : @time = 0
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,100 @@
1
+ require "rubycord/webhooks/embeds"
2
+
3
+ module Rubycord::Webhooks
4
+ # A class that acts as a builder for a webhook message object.
5
+ class Builder
6
+ def initialize(content: "", username: nil, avatar_url: nil, tts: false, file: nil, embeds: [], allowed_mentions: nil)
7
+ @content = content
8
+ @username = username
9
+ @avatar_url = avatar_url
10
+ @tts = tts
11
+ @file = file
12
+ @embeds = embeds
13
+ @allowed_mentions = allowed_mentions
14
+ end
15
+
16
+ # The content of the message. May be 2000 characters long at most.
17
+ # @return [String] the content of the message.
18
+ attr_accessor :content
19
+
20
+ # The username the webhook will display as. If this is not set, the default username set in the webhook's settings
21
+ # will be used instead.
22
+ # @return [String] the username.
23
+ attr_accessor :username
24
+
25
+ # The URL of an image file to be used as an avatar. If this is not set, the default avatar from the webhook's
26
+ # settings will be used instead.
27
+ # @return [String] the avatar URL.
28
+ attr_accessor :avatar_url
29
+
30
+ # Whether this message should use TTS or not. By default, it doesn't.
31
+ # @return [true, false] the TTS status.
32
+ attr_accessor :tts
33
+
34
+ # Sets a file to be sent together with the message. Mutually exclusive with embeds; a webhook message can contain
35
+ # either a file to be sent or an embed.
36
+ # @param file [File] A file to be sent.
37
+ def file=(file)
38
+ raise ArgumentError, "Embeds and files are mutually exclusive!" unless @embeds.empty?
39
+
40
+ @file = file
41
+ end
42
+
43
+ # Adds an embed to this message.
44
+ # @param embed [Embed] The embed to add.
45
+ def <<(embed)
46
+ raise ArgumentError, "Embeds and files are mutually exclusive!" if @file
47
+
48
+ @embeds << embed
49
+ end
50
+
51
+ # Convenience method to add an embed using a block-style builder pattern
52
+ # @example Add an embed to a message
53
+ # builder.add_embed do |embed|
54
+ # embed.title = 'Testing'
55
+ # embed.image = Rubycord::Webhooks::EmbedImage.new(url: 'https://i.imgur.com/PcMltU7.jpg')
56
+ # end
57
+ # @param embed [Embed, nil] The embed to start the building process with, or nil if one should be created anew.
58
+ # @return [Embed] The created embed.
59
+ def add_embed(embed = nil)
60
+ embed ||= Embed.new
61
+ yield(embed)
62
+ self << embed
63
+ embed
64
+ end
65
+
66
+ # @return [File, nil] the file attached to this message.
67
+ attr_reader :file
68
+
69
+ # @return [Array<Embed>] the embeds attached to this message.
70
+ attr_reader :embeds
71
+
72
+ # @return [Rubycord::AllowedMentions, Hash] Mentions that are allowed to ping in this message.
73
+ # @see https://discord.com/developers/docs/resources/channel#allowed-mentions-object
74
+ attr_accessor :allowed_mentions
75
+
76
+ # @return [Hash] a hash representation of the created message, for JSON format.
77
+ def to_json_hash
78
+ {
79
+ content: @content,
80
+ username: @username,
81
+ avatar_url: @avatar_url,
82
+ tts: @tts,
83
+ embeds: @embeds.map(&:to_hash),
84
+ allowed_mentions: @allowed_mentions&.to_hash
85
+ }
86
+ end
87
+
88
+ # @return [Hash] a hash representation of the created message, for multipart format.
89
+ def to_multipart_hash
90
+ {
91
+ content: @content,
92
+ username: @username,
93
+ avatar_url: @avatar_url,
94
+ tts: @tts,
95
+ file: @file,
96
+ allowed_mentions: @allowed_mentions&.to_hash
97
+ }
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,132 @@
1
+ require "rest-client"
2
+ require "json"
3
+
4
+ require "rubycord/webhooks/builder"
5
+
6
+ module Rubycord::Webhooks
7
+ # A client for a particular webhook added to a Discord channel.
8
+ class Client
9
+ # Create a new webhook
10
+ # @param url [String] The URL to post messages to.
11
+ # @param id [Integer] The webhook's ID. Will only be used if `url` is not
12
+ # set.
13
+ # @param token [String] The webhook's authorisation token. Will only be used
14
+ # if `url` is not set.
15
+ def initialize(url: nil, id: nil, token: nil)
16
+ @url = url || generate_url(id, token)
17
+ end
18
+
19
+ # Executes the webhook this client points to with the given data.
20
+ # @param builder [Builder, nil] The builder to start out with, or nil if one should be created anew.
21
+ # @param wait [true, false] Whether Discord should wait for the message to be successfully received by clients, or
22
+ # whether it should return immediately after sending the message.
23
+ # @yield [builder] Gives the builder to the block to add additional steps, or to do the entire building process.
24
+ # @yieldparam builder [Builder] The builder given as a parameter which is used as the initial step to start from.
25
+ # @example Execute the webhook with an already existing builder
26
+ # builder = Rubycord::Webhooks::Builder.new # ...
27
+ # client.execute(builder)
28
+ # @example Execute the webhook by building a new message
29
+ # client.execute do |builder|
30
+ # builder.content = 'Testing'
31
+ # builder.username = 'rubycord'
32
+ # builder.add_embed do |embed|
33
+ # embed.timestamp = Time.now
34
+ # embed.title = 'Testing'
35
+ # embed.image = Rubycord::Webhooks::EmbedImage.new(url: 'https://i.imgur.com/PcMltU7.jpg')
36
+ # end
37
+ # end
38
+ # @return [RestClient::Response] the response returned by Discord.
39
+ def execute(builder = nil, wait = false, components = nil)
40
+ raise TypeError, "builder needs to be nil or like a Rubycord::Webhooks::Builder!" if
41
+ !(builder.respond_to?(:file) && builder.respond_to?(:to_multipart_hash)) && !builder.respond_to?(:to_json_hash) && !builder.nil?
42
+
43
+ builder ||= Builder.new
44
+ view = View.new
45
+
46
+ yield(builder, view) if block_given?
47
+
48
+ components ||= view
49
+
50
+ if builder.file
51
+ post_multipart(builder, components, wait)
52
+ else
53
+ post_json(builder, components, wait)
54
+ end
55
+ end
56
+
57
+ # Modify this webhook's properties.
58
+ # @param name [String, nil] The default name.
59
+ # @param avatar [String, #read, nil] The new avatar, in base64-encoded JPG format.
60
+ # @param channel_id [String, Integer, nil] The channel to move the webhook to.
61
+ # @return [RestClient::Response] the response returned by Discord.
62
+ def modify(name: nil, avatar: nil, channel_id: nil)
63
+ RestClient.patch(@url, {name: name, avatar: avatarise(avatar), channel_id: channel_id}.compact.to_json, content_type: :json)
64
+ end
65
+
66
+ # Delete this webhook.
67
+ # @param reason [String, nil] The reason this webhook was deleted.
68
+ # @return [RestClient::Response] the response returned by Discord.
69
+ # @note This is permanent and cannot be undone.
70
+ def delete(reason: nil)
71
+ RestClient.delete(@url, "X-Audit-Log-Reason": reason)
72
+ end
73
+
74
+ # Edit a message from this webhook.
75
+ # @param message_id [String, Integer] The ID of the message to edit.
76
+ # @param builder [Builder, nil] The builder to start out with, or nil if one should be created anew.
77
+ # @param content [String] The message content.
78
+ # @param embeds [Array<Embed, Hash>]
79
+ # @param allowed_mentions [Hash]
80
+ # @return [RestClient::Response] the response returned by Discord.
81
+ # @example Edit message content
82
+ # client.edit_message(message_id, content: 'goodbye world!')
83
+ # @example Edit a message via builder
84
+ # client.edit_message(message_id) do |builder|
85
+ # builder.add_embed do |e|
86
+ # e.description = 'Hello World!'
87
+ # end
88
+ # end
89
+ # @note Not all builder options are available when editing.
90
+ def edit_message(message_id, builder: nil, content: nil, embeds: nil, allowed_mentions: nil)
91
+ builder ||= Builder.new
92
+
93
+ yield builder if block_given?
94
+
95
+ data = builder.to_json_hash.merge({content: content, embeds: embeds, allowed_mentions: allowed_mentions}.compact)
96
+ RestClient.patch("#{@url}/messages/#{message_id}", data.compact.to_json, content_type: :json)
97
+ end
98
+
99
+ # Delete a message created by this webhook.
100
+ # @param message_id [String, Integer] The ID of the message to delete.
101
+ # @return [RestClient::Response] the response returned by Discord.
102
+ def delete_message(message_id)
103
+ RestClient.delete("#{@url}/messages/#{message_id}")
104
+ end
105
+
106
+ private
107
+
108
+ # Convert an avatar to API ready data.
109
+ # @param avatar [String, #read] Avatar data.
110
+ def avatarise(avatar)
111
+ if avatar.respond_to? :read
112
+ "data:image/jpg;base64,#{Base64.strict_encode64(avatar.read)}"
113
+ else
114
+ avatar
115
+ end
116
+ end
117
+
118
+ def post_json(builder, components, wait)
119
+ data = builder.to_json_hash.merge({components: components.to_a})
120
+ RestClient.post(@url + (wait ? "?wait=true" : ""), data.to_json, content_type: :json)
121
+ end
122
+
123
+ def post_multipart(builder, components, wait)
124
+ data = builder.to_multipart_hash.merge({components: components.to_a})
125
+ RestClient.post(@url + (wait ? "?wait=true" : ""), data)
126
+ end
127
+
128
+ def generate_url(id, token)
129
+ "https://discord.com/api/v9/webhooks/#{id}/#{token}"
130
+ end
131
+ end
132
+ end