discordrb 1.5.4 → 1.6.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of discordrb might be problematic. Click here for more details.

@@ -31,7 +31,7 @@ module Discordrb
31
31
  23 => :deafen_members, # 8388608
32
32
  24 => :move_members, # 16777216
33
33
  25 => :use_voice_activity # 33554432
34
- }
34
+ }.freeze
35
35
 
36
36
  Flags.each do |position, flag|
37
37
  attr_reader flag
@@ -5,7 +5,7 @@ require 'discordrb/api'
5
5
 
6
6
  # Discordrb
7
7
  module Discordrb
8
- # Amount of bytes the key should be long (32 bytes = 256 bits -> AES256)
8
+ # Amount of bytes the token encryption key should be long (32 bytes = 256 bits -> AES256)
9
9
  KEYLEN = 32
10
10
 
11
11
  # Represents a cached token with encryption data
@@ -65,7 +65,6 @@ module Discordrb
65
65
  cipher.key = key
66
66
  @iv = cipher.random_iv
67
67
  @encrypted_token = cipher.update(token) + cipher.final
68
- @encrypted_token
69
68
  end
70
69
 
71
70
  def test_token(token)
@@ -80,7 +79,7 @@ module Discordrb
80
79
  end
81
80
  end
82
81
 
83
- # Path where the cache file will be stored
82
+ # Path where the token cache file will be stored
84
83
  CACHE_PATH = Dir.home + '/.discordrb_token_cache.json'
85
84
 
86
85
  # Represents a token file
@@ -1,4 +1,5 @@
1
1
  # Discordrb and all its functionality, in this case only the version.
2
2
  module Discordrb
3
- VERSION = '1.5.4'
3
+ # The current version of discordrb.
4
+ VERSION = '1.6.0'.freeze
4
5
  end
@@ -1,3 +1,4 @@
1
+ # This makes opus an optional dependency
1
2
  begin
2
3
  require 'opus-ruby'
3
4
  OPUS_AVAILABLE = true
@@ -7,10 +8,26 @@ end
7
8
 
8
9
  # Discord voice chat support
9
10
  module Discordrb::Voice
10
- # Wrapper class around opus-ruby
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.
11
13
  class Encoder
12
- attr_accessor :volume, :use_avconv
14
+ # The volume that should be used with future ffmpeg conversions. If ffmpeg is used, this can be specified as:
15
+ #
16
+ # * A number, where `1` is no change in volume, `0` is completely silent, `0.5` is half the default volume and `2` is twice the default.
17
+ # * A string representation of the above number.
18
+ # * A string representing a change in gain given in decibels, in the format `-6dB` or `6dB`.
19
+ #
20
+ # If avconv is used (see #use_avconv) then it can only be given as a number from `0` to `1`, where `1` is no change
21
+ # and `0` is completely silent.
22
+ # @return [String, Number] the volume for future playbacks, `1.0` by default.
23
+ attr_accessor :volume
13
24
 
25
+ # Whether or not avconv should be used instead of ffmpeg. If possible, it is recommended to use ffmpeg instead,
26
+ # as it is better supported and has a wider range of possible volume settings (see #volume).
27
+ # @return [true, false] whether avconv should be used instead of ffmpeg.
28
+ attr_accessor :use_avconv
29
+
30
+ # Create a new encoder
14
31
  def initialize
15
32
  @sample_rate = 48_000
16
33
  @frame_size = 960
@@ -24,19 +41,32 @@ module Discordrb::Voice
24
41
  end
25
42
  end
26
43
 
44
+ # Encodes the given buffer using opus.
45
+ # @param buffer [String] An unencoded PCM (s16le) buffer.
46
+ # @return [String] A buffer encoded using opus.
27
47
  def encode(buffer)
28
48
  @opus.encode(buffer, 1920)
29
49
  end
30
50
 
51
+ # Destroys this encoder and the opus connection, preventing any future encodings.
31
52
  def destroy
32
53
  @opus.destroy
33
54
  end
34
55
 
56
+ # Encodes a given file (or rather, decodes it) using ffmpeg. This accepts pretty much any format, even videos with
57
+ # an audio track. For a list of supported formats, see https://ffmpeg.org/general.html#Audio-Codecs. It even accepts
58
+ # URLs, though encoding them is pretty slow - I recommend to make a stream of it and then use {#encode_io} instead.
59
+ # @param file [String] The path or URL to encode.
60
+ # @return [IO] the audio, encoded as s16le PCM
35
61
  def encode_file(file)
36
62
  command = "#{ffmpeg_command} -loglevel 0 -i \"#{file}\" -f s16le -ar 48000 -ac 2 #{ffmpeg_volume} pipe:1"
