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.
- checksums.yaml +7 -0
- data/lib/rubycord/allowed_mentions.rb +34 -0
- data/lib/rubycord/api/application.rb +200 -0
- data/lib/rubycord/api/channel.rb +597 -0
- data/lib/rubycord/api/interaction.rb +52 -0
- data/lib/rubycord/api/invite.rb +42 -0
- data/lib/rubycord/api/server.rb +557 -0
- data/lib/rubycord/api/user.rb +153 -0
- data/lib/rubycord/api/webhook.rb +138 -0
- data/lib/rubycord/api.rb +356 -0
- data/lib/rubycord/await.rb +49 -0
- data/lib/rubycord/bot.rb +1757 -0
- data/lib/rubycord/cache.rb +259 -0
- data/lib/rubycord/colour_rgb.rb +41 -0
- data/lib/rubycord/commands/command_bot.rb +519 -0
- data/lib/rubycord/commands/container.rb +110 -0
- data/lib/rubycord/commands/events.rb +9 -0
- data/lib/rubycord/commands/parser.rb +325 -0
- data/lib/rubycord/commands/rate_limiter.rb +142 -0
- data/lib/rubycord/container.rb +753 -0
- data/lib/rubycord/data/activity.rb +269 -0
- data/lib/rubycord/data/application.rb +48 -0
- data/lib/rubycord/data/attachment.rb +109 -0
- data/lib/rubycord/data/audit_logs.rb +343 -0
- data/lib/rubycord/data/channel.rb +996 -0
- data/lib/rubycord/data/component.rb +227 -0
- data/lib/rubycord/data/embed.rb +249 -0
- data/lib/rubycord/data/emoji.rb +80 -0
- data/lib/rubycord/data/integration.rb +120 -0
- data/lib/rubycord/data/interaction.rb +798 -0
- data/lib/rubycord/data/invite.rb +135 -0
- data/lib/rubycord/data/member.rb +370 -0
- data/lib/rubycord/data/message.rb +412 -0
- data/lib/rubycord/data/overwrite.rb +106 -0
- data/lib/rubycord/data/profile.rb +89 -0
- data/lib/rubycord/data/reaction.rb +31 -0
- data/lib/rubycord/data/recipient.rb +32 -0
- data/lib/rubycord/data/role.rb +246 -0
- data/lib/rubycord/data/server.rb +1002 -0
- data/lib/rubycord/data/user.rb +261 -0
- data/lib/rubycord/data/voice_region.rb +43 -0
- data/lib/rubycord/data/voice_state.rb +39 -0
- data/lib/rubycord/data/webhook.rb +232 -0
- data/lib/rubycord/data.rb +40 -0
- data/lib/rubycord/errors.rb +737 -0
- data/lib/rubycord/events/await.rb +46 -0
- data/lib/rubycord/events/bans.rb +58 -0
- data/lib/rubycord/events/channels.rb +186 -0
- data/lib/rubycord/events/generic.rb +126 -0
- data/lib/rubycord/events/guilds.rb +191 -0
- data/lib/rubycord/events/interactions.rb +480 -0
- data/lib/rubycord/events/invites.rb +123 -0
- data/lib/rubycord/events/lifetime.rb +29 -0
- data/lib/rubycord/events/members.rb +91 -0
- data/lib/rubycord/events/message.rb +337 -0
- data/lib/rubycord/events/presence.rb +127 -0
- data/lib/rubycord/events/raw.rb +45 -0
- data/lib/rubycord/events/reactions.rb +156 -0
- data/lib/rubycord/events/roles.rb +86 -0
- data/lib/rubycord/events/threads.rb +94 -0
- data/lib/rubycord/events/typing.rb +70 -0
- data/lib/rubycord/events/voice_server_update.rb +45 -0
- data/lib/rubycord/events/voice_state_update.rb +103 -0
- data/lib/rubycord/events/webhooks.rb +62 -0
- data/lib/rubycord/gateway.rb +867 -0
- data/lib/rubycord/id_object.rb +37 -0
- data/lib/rubycord/light/data.rb +60 -0
- data/lib/rubycord/light/integrations.rb +71 -0
- data/lib/rubycord/light/light_bot.rb +56 -0
- data/lib/rubycord/light.rb +6 -0
- data/lib/rubycord/logger.rb +118 -0
- data/lib/rubycord/paginator.rb +55 -0
- data/lib/rubycord/permissions.rb +251 -0
- data/lib/rubycord/version.rb +5 -0
- data/lib/rubycord/voice/encoder.rb +113 -0
- data/lib/rubycord/voice/network.rb +366 -0
- data/lib/rubycord/voice/sodium.rb +96 -0
- data/lib/rubycord/voice/voice_bot.rb +408 -0
- data/lib/rubycord/webhooks/builder.rb +100 -0
- data/lib/rubycord/webhooks/client.rb +132 -0
- data/lib/rubycord/webhooks/embeds.rb +248 -0
- data/lib/rubycord/webhooks/modal.rb +78 -0
- data/lib/rubycord/webhooks/version.rb +7 -0
- data/lib/rubycord/webhooks/view.rb +192 -0
- data/lib/rubycord/webhooks.rb +12 -0
- data/lib/rubycord/websocket.rb +70 -0
- data/lib/rubycord.rb +140 -0
- 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
|