rubycord 1.0.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 (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