37
63
  IO.popen(command)
38
64
  end
39
65
 
66
+ # Encodes an arbitrary IO audio stream using ffmpeg. Accepts pretty much any media format, even videos with audio
67
+ # tracks. For a list of supported audio formats, see https://ffmpeg.org/general.html#Audio-Codecs.
68
+ # @param io [IO] The stream to encode.
69
+ # @return [IO] the audio, encoded as s16le PCM
40
70
  def encode_io(io)
41
71
  ret_io, writer = IO.pipe
42
72
  command = "#{ffmpeg_command} -loglevel 0 -i - -f s16le -ar 48000 -ac 2 #{ffmpeg_volume} pipe:1"
@@ -2,16 +2,35 @@ require 'websocket-client-simple'
2
2
  require 'resolv'
3
3
  require 'socket'
4
4
  require 'json'
5
+ require 'rbnacl/libsodium'
5
6
 
6
7
  module Discordrb::Voice
7
- # Represents a UDP connection to a voice server
8
+ # Signifies to Discord that encryption should be used
9
+ ENCRYPTED_MODE = 'xsalsa20_poly1305'
10
+
11
+ # Signifies to Discord that no encryption should be used
12
+ PLAIN_MODE = 'plain'
13
+
14
+ # Represents a UDP connection to a voice server. This connection is used to send the actual audio data.
8
15
  class VoiceUDP
9
- # Only creates a socket as the discovery reply may come before the data is initialized.
16
+ # @return [true, false] whether or not UDP communications are encrypted.
17
+ attr_accessor :encrypted
18
+ alias_method :encrypted?, :encrypted
19
+
20
+ # Sets the secret key used for encryption
21
+ attr_writer :secret_key
22
+
23
+ # Creates a new UDP connection. Only creates a socket as the discovery reply may come before the data is
24
+ # initialized.
10
25
  def initialize
11
26
  @socket = UDPSocket.new
12
27
  end
13
28
 
14
- # Initializes the data from opcode 2
29
+ # Initializes the UDP socket with data obtained from opcode 2.
30
+ # @param endpoint [String] The voice endpoint to connect to.
31
+ # @param port [Integer] The port to connect to.
32
+ # @param ssrc [Integer] The Super Secret Relay Code (SSRC). Discord uses this to identify different voice users
33
+ # on the same endpoint.
15
34
  def connect(endpoint, port, ssrc)
16
35
  @endpoint = endpoint
17
36
  @endpoint = @endpoint[6..-1] if @endpoint.start_with? 'wss://'
@@ -22,6 +41,8 @@ module Discordrb::Voice
22
41
  @ssrc = ssrc
23
42
  end
24
43
 
44
+ # Waits for a UDP discovery reply, and returns the sent data.
45
+ # @return [Array(String, Integer)] the IP and port received from the discovery reply.
25
46
  def receive_discovery_reply
26
47
  # Wait for a UDP message
27
48
  message = @socket.recv(70)
@@ -30,11 +51,25 @@ module Discordrb::Voice
30
51
  [ip, port]
31
52
  end
32
53
 
54
+ # Makes an audio packet from a buffer and sends it to Discord.
55
+ # @param buf [String] The audio data to send, must be exactly one Opus frame
56
+ # @param sequence [Integer] The packet sequence number, incremented by one for subsequent packets
57
+ # @param time [Integer] When this packet should be played back, in no particular unit (essentially just the
58
+ # sequence number multiplied by 960)
33
59
  def send_audio(buf, sequence, time)
34
- packet = [0x80, 0x78, sequence, time, @ssrc].pack('CCnNN') + buf
35
- send_packet(packet)
60
+ # Header of the audio packet
61
+ header = [0x80, 0x78, sequence, time, @ssrc].pack('CCnNN')
62
+
63
+ # Encrypt data, if necessary
64
+ if encrypted?
65
+ buf = encrypt_audio(header, buf)
66
+ end
67
+
68
+ send_packet(header + buf)
36
69
  end
37
70
 
71
+ # Sends the UDP discovery packet with the internally stored SSRC. Discord will send a reply afterwards which can
72
+ # be received using {#receive_discovery_reply}
38
73
  def send_discovery
39
74
  discovery_packet = [@ssrc].pack('N')
40
75
 
@@ -45,15 +80,38 @@ module Discordrb::Voice
45
80
 
46
81
  private
47
82
 
