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