rubycord 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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,113 @@
|
|
1
|
+
# This makes opus an optional dependency
|
2
|
+
begin
|
3
|
+
require "opus-ruby"
|
4
|
+
OPUS_AVAILABLE = true
|
5
|
+
rescue LoadError
|
6
|
+
OPUS_AVAILABLE = false
|
7
|
+
end
|
8
|
+
|
9
|
+
# Discord voice chat support
|
10
|
+
module Rubycord::Voice
|
11
|
+
# This class conveniently abstracts opus and ffmpeg/avconv, for easy implementation of voice sending. It's not very
|
12
|
+
# useful for most users, but I guess it can be useful sometimes.
|
13
|
+
class Encoder
|
14
|
+
# Whether or not avconv should be used instead of ffmpeg. If possible, it is recommended to use ffmpeg instead,
|
15
|
+
# as it is better supported.
|
16
|
+
# @return [true, false] whether avconv should be used instead of ffmpeg.
|
17
|
+
attr_accessor :use_avconv
|
18
|
+
|
19
|
+
# @see VoiceBot#filter_volume=
|
20
|
+
# @return [Integer] the volume used as a filter to ffmpeg/avconv.
|
21
|
+
attr_accessor :filter_volume
|
22
|
+
|
23
|
+
# Create a new encoder
|
24
|
+
def initialize
|
25
|
+
sample_rate = 48_000
|
26
|
+
frame_size = 960
|
27
|
+
channels = 2
|
28
|
+
@filter_volume = 1
|
29
|
+
|
30
|
+
raise LoadError, "Opus unavailable - voice not supported! Please install opus for voice support to work." unless OPUS_AVAILABLE
|
31
|
+
|
32
|
+
@opus = Opus::Encoder.new(sample_rate, frame_size, channels)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Set the opus encoding bitrate
|
36
|
+
# @param value [Integer] The new bitrate to use, in bits per second (so 64000 if you want 64 kbps)
|
37
|
+
def bitrate=(value)
|
38
|
+
@opus.bitrate = value
|
39
|
+
end
|
40
|
+
|
41
|
+
# Encodes the given buffer using opus.
|
42
|
+
# @param buffer [String] An unencoded PCM (s16le) buffer.
|
43
|
+
# @return [String] A buffer encoded using opus.
|
44
|
+
def encode(buffer)
|
45
|
+
@opus.encode(buffer, 1920)
|
46
|
+
end
|
47
|
+
|
48
|
+
# One frame of complete silence Opus encoded
|
49
|
+
OPUS_SILENCE = [0xF8, 0xFF, 0xFE].pack("C*").freeze
|
50
|
+
|
51
|
+
# Adjusts the volume of a given buffer of s16le PCM data.
|
52
|
+
# @param buf [String] An unencoded PCM (s16le) buffer.
|
53
|
+
# @param mult [Float] The volume multiplier, 1 for same volume.
|
54
|
+
# @return [String] The buffer with adjusted volume, s16le again
|
55
|
+
def adjust_volume(buf, mult)
|
56
|
+
# We don't need to adjust anything if the buf is nil so just return in that case
|
57
|
+
return unless buf
|
58
|
+
|
59
|
+
# buf is s16le so use 's<' for signed, 16 bit, LE
|
60
|
+
result = buf.unpack("s<*").map do |sample|
|
61
|
+
sample *= mult
|
62
|
+
|
63
|
+
# clamp to s16 range
|
64
|
+
sample.clamp(-32_768, 32_767)
|
65
|
+
end
|
66
|
+
|
67
|
+
# After modification, make it s16le again
|
68
|
+
result.pack("s<*")
|
69
|
+
end
|
70
|
+
|
71
|
+
# Encodes a given file (or rather, decodes it) using ffmpeg. This accepts pretty much any format, even videos with
|
72
|
+
# an audio track. For a list of supported formats, see https://ffmpeg.org/general.html#Audio-Codecs. It even accepts
|
73
|
+
# URLs, though encoding them is pretty slow - I recommend to make a stream of it and then use {#encode_io} instead.
|
74
|
+
# @param file [String] The path or URL to encode.
|
75
|
+
# @param options [String] ffmpeg options to pass after the -i flag
|
76
|
+
# @return [IO] the audio, encoded as s16le PCM
|
77
|
+
def encode_file(file, options = "")
|
78
|
+
command = ffmpeg_command(input: file, options: options)
|
79
|
+
IO.popen(command)
|
80
|
+
end
|
81
|
+
|
82
|
+
# Encodes an arbitrary IO audio stream using ffmpeg. Accepts pretty much any media format, even videos with audio
|
83
|
+
# tracks. For a list of supported audio formats, see https://ffmpeg.org/general.html#Audio-Codecs.
|
84
|
+
# @param io [IO] The stream to encode.
|
85
|
+
# @param options [String] ffmpeg options to pass after the -i flag
|
86
|
+
# @return [IO] the audio, encoded as s16le PCM
|
87
|
+
def encode_io(io, options = "")
|
88
|
+
command = ffmpeg_command(options: options)
|
89
|
+
IO.popen(command, in: io)
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def ffmpeg_command(input: "-", options: null)
|
95
|
+
[
|
96
|
+
@use_avconv ? "avconv" : "ffmpeg",
|
97
|
+
"-loglevel", "0",
|
98
|
+
"-i", input,
|
99
|
+
"-f", "s16le",
|
100
|
+
"-ar", "48000",
|
101
|
+
"-ac", "2",
|
102
|
+
"pipe:1",
|
103
|
+
filter_volume_argument
|
104
|
+
].concat(options.split).reject { |segment| segment.nil? || segment == "" }
|
105
|
+
end
|
106
|
+
|
107
|
+
def filter_volume_argument
|
108
|
+
return "" if @filter_volume == 1
|
109
|
+
|
110
|
+
@use_avconv ? "-vol #{(@filter_volume * 256).ceil}" : "-af volume=#{@filter_volume}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,366 @@
|
|
1
|
+
require "websocket-client-simple"
|
2
|
+
require "socket"
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
require "rubycord/websocket"
|
6
|
+
|
7
|
+
begin
|
8
|
+
LIBSODIUM_AVAILABLE = if ENV["RUBYCORD_NONACL"]
|
9
|
+
false
|
10
|
+
else
|
11
|
+
require "rubycord/voice/sodium"
|
12
|
+
end
|
13
|
+
rescue LoadError
|
14
|
+
puts "libsodium not available! You can continue to use rubycord as normal but voice support won't work.
|
15
|
+
Read https://github.com/dakurei-gems/rubycord/wiki/Installing-libsodium for more details."
|
16
|
+
LIBSODIUM_AVAILABLE = false
|
17
|
+
end
|
18
|
+
|
19
|
+
module Rubycord::Voice
|
20
|
+
# Signifies to Discord that encryption should be used
|
21
|
+
# @deprecated Discord now supports multiple encryption options.
|
22
|
+
# TODO: Resolve replacement for this constant.
|
23
|
+
ENCRYPTED_MODE = "xsalsa20_poly1305"
|
24
|
+
|
25
|
+
# Signifies to Discord that no encryption should be used
|
26
|
+
# @deprecated Discord no longer supports unencrypted voice communication.
|
27
|
+
PLAIN_MODE = "plain"
|
28
|
+
|
29
|
+
# Encryption modes supported by Discord
|
30
|
+
ENCRYPTION_MODES = %w[xsalsa20_poly1305_lite xsalsa20_poly1305_suffix xsalsa20_poly1305].freeze
|
31
|
+
|
32
|
+
# Represents a UDP connection to a voice server. This connection is used to send the actual audio data.
|
33
|
+
class VoiceUDP
|
34
|
+
# @return [true, false] whether or not UDP communications are encrypted.
|
35
|
+
# @deprecated Discord no longer supports unencrypted voice communication.
|
36
|
+
attr_accessor :encrypted
|
37
|
+
alias_method :encrypted?, :encrypted
|
38
|
+
|
39
|
+
# Sets the secret key used for encryption
|
40
|
+
attr_writer :secret_key
|
41
|
+
|
42
|
+
# The UDP encryption mode
|
43
|
+
attr_reader :mode
|
44
|
+
|
45
|
+
# @!visibility private
|
46
|
+
attr_writer :mode
|
47
|
+
|
48
|
+
# Creates a new UDP connection. Only creates a socket as the discovery reply may come before the data is
|
49
|
+
# initialized.
|
50
|
+
def initialize
|
51
|
+
@socket = UDPSocket.new
|
52
|
+
@encrypted = true
|
53
|
+
end
|
54
|
+
|
55
|
+
# Initializes the UDP socket with data obtained from opcode 2.
|
56
|
+
# @param ip [String] The IP address to connect to.
|
57
|
+
# @param port [Integer] The port to connect to.
|
58
|
+
# @param ssrc [Integer] The Super Secret Relay Code (SSRC). Discord uses this to identify different voice users
|
59
|
+
# on the same endpoint.
|
60
|
+
def connect(ip, port, ssrc)
|
61
|
+
@ip = ip
|
62
|
+
@port = port
|
63
|
+
@ssrc = ssrc
|
64
|
+
end
|
65
|
+
|
66
|
+
# Waits for a UDP discovery reply, and returns the sent data.
|
67
|
+
# @return [Array(String, Integer)] the IP and port received from the discovery reply.
|
68
|
+
def receive_discovery_reply
|
69
|
+
# Wait for a UDP message
|
70
|
+
message = @socket.recv(74)
|
71
|
+
ip = message[8..-3].delete("\0")
|
72
|
+
port = message[-2..].unpack1("n")
|
73
|
+
[ip, port]
|
74
|
+
end
|
75
|
+
|
76
|
+
# Makes an audio packet from a buffer and sends it to Discord.
|
77
|
+
# @param buf [String] The audio data to send, must be exactly one Opus frame
|
78
|
+
# @param sequence [Integer] The packet sequence number, incremented by one for subsequent packets
|
79
|
+
# @param time [Integer] When this packet should be played back, in no particular unit (essentially just the
|
80
|
+
# sequence number multiplied by 960)
|
81
|
+
def send_audio(buf, sequence, time)
|
82
|
+
# Header of the audio packet
|
83
|
+
header = [0x80, 0x78, sequence, time, @ssrc].pack("CCnNN")
|
84
|
+
|
85
|
+
nonce = generate_nonce(header)
|
86
|
+
buf = encrypt_audio(buf, nonce)
|
87
|
+
|
88
|
+
data = header + buf
|
89
|
+
|
90
|
+
# xsalsa20_poly1305 does not require an appended nonce
|
91
|
+
data += nonce unless @mode == "xsalsa20_poly1305"
|
92
|
+
|
93
|
+
send_packet(data)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Sends the UDP discovery packet with the internally stored SSRC. Discord will send a reply afterwards which can
|
97
|
+
# be received using {#receive_discovery_reply}
|
98
|
+
def send_discovery
|
99
|
+
# Create empty packet
|
100
|
+
discovery_packet = ""
|
101
|
+
|
102
|
+
# Add Type request (0x1 = request, 0x2 = response)
|
103
|
+
discovery_packet += [0x1].pack("n")
|
104
|
+
|
105
|
+
# Add Length (excluding Type and itself = 70)
|
106
|
+
discovery_packet += [70].pack("n")
|
107
|
+
|
108
|
+
# Add SSRC
|
109
|
+
discovery_packet += [@ssrc].pack("N")
|
110
|
+
|
111
|
+
# Add 66 zeroes so the packet is 74 bytes long
|
112
|
+
discovery_packet += "\0" * 66
|
113
|
+
|
114
|
+
send_packet(discovery_packet)
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
# Encrypts audio data using libsodium
|
120
|
+
# @param buf [String] The encoded audio data to be encrypted
|
121
|
+
# @param nonce [String] The nonce to be used to encrypt the data
|
122
|
+
# @return [String] the audio data, encrypted
|
123
|
+
def encrypt_audio(buf, nonce)
|
124
|
+
raise "No secret key found, despite encryption being enabled!" unless @secret_key
|
125
|
+
|
126
|
+
secret_box = Rubycord::Voice::SecretBox.new(@secret_key)
|
127
|
+
|
128
|
+
# Nonces must be 24 bytes in length. We right pad with null bytes for poly1305 and poly1305_lite
|
129
|
+
secret_box.box(nonce.ljust(24, "\0"), buf)
|
130
|
+
end
|
131
|
+
|
132
|
+
def send_packet(packet)
|
133
|
+
@socket.send(packet, 0, @ip, @port)
|
134
|
+
end
|
135
|
+
|
136
|
+
# @param header [String] The header of the packet, to be used as the nonce
|
137
|
+
# @return [String]
|
138
|
+
# @note
|
139
|
+
# The nonce generated depends on the encryption mode.
|
140
|
+
# In xsalsa20_poly1305 the nonce is the header plus twelve null bytes for padding.
|
141
|
+
# In xsalsa20_poly1305_suffix, the nonce is 24 random bytes
|
142
|
+
# In xsalsa20_poly1305_lite, the nonce is an incremental 4 byte int.
|
143
|
+
def generate_nonce(header)
|
144
|
+
case @mode
|
145
|
+
when "xsalsa20_poly1305"
|
146
|
+
header
|
147
|
+
when "xsalsa20_poly1305_suffix"
|
148
|
+
Random.urandom(24)
|
149
|
+
when "xsalsa20_poly1305_lite"
|
150
|
+
case @lite_nonce
|
151
|
+
when nil, 0xff_ff_ff_ff
|
152
|
+
@lite_nonce = 0
|
153
|
+
else
|
154
|
+
@lite_nonce += 1
|
155
|
+
end
|
156
|
+
[@lite_nonce].pack("N")
|
157
|
+
else
|
158
|
+
raise "`#{@mode}' is not a supported encryption mode"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Represents a websocket client connection to the voice server. The websocket connection (sometimes called vWS) is
|
164
|
+
# used to manage general data about the connection, such as sending the speaking packet, which determines the green
|
165
|
+
# circle around users on Discord, and obtaining UDP connection info.
|
166
|
+
class VoiceWS
|
167
|
+
# The version of the voice gateway that's supposed to be used.
|
168
|
+
VOICE_GATEWAY_VERSION = 4
|
169
|
+
|
170
|
+
# @return [VoiceUDP] the UDP voice connection over which the actual audio data is sent.
|
171
|
+
attr_reader :udp
|
172
|
+
|
173
|
+
# Makes a new voice websocket client, but doesn't connect it (see {#connect} for that)
|
174
|
+
# @param channel [Channel] The voice channel to connect to
|
175
|
+
# @param bot [Bot] The regular bot to which this vWS is bound
|
176
|
+
# @param token [String] The authentication token which is also used for REST requests
|
177
|
+
# @param session [String] The voice session ID Discord sends over the regular websocket
|
178
|
+
# @param endpoint [String] The endpoint URL to connect to
|
179
|
+
def initialize(channel, bot, token, session, endpoint)
|
180
|
+
raise "libsodium is unavailable - unable to create voice bot! Please read https://github.com/dakurei-gems/rubycord/wiki/Installing-libsodium" unless LIBSODIUM_AVAILABLE
|
181
|
+
|
182
|
+
@channel = channel
|
183
|
+
@bot = bot
|
184
|
+
@token = token
|
185
|
+
@session = session
|
186
|
+
|
187
|
+
@endpoint = endpoint.split(":").first
|
188
|
+
|
189
|
+
@udp = VoiceUDP.new
|
190
|
+
end
|
191
|
+
|
192
|
+
# Send a connection init packet (op 0)
|
193
|
+
# @param server_id [Integer] The ID of the server to connect to
|
194
|
+
# @param bot_user_id [Integer] The ID of the bot that is connecting
|
195
|
+
# @param session_id [String] The voice session ID
|
196
|
+
# @param token [String] The Discord authentication token
|
197
|
+
def send_init(server_id, bot_user_id, session_id, token)
|
198
|
+
@client.send({
|
199
|
+
op: 0,
|
200
|
+
d: {
|
201
|
+
server_id: server_id,
|
202
|
+
user_id: bot_user_id,
|
203
|
+
session_id: session_id,
|
204
|
+
token: token
|
205
|
+
}
|
206
|
+
}.to_json)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Sends the UDP connection packet (op 1)
|
210
|
+
# @param ip [String] The IP to bind UDP to
|
211
|
+
# @param port [Integer] The port to bind UDP to
|
212
|
+
# @param mode [Object] Which mode to use for the voice connection
|
213
|
+
def send_udp_connection(ip, port, mode)
|
214
|
+
@client.send({
|
215
|
+
op: 1,
|
216
|
+
d: {
|
217
|
+
protocol: "udp",
|
218
|
+
data: {
|
219
|
+
address: ip,
|
220
|
+
port: port,
|
221
|
+
mode: mode
|
222
|
+
}
|
223
|
+
}
|
224
|
+
}.to_json)
|
225
|
+
end
|
226
|
+
|
227
|
+
# Send a heartbeat (op 3), has to be done every @heartbeat_interval seconds or the connection will terminate
|
228
|
+
def send_heartbeat
|
229
|
+
millis = Time.now.strftime("%s%L").to_i
|
230
|
+
@bot.debug("Sending voice heartbeat at #{millis}")
|
231
|
+
|
232
|
+
@client.send({
|
233
|
+
op: 3,
|
234
|
+
d: millis
|
235
|
+
}.to_json)
|
236
|
+
end
|
237
|
+
|
238
|
+
# Send a speaking packet (op 5). This determines the green circle around the avatar in the voice channel
|
239
|
+
# @param value [true, false, Integer] Whether or not the bot should be speaking, can also be a bitmask denoting audio type.
|
240
|
+
def send_speaking(value)
|
241
|
+
@bot.debug("Speaking: #{value}")
|
242
|
+
@client.send({
|
243
|
+
op: 5,
|
244
|
+
d: {
|
245
|
+
speaking: value,
|
246
|
+
delay: 0
|
247
|
+
}
|
248
|
+
}.to_json)
|
249
|
+
end
|
250
|
+
|
251
|
+
# Event handlers; public for websocket-simple to work correctly
|
252
|
+
# @!visibility private
|
253
|
+
def websocket_open
|
254
|
+
# Give the current thread a name ('Voice Web Socket Internal')
|
255
|
+
Thread.current[:rubycord_name] = "vws-i"
|
256
|
+
|
257
|
+
# Send the init packet
|
258
|
+
send_init(@channel.server.id, @bot.profile.id, @session, @token)
|
259
|
+
end
|
260
|
+
|
261
|
+
# @!visibility private
|
262
|
+
def websocket_message(msg)
|
263
|
+
@bot.debug("Received VWS message! #{msg}")
|
264
|
+
packet = JSON.parse(msg)
|
265
|
+
|
266
|
+
case packet["op"]
|
267
|
+
when 2
|
268
|
+
# Opcode 2 contains data to initialize the UDP connection
|
269
|
+
@ws_data = packet["d"]
|
270
|
+
|
271
|
+
@ssrc = @ws_data["ssrc"]
|
272
|
+
@port = @ws_data["port"]
|
273
|
+
|
274
|
+
@udp_mode = (ENCRYPTION_MODES & @ws_data["modes"]).first
|
275
|
+
|
276
|
+
@udp.connect(@ws_data["ip"], @port, @ssrc)
|
277
|
+
@udp.send_discovery
|
278
|
+
when 4
|
279
|
+
# Opcode 4 sends the secret key used for encryption
|
280
|
+
@ws_data = packet["d"]
|
281
|
+
|
282
|
+
@ready = true
|
283
|
+
@udp.secret_key = @ws_data["secret_key"].pack("C*")
|
284
|
+
@udp.mode = @ws_data["mode"]
|
285
|
+
when 8
|
286
|
+
# Opcode 8 contains the heartbeat interval.
|
287
|
+
@heartbeat_interval = packet["d"]["heartbeat_interval"]
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
# Communication goes like this:
|
292
|
+
# me discord
|
293
|
+
# | |
|
294
|
+
# websocket connect -> |
|
295
|
+
# | |
|
296
|
+
# | <- websocket opcode 2
|
297
|
+
# | |
|
298
|
+
# UDP discovery -> |
|
299
|
+
# | |
|
300
|
+
# | <- UDP reply packet
|
301
|
+
# | |
|
302
|
+
# websocket opcode 1 -> |
|
303
|
+
# | |
|
304
|
+
# ...
|
305
|
+
def connect
|
306
|
+
# Connect websocket
|
307
|
+
@thread = Thread.new do
|
308
|
+
Thread.current[:rubycord_name] = "vws"
|
309
|
+
init_ws
|
310
|
+
end
|
311
|
+
|
312
|
+
@bot.debug("Started websocket initialization, now waiting for UDP discovery reply")
|
313
|
+
|
314
|
+
# Now wait for opcode 2 and the resulting UDP reply packet
|
315
|
+
ip, port = @udp.receive_discovery_reply
|
316
|
+
@bot.debug("UDP discovery reply received! #{ip} #{port}")
|
317
|
+
|
318
|
+
# Send UDP init packet with received UDP data
|
319
|
+
send_udp_connection(ip, port, @udp_mode)
|
320
|
+
|
321
|
+
@bot.debug("Waiting for op 4 now")
|
322
|
+
|
323
|
+
# Wait for op 4, then finish
|
324
|
+
sleep 0.05 until @ready
|
325
|
+
end
|
326
|
+
|
327
|
+
# Disconnects the websocket and kills the thread
|
328
|
+
def destroy
|
329
|
+
@heartbeat_running = false
|
330
|
+
end
|
331
|
+
|
332
|
+
private
|
333
|
+
|
334
|
+
def heartbeat_loop
|
335
|
+
@heartbeat_running = true
|
336
|
+
while @heartbeat_running
|
337
|
+
if @heartbeat_interval
|
338
|
+
sleep @heartbeat_interval / 1000.0
|
339
|
+
send_heartbeat
|
340
|
+
else
|
341
|
+
# If no interval has been set yet, sleep a second and check again
|
342
|
+
sleep 1
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def init_ws
|
348
|
+
host = "wss://#{@endpoint}:443/?v=#{VOICE_GATEWAY_VERSION}"
|
349
|
+
@bot.debug("Connecting VWS to host: #{host}")
|
350
|
+
|
351
|
+
# Connect the WS
|
352
|
+
@client = Rubycord::WebSocket.new(
|
353
|
+
host,
|
354
|
+
method(:websocket_open),
|
355
|
+
method(:websocket_message),
|
356
|
+
proc { |e| Rubycord::LOGGER.error "VWS error: #{e}" },
|
357
|
+
proc { |e| Rubycord::LOGGER.warn "VWS close: #{e}" }
|
358
|
+
)
|
359
|
+
|
360
|
+
@bot.debug("VWS connected")
|
361
|
+
|
362
|
+
# Block any further execution
|
363
|
+
heartbeat_loop
|
364
|
+
end
|
365
|
+
end
|
366
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "ffi"
|
2
|
+
|
3
|
+
module Rubycord::Voice
|
4
|
+
# @!visibility private
|
5
|
+
module Sodium
|
6
|
+
extend ::FFI::Library
|
7
|
+
|
8
|
+
ffi_lib(["sodium", "libsodium.so.18", "libsodium.so.23"])
|
9
|
+
|
10
|
+
# Encryption & decryption
|
11
|
+
attach_function(:crypto_secretbox_xsalsa20poly1305, %i[pointer pointer ulong_long pointer pointer], :int)
|
12
|
+
attach_function(:crypto_secretbox_xsalsa20poly1305_open, %i[pointer pointer ulong_long pointer pointer], :int)
|
13
|
+
|
14
|
+
# Constants
|
15
|
+
attach_function(:crypto_secretbox_xsalsa20poly1305_keybytes, [], :size_t)
|
16
|
+
attach_function(:crypto_secretbox_xsalsa20poly1305_noncebytes, [], :size_t)
|
17
|
+
attach_function(:crypto_secretbox_xsalsa20poly1305_zerobytes, [], :size_t)
|
18
|
+
attach_function(:crypto_secretbox_xsalsa20poly1305_boxzerobytes, [], :size_t)
|
19
|
+
end
|
20
|
+
|
21
|
+
# Utility class for interacting with required `xsalsa20poly1305` functions for voice transmission
|
22
|
+
# @!visibility private
|
23
|
+
class SecretBox
|
24
|
+
# Exception raised when a key or nonce with invalid length is used
|
25
|
+
class LengthError < RuntimeError
|
26
|
+
end
|
27
|
+
|
28
|
+
# Exception raised when encryption or decryption fails
|
29
|
+
class CryptoError < RuntimeError
|
30
|
+
end
|
31
|
+
|
32
|
+
# Required key length
|
33
|
+
KEY_LENGTH = Sodium.crypto_secretbox_xsalsa20poly1305_keybytes
|
34
|
+
|
35
|
+
# Required nonce length
|
36
|
+
NONCE_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_noncebytes
|
37
|
+
|
38
|
+
# Zero byte padding for encryption
|
39
|
+
ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes
|
40
|
+
|
41
|
+
# Zero byte padding for decryption
|
42
|
+
BOX_ZERO_BYTES = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes
|
43
|
+
|
44
|
+
# @param key [String] Crypto key of length {KEY_LENGTH}
|
45
|
+
def initialize(key)
|
46
|
+
raise(LengthError, "Key length") if key.bytesize != KEY_LENGTH
|
47
|
+
|
48
|
+
@key = key
|
49
|
+
end
|
50
|
+
|
51
|
+
# Encrypts a message using this box's key
|
52
|
+
# @param nonce [String] encryption nonce for this message
|
53
|
+
# @param message [String] message to be encrypted
|
54
|
+
def box(nonce, message)
|
55
|
+
raise(LengthError, "Nonce length") if nonce.bytesize != NONCE_BYTES
|
56
|
+
|
57
|
+
message_padded = prepend_zeroes(ZERO_BYTES, message)
|
58
|
+
buffer = zero_string(message_padded.bytesize)
|
59
|
+
|
60
|
+
success = Sodium.crypto_secretbox_xsalsa20poly1305(buffer, message_padded, message_padded.bytesize, nonce, @key)
|
61
|
+
raise(CryptoError, "Encryption failed (#{success})") unless success.zero?
|
62
|
+
|
63
|
+
remove_zeroes(BOX_ZERO_BYTES, buffer)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Decrypts the given ciphertext using this box's key
|
67
|
+
# @param nonce [String] encryption nonce for this ciphertext
|
68
|
+
# @param ciphertext [String] ciphertext to decrypt
|
69
|
+
def open(nonce, ciphertext)
|
70
|
+
raise(LengthError, "Nonce length") if nonce.bytesize != NONCE_BYTES
|
71
|
+
|
72
|
+
ct_padded = prepend_zeroes(BOX_ZERO_BYTES, ciphertext)
|
73
|
+
buffer = zero_string(ct_padded.bytesize)
|
74
|
+
|
75
|
+
success = Sodium.crypto_secretbox_xsalsa20poly1305_open(buffer, ct_padded, ct_padded.bytesize, nonce, @key)
|
76
|
+
raise(CryptoError, "Decryption failed (#{success})") unless success.zero?
|
77
|
+
|
78
|
+
remove_zeroes(ZERO_BYTES, buffer)
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def zero_string(size)
|
84
|
+
str = "\0" * size
|
85
|
+
str.force_encoding("ASCII-8BIT") if str.respond_to?(:force_encoding)
|
86
|
+
end
|
87
|
+
|
88
|
+
def prepend_zeroes(size, string)
|
89
|
+
zero_string(size) + string
|
90
|
+
end
|
91
|
+
|
92
|
+
def remove_zeroes(size, string)
|
93
|
+
string.slice!(size, string.bytesize - size)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|