83
+ # Encrypts audio data using RbNaCl
84
+ # @param header [String] The header of the packet, to be used as the nonce
85
+ # @param buf [String] The encoded audio data to be encrypted
86
+ # @return [String] the audio data, encrypted
87
+ def encrypt_audio(header, buf)
88
+ fail 'No secret key found, despite encryption being enabled!' unless @secret_key
89
+ box = RbNaCl::SecretBox.new(@secret_key)
90
+
91
+ # The nonce is the header of the voice packet with 12 null bytes appended
92
+ nonce = header + ([0] * 12).pack('C*')
93
+
94
+ box.encrypt(nonce, buf)
95
+ end
96
+
48
97
  def send_packet(packet)
49
98
  @socket.send(packet, 0, @endpoint, @port)
50
99
  end
51
100
  end
52
101
 
53
- # Represents a websocket connection to the voice server
102
+ # Represents a websocket client connection to the voice server. The websocket connection (sometimes called vWS) is
103
+ # used to manage general data about the connection, such as sending the speaking packet, which determines the green
104
+ # circle around users on Discord, and obtaining UDP connection info.
54
105
  class VoiceWS
106
+ # @return [VoiceUDP] the UDP voice connection over which the actual audio data is sent.
55
107
  attr_reader :udp
56
108
 
109
+ # Makes a new voice websocket client, but doesn't connect it (see {#connect} for that)
110
+ # @param channel [Channel] The voice channel to connect to
111
+ # @param bot [Bot] The regular bot to which this vWS is bound
112
+ # @param token [String] The authentication token which is also used for REST requests
113
+ # @param session [String] The voice session ID Discord sends over the regular websocket
114
+ # @param endpoint [String] The endpoint URL to connect to
57
115
  def initialize(channel, bot, token, session, endpoint)
58
116
  @channel = channel
59
117
  @bot = bot
@@ -67,6 +125,10 @@ module Discordrb::Voice
67
125
  end
68
126
 
69
127
  # Send a connection init packet (op 0)
128
+ # @param server_id [Integer] The ID of the server to connect to
129
+ # @param bot_user_id [Integer] The ID of the bot that is connecting
130
+ # @param session_id [String] The voice session ID
131
+ # @param token [String] The Discord authentication token
70
132
  def send_init(server_id, bot_user_id, session_id, token)
71
133
  @client.send({
72
134
  op: 0,
@@ -80,6 +142,9 @@ module Discordrb::Voice
80
142
  end
81
143
 
82
144
  # Sends the UDP connection packet (op 1)
145
+ # @param ip [String] The IP to bind UDP to
146
+ # @param port [Integer] The port to bind UDP to
147
+ # @param mode [Object] Which mode to use for the voice connection
83
148
  def send_udp_connection(ip, port, mode)
84
149
  @client.send({
85
150
  op: 1,
@@ -100,12 +165,13 @@ module Discordrb::Voice
100
165
  @bot.debug("Sending voice heartbeat at #{millis}")
101
166
 
102
167
  @client.send({
103
- 'op' => 3,
104
- 'd' => nil
168
+ op: 3,
169
+ d: nil
105
170
  }.to_json)
106
171
  end
107
172
 
108
173
  # Send a speaking packet (op 5). This determines the green circle around the avatar in the voice channel
174
+ # @param value [true, false] Whether or not the bot should be speaking
109
175
  def send_speaking(value)
110
176
  @bot.debug("Speaking: #{value}")
111
177
  @client.send({
@@ -118,11 +184,13 @@ module Discordrb::Voice
118
184
  end
119
185
 
120
186
  # Event handlers; public for websocket-simple to work correctly
187
+ # @!visibility private
121
188
  def websocket_open
122
189
  # Send the init packet
123
190
  send_init(@channel.server.id, @bot.bot_user.id, @session, @token)
124
191
  end
125
192
 
193
+ # @!visibility private
126
194
  def websocket_message(msg)
127
195
  @bot.debug("Received VWS message! #{msg}")
128
196
  packet = JSON.parse(msg)
@@ -135,15 +203,17 @@ module Discordrb::Voice
135
203
  @heartbeat_interval = @ws_data['heartbeat_interval']
136
204
  @ssrc = @ws_data['ssrc']
137
205
  @port = @ws_data['port']
138
- @udp_mode = @ws_data['modes'][0]
206
+ @udp_mode = mode
139
207
 
140
208
  @udp.connect(@endpoint, @port, @ssrc)
141
209
  @udp.send_discovery
142
210
  when 4
143
- # I'm not 100% sure what this packet does, but I'm keeping it for future compatibility.
211
+ # Opcode 4 sends the secret key used for encryption
144
212
  @ws_data = packet['d']
145
213
  @ready = true
146
- @mode = @ws_data['mode']
214
+ @udp.secret_key = @ws_data['secret_key'].pack('C*')
215
+ else
216
+ # irrelevant opcode, ignore
147
217
  end
148
218
  end
149
219
 
@@ -178,12 +248,18 @@ module Discordrb::Voice
178
248
  send_udp_connection(ip, port, @udp_mode)
179
249
  end
180
250
 
251
+ # Disconnects the websocket and kills the thread
181
252
  def destroy
182
253
  @thread.kill if @thread
183
254
  end
184
255
 
185
256
  private
186
257
 
258
+ # @return [String] the mode string that signifies whether encryption should be used or not
259
+ def mode
260
+ @udp.encrypted? ? ENCRYPTED_MODE : PLAIN_MODE
261
+ end
262
+
187
263
  def heartbeat_loop
188
264
  loop do
189
265
  if @heartbeat_interval
@@ -3,21 +3,56 @@ require 'discordrb/voice/network'
3
3
 
4
4
  # Voice support
5
5
  module Discordrb::Voice
6
- # How long one packet should ideally be (20 ms as defined by Discord)
6
+ # How long one voice packet should ideally be (20 ms as defined by Discord)
7
7
  IDEAL_LENGTH = 20.0
8
8
 
9
- # How many bytes of data to read (1920 bytes * 2 channels)
9
+ # How many bytes of data to read (1920 bytes * 2 channels) from audio PCM data
10
10
  DATA_LENGTH = 1920 * 2
11
11
 
12
- # A voice connection consisting of a UDP socket and a websocket client
12
+ # This class represents a connection to a Discord voice server and channel. It can be used to play audio files and
13
+ # streams and to control playback on currently playing tracks. The method {Bot#voice_connect} can be used to connect
14
+ # to a voice channel.
15
+ #
16
+ # discordrb does latency adjustments every now and then to improve playback quality. I made sure to put useful
17
+ # defaults for the adjustment parameters, but if the sound is patchy or too fast (or the speed varies a lot) you
18
+ # should check the parameters and adjust them to your connection: {VoiceBot#adjust_interval},
19
+ # {VoiceBot#adjust_offset}, and {VoiceBot#adjust_average}.
13
20
  class VoiceBot
14
- attr_reader :stream_time, :encoder
15
- attr_accessor :adjust_interval, :adjust_offset, :adjust_average
16
-
17
- def initialize(channel, bot, token, session, endpoint)
21
+ # @return [Integer, nil] the amount of time the stream has been playing, or `nil` if nothing has been played yet.
22
+ attr_reader :stream_time
23
+
24
+ # @return [Encoder] the encoder used to encode audio files into the format required by Discord.
25
+ attr_reader :encoder
26
+
27
+ # discordrb will occasionally measure the time it takes to send a packet, and adjust future delay times based
28
+ # on that data. This makes voice playback more smooth, because if packets are sent too slowly, the audio will
29
+ # sound patchy, and if they're sent too quickly, packets will "pile up" and occasionally skip some data or
30
+ # play parts back too fast. How often these measurements should be done depends a lot on the system, and if it's
31
+ # done too quickly, especially on slow connections, the playback speed will vary wildly; if it's done too slowly
32
+ # however, small errors will cause quality problems for a longer time.
33
+ # @return [Integer] how frequently audio length adjustments should be done, in ideal packets (20 ms).
34
+ attr_accessor :adjust_interval
35
+
36
+ # This particular value is also important because ffmpeg may take longer to process the first few packets. It is
37
+ # recommended to set this to 10 at maximum, otherwise it will take too long to make the first adjustment, but it
38
+ # shouldn't be any higher than {#adjust_interval}, otherwise no adjustments will take place. If {#adjust_interval}
39
+ # is at a value higher than 10, this value should not be changed at all.
40
+ # @see #adjust_interval
41
+ # @return [Integer] the packet number (1 packet = 20 ms) at which length adjustments should start.
42
+ attr_accessor :adjust_offset
43
+
44
+ # This value determines whether or not the adjustment length should be averaged with the previous value. This may
45
+ # be useful on slower connections where latencies vary a lot. In general, it will make adjustments more smooth,
46
+ # but whether that is desired behaviour should be tried on a case-by-case basis.
47
+ # @see #adjust_interval
48
+ # @return [true, false] whether adjustment lengths should be averaged with the respective previous value.
49
+ attr_accessor :adjust_average
50
+
51
+ def initialize(channel, bot, token, session, endpoint, encrypted)
18
52
  @bot = bot
19
53
  @ws = VoiceWS.new(channel, bot, token, session, endpoint)
20
54
  @udp = @ws.udp
55
+ @udp.encrypted = encrypted
21
56
 
22
57
  @sequence = @time = 0
23
58
 
@@ -30,29 +65,41 @@ module Discordrb::Voice
30
65
  end
31
66
 
32
67
  # Set the volume. Only applies to future playbacks
68
+ # @see Encoder#volume=
33
69
  def volume=(value)
34
70
  @encoder.volume = value
35
71
  end
36
72
 
73
+ # @see Encoder#volume
74
+ # @return [Integer, String] the current encoder volume.
37
75
  def volume
38
76
  @encoder.volume
39
77
  end
40
78
 
41
- # Pause playback
79
+ # @return [true, false] whether audio data sent will be encrypted.
80
+ def encrypted?
81
+ @udp.encrypted?
82
+ end
83
+
84
+ # Pause playback. This is not instant; it may take up to 20 ms for this change to take effect. (This is usually
85
+ # negligible.)
42
86
  def pause
43
87
  @paused = true
44
88
  end
45
89
 
46
- # Continue playback
90
+ # Continue playback. This change may take up to 100 ms to take effect, which is usually negligible.
47
91
  def continue
48
92
  @paused = false
49
93
  end
50
94
 
95
+ # Sets whether or not the bot is speaking (green circle around user).
96
+ # @param value [true, false] whether or not the bot should be speaking.
51
97
  def speaking=(value)
52
98
  @playing = value
53
99
  @ws.send_speaking(value)
54
100
  end
55
101
 
102
+ # Stops the current playback entirely.
56
103
  def stop_playing
57
104
  @was_playing_before = @playing
58
105
  @speaking = false
@@ -61,22 +108,34 @@ module Discordrb::Voice
61
108
  sleep IDEAL_LENGTH / 1000.0 if @was_playing_before
62
109
  end
63
110
 
111
+ # Permanently disconnects from the voice channel; to reconnect you will have to call {Bot#voice_connect} again.
64
112
  def destroy
65
113
  stop_playing
66
114
  @ws.destroy
67
115
  @encoder.destroy
68
116
  end
69
117
 
118
+ # Plays a stream of raw data to the channel. All playback methods are blocking, i. e. they wait for the playback to
119
+ # finish before exiting the method. This doesn't cause a problem if you just use discordrb events/commands to
120
+ # play stuff, as these are fully threaded, but if you don't want this behaviour anyway, be sure to call these
121
+ # methods in separate threads.
122
+ # @param encoded_io [IO] A stream of raw PCM data (s16le)
70
123
  def play(encoded_io)
71
124
  stop_playing if @playing
72
125
  @io = encoded_io
73
126
  play_internal
74
127
  end
75
128
 
129
+ # Plays an encoded audio file of arbitrary format to the channel.
130
+ # @see Encoder#encode_file
131
+ # @see #play
76
132
  def play_file(file)
77
133
  play @encoder.encode_file(file)
78
134
  end
79
135
 
136
+ # Plays a stream of encoded audio data of arbitrary format to the channel.
137
+ # @see Encoder#encode_io
138
+ # @see #play
80
139
  def play_io(io)
81
140
  play @encoder.encode_io(io)
82
141
  end
@@ -116,12 +175,10 @@ module Discordrb::Voice
116
175
  # Check whether the buffer has enough data
117
176
  if !buf || buf.length != DATA_LENGTH
118
177
  @bot.debug("No data is available! Retrying #{@retry_attempts} more times")
119
- if @retry_attempts == 0
120
- break
121
- else
122
- @retry_attempts -= 1
123
- next
124
- end
178
+ break if @retry_attempts == 0
179
+
180
+ @retry_attempts -= 1
181
+ next
125
182
  end
126
183
 
127
184
  # Track packet count, sequence and time (Discord requires this)
@@ -140,11 +197,11 @@ module Discordrb::Voice
140
197
  # Difference between length_adjust and now in ms
141
198
  ms_diff = (Time.now.nsec - @length_adjust) / 1_000_000.0
142
199
  if ms_diff >= 0
143
- if @adjust_average
144
- @length = (IDEAL_LENGTH - ms_diff + @length) / 2.0
145
- else
146
- @length = IDEAL_LENGTH - ms_diff
147
- end
200
+ @length = if @adjust_average
201
+ (IDEAL_LENGTH - ms_diff + @length) / 2.0
202
+ else
203
+ IDEAL_LENGTH - ms_diff
204
+ end
148
205
 
149
206
  @bot.debug("Length adjustment: new length #{@length}")
150
207
  end