discordrb 3.3.0 → 3.4.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +126 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +39 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +25 -0
  5. data/.github/pull_request_template.md +37 -0
  6. data/.rubocop.yml +34 -37
  7. data/.travis.yml +5 -6
  8. data/CHANGELOG.md +504 -347
  9. data/Gemfile +2 -0
  10. data/LICENSE.txt +1 -1
  11. data/README.md +61 -79
  12. data/Rakefile +2 -0
  13. data/bin/console +1 -0
  14. data/discordrb-webhooks.gemspec +6 -6
  15. data/discordrb.gemspec +18 -18
  16. data/lib/discordrb/allowed_mentions.rb +36 -0
  17. data/lib/discordrb/api/channel.rb +62 -39
  18. data/lib/discordrb/api/invite.rb +3 -3
  19. data/lib/discordrb/api/server.rb +57 -50
  20. data/lib/discordrb/api/user.rb +9 -8
  21. data/lib/discordrb/api/webhook.rb +6 -6
  22. data/lib/discordrb/api.rb +40 -15
  23. data/lib/discordrb/await.rb +0 -1
  24. data/lib/discordrb/bot.rb +175 -73
  25. data/lib/discordrb/cache.rb +4 -2
  26. data/lib/discordrb/colour_rgb.rb +43 -0
  27. data/lib/discordrb/commands/command_bot.rb +30 -9
  28. data/lib/discordrb/commands/container.rb +20 -23
  29. data/lib/discordrb/commands/parser.rb +18 -18
  30. data/lib/discordrb/commands/rate_limiter.rb +3 -2
  31. data/lib/discordrb/container.rb +77 -17
  32. data/lib/discordrb/data/activity.rb +271 -0
  33. data/lib/discordrb/data/application.rb +50 -0
  34. data/lib/discordrb/data/attachment.rb +56 -0
  35. data/lib/discordrb/data/audit_logs.rb +345 -0
  36. data/lib/discordrb/data/channel.rb +849 -0
  37. data/lib/discordrb/data/embed.rb +251 -0
  38. data/lib/discordrb/data/emoji.rb +82 -0
  39. data/lib/discordrb/data/integration.rb +83 -0
  40. data/lib/discordrb/data/invite.rb +137 -0
  41. data/lib/discordrb/data/member.rb +297 -0
  42. data/lib/discordrb/data/message.rb +334 -0
  43. data/lib/discordrb/data/overwrite.rb +102 -0
  44. data/lib/discordrb/data/profile.rb +91 -0
  45. data/lib/discordrb/data/reaction.rb +33 -0
  46. data/lib/discordrb/data/recipient.rb +34 -0
  47. data/lib/discordrb/data/role.rb +191 -0
  48. data/lib/discordrb/data/server.rb +1002 -0
  49. data/lib/discordrb/data/user.rb +204 -0
  50. data/lib/discordrb/data/voice_region.rb +45 -0
  51. data/lib/discordrb/data/voice_state.rb +41 -0
  52. data/lib/discordrb/data/webhook.rb +145 -0
  53. data/lib/discordrb/data.rb +25 -4180
  54. data/lib/discordrb/errors.rb +2 -1
  55. data/lib/discordrb/events/bans.rb +7 -5
  56. data/lib/discordrb/events/channels.rb +2 -0
  57. data/lib/discordrb/events/guilds.rb +16 -9
  58. data/lib/discordrb/events/invites.rb +125 -0
  59. data/lib/discordrb/events/members.rb +6 -2
  60. data/lib/discordrb/events/message.rb +69 -27
  61. data/lib/discordrb/events/presence.rb +14 -4
  62. data/lib/discordrb/events/raw.rb +1 -3
  63. data/lib/discordrb/events/reactions.rb +49 -3
  64. data/lib/discordrb/events/typing.rb +6 -4
  65. data/lib/discordrb/events/voice_server_update.rb +47 -0
  66. data/lib/discordrb/events/voice_state_update.rb +15 -10
  67. data/lib/discordrb/events/webhooks.rb +9 -6
  68. data/lib/discordrb/gateway.rb +72 -57
  69. data/lib/discordrb/id_object.rb +39 -0
  70. data/lib/discordrb/light/integrations.rb +1 -1
  71. data/lib/discordrb/light/light_bot.rb +1 -1
  72. data/lib/discordrb/logger.rb +4 -4
  73. data/lib/discordrb/paginator.rb +57 -0
  74. data/lib/discordrb/permissions.rb +103 -8
  75. data/lib/discordrb/version.rb +1 -1
  76. data/lib/discordrb/voice/encoder.rb +16 -7
  77. data/lib/discordrb/voice/network.rb +84 -43
  78. data/lib/discordrb/voice/sodium.rb +96 -0
  79. data/lib/discordrb/voice/voice_bot.rb +34 -26
  80. data/lib/discordrb.rb +73 -0
  81. metadata +98 -60
  82. /data/{CONTRIBUTING.md → .github/CONTRIBUTING.md} +0 -0
@@ -4,8 +4,7 @@ module Discordrb
4
4
  # List of permissions Discord uses
5
5
  class Permissions
6
6
  # This hash maps bit positions to logical permissions.
7
- # I'm not sure what the unlabeled bits are reserved for.
8
- Flags = {
7
+ FLAGS = {
9
8
  # Bit => Permission # Value
10
9
  0 => :create_instant_invite, # 1
11
10
  1 => :kick_members, # 2
@@ -16,7 +15,7 @@ module Discordrb
16
15
  6 => :add_reactions, # 64
17
16
  7 => :view_audit_log, # 128
18
17
  8 => :priority_speaker, # 256
19
- # 9 # 512
18
+ 9 => :stream, # 512
20
19
  10 => :read_messages, # 1024
21
20
  11 => :send_messages, # 2048
22
21
  12 => :send_tts_messages, # 4096
@@ -26,7 +25,7 @@ module Discordrb
26
25
  16 => :read_message_history, # 65536
27
26
  17 => :mention_everyone, # 131072
28
27
  18 => :use_external_emoji, # 262144
29
- # 19 # 524288
28
+ 19 => :view_server_insights, # 524288
30
29
  20 => :connect, # 1048576
31
30
  21 => :speak, # 2097152
32
31
  22 => :mute_members, # 4194304
@@ -40,8 +39,9 @@ module Discordrb
40
39
  30 => :manage_emojis # 1073741824
41
40
  }.freeze
42
41
 
43
- Flags.each do |position, flag|
42
+ FLAGS.each do |position, flag|
44
43
  attr_reader flag
44
+
45
45
  define_method "can_#{flag}=" do |value|
46
46
  new_bits = @bits
47
47
  if value
@@ -49,7 +49,7 @@ module Discordrb
49
49
  else
50
50
  new_bits &= ~(1 << position)
51
51
  end
52
- @writer.write(new_bits) if @writer
52
+ @writer&.write(new_bits)
53
53
  @bits = new_bits
54
54
  init_vars
55
55
  end
@@ -69,7 +69,7 @@ module Discordrb
69
69
 
70
70
  # Initialize the instance variables based on the bitset.
71
71
  def init_vars
72
- Flags.each do |position, flag|
72
+ FLAGS.each do |position, flag|
73
73
  flag_set = ((@bits >> position) & 0x1) == 1
74
74
  instance_variable_set "@#{flag}", flag_set
75
75
  end
@@ -85,7 +85,7 @@ module Discordrb
85
85
  def self.bits(list)
86
86
  value = 0
87
87
 
88
- Flags.each do |position, flag|
88
+ FLAGS.each do |position, flag|
89
89
  value += 2**position if list.include? flag
90
90
  end
91
91
 
@@ -121,4 +121,99 @@ module Discordrb
121
121
  bits == other.bits
122
122
  end
123
123
  end
124
+
125
+ # Mixin to calculate resulting permissions from overrides etc.
126
+ module PermissionCalculator
127
+ # Checks whether this user can do the particular action, regardless of whether it has the permission defined,
128
+ # through for example being the server owner or having the Manage Roles permission
129
+ # @param action [Symbol] The permission that should be checked. See also {Permissions::FLAGS} for a list.
130
+ # @param channel [Channel, nil] If channel overrides should be checked too, this channel specifies where the overrides should be checked.
131
+ # @example Check if the bot can send messages to a specific channel in a server.
132
+ # bot_profile = bot.profile.on(event.server)
133
+ # can_send_messages = bot_profile.permission?(:send_messages, channel)
134
+ # @return [true, false] whether or not this user has the permission.
135
+ def permission?(action, channel = nil)
136
+ # If the member is the server owner, it irrevocably has all permissions.
137
+ return true if owner?
138
+
139
+ # First, check whether the user has Manage Roles defined.
140
+ # (Coincidentally, Manage Permissions is the same permission as Manage Roles, and a
141
+ # Manage Permissions deny overwrite will override Manage Roles, so we can just check for
142
+ # Manage Roles once and call it a day.)
143
+ return true if defined_permission?(:administrator, channel)
144
+
145
+ # Otherwise, defer to defined_permission
146
+ defined_permission?(action, channel)
147
+ end
148
+
149
+ # Checks whether this user has a particular permission defined (i.e. not implicit, through for example
150
+ # Manage Roles)
151
+ # @param action [Symbol] The permission that should be checked. See also {Permissions::FLAGS} for a list.
152
+ # @param channel [Channel, nil] If channel overrides should be checked too, this channel specifies where the overrides should be checked.
153
+ # @example Check if a member has the Manage Channels permission defined in the server.
154
+ # has_manage_channels = member.defined_permission?(:manage_channels)
155
+ # @return [true, false] whether or not this user has the permission defined.
156
+ def defined_permission?(action, channel = nil)
157
+ # Get the permission the user's roles have
158
+ role_permission = defined_role_permission?(action, channel)
159
+
160
+ # Once we have checked the role permission, we have to check the channel overrides for the
161
+ # specific user
162
+ user_specific_override = permission_overwrite(action, channel, id) # Use the ID reader as members have no ID instance variable
163
+
164
+ # Merge the two permissions - if an override is defined, it has to be allow, otherwise we only care about the role
165
+ return role_permission unless user_specific_override
166
+
167
+ user_specific_override == :allow
168
+ end
169
+
170
+ # Define methods for querying permissions
171
+ Discordrb::Permissions::FLAGS.each_value do |flag|
172
+ define_method "can_#{flag}?" do |channel = nil|
173
+ permission? flag, channel
174
+ end
175
+ end
176
+
177
+ alias_method :can_administrate?, :can_administrator?
178
+
179
+ private
180
+
181
+ def defined_role_permission?(action, channel)
182
+ roles_to_check = [@server.everyone_role] + @roles
183
+
184
+ # For each role, check if
185
+ # (1) the channel explicitly allows or permits an action for the role and
186
+ # (2) if the user is allowed to do the action if the channel doesn't specify
187
+ roles_to_check.sort_by(&:position).reduce(false) do |can_act, role|
188
+ # Get the override defined for the role on the channel
189
+ channel_allow = permission_overwrite(action, channel, role.id)
190
+ if channel_allow
191
+ # If the channel has an override, check whether it is an allow - if yes,
192
+ # the user can act, if not, it can't
193
+ break true if channel_allow == :allow
194
+
195
+ false
196
+ else
197
+ # Otherwise defer to the role
198
+ role.permissions.instance_variable_get("@#{action}") || can_act
199
+ end
200
+ end
201
+ end
202
+
203
+ def permission_overwrite(action, channel, id)
204
+ # If no overwrites are defined, or no channel is set, no overwrite will be present
205
+ return nil unless channel && channel.permission_overwrites[id]
206
+
207
+ # Otherwise, check the allow and deny objects
208
+ allow = channel.permission_overwrites[id].allow
209
+ deny = channel.permission_overwrites[id].deny
210
+ if allow.instance_variable_get("@#{action}")
211
+ :allow
212
+ elsif deny.instance_variable_get("@#{action}")
213
+ :deny
214
+ end
215
+
216
+ # If there's no variable defined, nil will implicitly be returned
217
+ end
218
+ end
124
219
  end
@@ -3,5 +3,5 @@
3
3
  # Discordrb and all its functionality, in this case only the version.
4
4
  module Discordrb
5
5
  # The current version of discordrb.
6
- VERSION = '3.3.0'.freeze
6
+ VERSION = '3.4.3'
7
7
  end
@@ -30,6 +30,7 @@ module Discordrb::Voice
30
30
  @filter_volume = 1
31
31
 
32
32
  raise LoadError, 'Opus unavailable - voice not supported! Please install opus for voice support to work.' unless OPUS_AVAILABLE
33
+
33
34
  @opus = Opus::Encoder.new(sample_rate, frame_size, channels)
34
35
  end
35
36
 
@@ -76,7 +77,7 @@ module Discordrb::Voice
76
77
  # @param options [String] ffmpeg options to pass after the -i flag
77
78
  # @return [IO] the audio, encoded as s16le PCM
78
79
  def encode_file(file, options = '')
79
- command = "#{ffmpeg_command} -loglevel 0 -i \"#{file}\" #{options} -f s16le -ar 48000 -ac 2 #{filter_volume_argument} pipe:1"
80
+ command = ffmpeg_command(input: file, options: options)
80
81
  IO.popen(command)
81
82
  end
82
83
 
@@ -86,20 +87,28 @@ module Discordrb::Voice
86
87
  # @param options [String] ffmpeg options to pass after the -i flag
87
88
  # @return [IO] the audio, encoded as s16le PCM
88
89
  def encode_io(io, options = '')
89
- ret_io, writer = IO.pipe
90
- command = "#{ffmpeg_command} -loglevel 0 -i - #{options} -f s16le -ar 48000 -ac 2 #{filter_volume_argument} pipe:1"
91
- spawn(command, in: io, out: writer)
92
- ret_io
90
+ command = ffmpeg_command(options: options)
91
+ IO.popen(command, in: io)
93
92
  end
94
93
 
95
94
  private
96
95
 
97
- def ffmpeg_command
98
- @use_avconv ? 'avconv' : 'ffmpeg'
96
+ def ffmpeg_command(input: '-', options: null)
97
+ [
98
+ @use_avconv ? 'avconv' : 'ffmpeg',
99
+ '-loglevel', '0',
100
+ '-i', input,
101
+ '-f', 's16le',
102
+ '-ar', '48000',
103
+ '-ac', '2',
104
+ 'pipe:1',
105
+ filter_volume_argument,
106
+ ].concat(options.split).reject {|segment| segment.nil? || segment == '' }
99
107
  end
100
108
 
101
109
  def filter_volume_argument
102
110
  return '' if @filter_volume == 1
111
+
103
112
  @use_avconv ? "-vol #{(@filter_volume * 256).ceil}" : "-af volume=#{@filter_volume}"
104
113
  end
105
114
  end
@@ -1,58 +1,66 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'websocket-client-simple'
4
- require 'resolv'
5
4
  require 'socket'
6
5
  require 'json'
7
6
 
8
7
  require 'discordrb/websocket'
9
8
 
10
9
  begin
11
- RBNACL_AVAILABLE = if ENV['DISCORDRB_NONACL']
12
- false
13
- else
14
- require 'rbnacl'
15
- true
16
- end
10
+ LIBSODIUM_AVAILABLE = if ENV['DISCORDRB_NONACL']
11
+ false
12
+ else
13
+ require 'discordrb/voice/sodium'
14
+ end
17
15
  rescue LoadError
18
16
  puts "libsodium not available! You can continue to use discordrb as normal but voice support won't work.
19
- Read https://github.com/meew0/discordrb/wiki/Installing-libsodium for more details."
20
- RBNACL_AVAILABLE = false
17
+ Read https://github.com/shardlab/discordrb/wiki/Installing-libsodium for more details."
18
+ LIBSODIUM_AVAILABLE = false
21
19
  end
22
20
 
23
21
  module Discordrb::Voice
24
22
  # Signifies to Discord that encryption should be used
25
- ENCRYPTED_MODE = 'xsalsa20_poly1305'.freeze
23
+ # @deprecated Discord now supports multiple encryption options.
24
+ # TODO: Resolve replacement for this constant.
25
+ ENCRYPTED_MODE = 'xsalsa20_poly1305'
26
26
 
27
27
  # Signifies to Discord that no encryption should be used
28
- PLAIN_MODE = 'plain'.freeze
28
+ # @deprecated Discord no longer supports unencrypted voice communication.
29
+ PLAIN_MODE = 'plain'
30
+
31
+ # Encryption modes supported by Discord
32
+ ENCRYPTION_MODES = %w[xsalsa20_poly1305_lite xsalsa20_poly1305_suffix xsalsa20_poly1305].freeze
29
33
 
30
34
  # Represents a UDP connection to a voice server. This connection is used to send the actual audio data.
31
35
  class VoiceUDP
32
36
  # @return [true, false] whether or not UDP communications are encrypted.
37
+ # @deprecated Discord no longer supports unencrypted voice communication.
33
38
  attr_accessor :encrypted
34
39
  alias_method :encrypted?, :encrypted
35
40
 
36
41
  # Sets the secret key used for encryption
37
42
  attr_writer :secret_key
38
43
 
44
+ # The UDP encryption mode
45
+ attr_reader :mode # rubocop:disable Style/BisectedAttrAccessor
46
+
47
+ # @!visibility private
48
+ attr_writer :mode # rubocop:disable Style/BisectedAttrAccessor
49
+
39
50
  # Creates a new UDP connection. Only creates a socket as the discovery reply may come before the data is
40
51
  # initialized.
41
52
  def initialize
42
53
  @socket = UDPSocket.new
54
+ @encrypted = true
43
55
  end
44
56
 
45
57
  # Initializes the UDP socket with data obtained from opcode 2.
46
- # @param endpoint [String] The voice endpoint to connect to.
58
+ # @param ip [String] The IP address to connect to.
47
59
  # @param port [Integer] The port to connect to.
48
60
  # @param ssrc [Integer] The Super Secret Relay Code (SSRC). Discord uses this to identify different voice users
49
61
  # on the same endpoint.
50
- def connect(endpoint, port, ssrc)
51
- @endpoint = endpoint
52
- @endpoint = @endpoint[6..-1] if @endpoint.start_with? 'wss://'
53
- @endpoint = @endpoint.gsub(':80', '') # The endpoint may contain a port, we don't want that
54
- @endpoint = Resolv.getaddress @endpoint
55
-
62
+ def connect(ip, port, ssrc)
63
+ @ip = ip
56
64
  @port = port
57
65
  @ssrc = ssrc
58
66
  end
@@ -63,7 +71,7 @@ module Discordrb::Voice
63
71
  # Wait for a UDP message
64
72
  message = @socket.recv(70)
65
73
  ip = message[4..-3].delete("\0")
66
- port = message[-2..-1].to_i
74
+ port = message[-2..-1].unpack1('n')
67
75
  [ip, port]
68
76
  end
69
77
 
@@ -76,10 +84,15 @@ module Discordrb::Voice
76
84
  # Header of the audio packet
77
85
  header = [0x80, 0x78, sequence, time, @ssrc].pack('CCnNN')
78
86
 
79
- # Encrypt data, if necessary
80
- buf = encrypt_audio(header, buf) if encrypted?
87
+ nonce = generate_nonce(header)
88
+ buf = encrypt_audio(buf, nonce)
81
89
 
82
- send_packet(header + buf)
90
+ data = header + buf
91
+
92
+ # xsalsa20_poly1305 does not require an appended nonce
93
+ data += nonce unless @mode == 'xsalsa20_poly1305'
94
+
95
+ send_packet(data)
83
96
  end
84
97
 
85
98
  # Sends the UDP discovery packet with the internally stored SSRC. Discord will send a reply afterwards which can
@@ -94,22 +107,47 @@ module Discordrb::Voice
94
107
 
95
108
  private
96
109
 
97
- # Encrypts audio data using RbNaCl
98
- # @param header [String] The header of the packet, to be used as the nonce
110
+ # Encrypts audio data using libsodium
99
111
  # @param buf [String] The encoded audio data to be encrypted
112
+ # @param nonce [String] The nonce to be used to encrypt the data
100
113
  # @return [String] the audio data, encrypted
101
- def encrypt_audio(header, buf)
114
+ def encrypt_audio(buf, nonce)
102
115
  raise 'No secret key found, despite encryption being enabled!' unless @secret_key
103
- box = RbNaCl::SecretBox.new(@secret_key)
104
116
 
105
- # The nonce is the header of the voice packet with 12 null bytes appended
106
- nonce = header + ([0] * 12).pack('C*')
117
+ secret_box = Discordrb::Voice::SecretBox.new(@secret_key)
107
118
 
108
- box.encrypt(nonce, buf)
119
+ # Nonces must be 24 bytes in length. We right pad with null bytes for poly1305 and poly1305_lite
120
+ secret_box.box(nonce.ljust(24, "\0"), buf)
109
121
  end
110
122
 
111
123
  def send_packet(packet)
112
- @socket.send(packet, 0, @endpoint, @port)
124
+ @socket.send(packet, 0, @ip, @port)
125
+ end
126
+
127
+ # @param header [String] The header of the packet, to be used as the nonce
128
+ # @return [String]
129
+ # @note
130
+ # The nonce generated depends on the encryption mode.
131
+ # In xsalsa20_poly1305 the nonce is the header plus twelve null bytes for padding.
132
+ # In xsalsa20_poly1305_suffix, the nonce is 24 random bytes
133
+ # In xsalsa20_poly1305_lite, the nonce is an incremental 4 byte int.
134
+ def generate_nonce(header)
135
+ case @mode
136
+ when 'xsalsa20_poly1305'
137
+ header
138
+ when 'xsalsa20_poly1305_suffix'
139
+ Random.urandom(24)
140
+ when 'xsalsa20_poly1305_lite'
141
+ case @lite_nonce
142
+ when nil, 0xff_ff_ff_ff
143
+ @lite_nonce = 0
144
+ else
145
+ @lite_nonce += 1
146
+ end
147
+ [@lite_nonce].pack('N')
148
+ else
149
+ raise "`#{@mode}' is not a supported encryption mode"
150
+ end
113
151
  end
114
152
  end
115
153
 
@@ -117,6 +155,9 @@ module Discordrb::Voice
117
155
  # used to manage general data about the connection, such as sending the speaking packet, which determines the green
118
156
  # circle around users on Discord, and obtaining UDP connection info.
119
157
  class VoiceWS
158
+ # The version of the voice gateway that's supposed to be used.
159
+ VOICE_GATEWAY_VERSION = 4
160
+
120
161
  # @return [VoiceUDP] the UDP voice connection over which the actual audio data is sent.
121
162
  attr_reader :udp
122
163
 
@@ -127,14 +168,14 @@ module Discordrb::Voice
127
168
  # @param session [String] The voice session ID Discord sends over the regular websocket
128
169
  # @param endpoint [String] The endpoint URL to connect to
129
170
  def initialize(channel, bot, token, session, endpoint)
130
- raise 'RbNaCl is unavailable - unable to create voice bot! Please read https://github.com/meew0/discordrb/wiki/Installing-libsodium' unless RBNACL_AVAILABLE
171
+ raise 'libsodium is unavailable - unable to create voice bot! Please read https://github.com/shardlab/discordrb/wiki/Installing-libsodium' unless LIBSODIUM_AVAILABLE
131
172
 
132
173
  @channel = channel
133
174
  @bot = bot
134
175
  @token = token
135
176
  @session = session
136
177
 
137
- @endpoint = endpoint.gsub(':80', '')
178
+ @endpoint = endpoint.split(':').first
138
179
 
139
180
  @udp = VoiceUDP.new
140
181
  end
@@ -181,12 +222,12 @@ module Discordrb::Voice
181
222
 
182
223
  @client.send({
183
224
  op: 3,
184
- d: nil
225
+ d: millis
185
226
  }.to_json)
186
227
  end
187
228
 
188
229
  # Send a speaking packet (op 5). This determines the green circle around the avatar in the voice channel
189
- # @param value [true, false] Whether or not the bot should be speaking
230
+ # @param value [true, false, Integer] Whether or not the bot should be speaking, can also be a bitmask denoting audio type.
190
231
  def send_speaking(value)
191
232
  @bot.debug("Speaking: #{value}")
192
233
  @client.send({
@@ -218,18 +259,23 @@ module Discordrb::Voice
218
259
  # Opcode 2 contains data to initialize the UDP connection
219
260
  @ws_data = packet['d']
220
261
 
221
- @heartbeat_interval = @ws_data['heartbeat_interval']
222
262
  @ssrc = @ws_data['ssrc']
223
263
  @port = @ws_data['port']
224
- @udp_mode = mode
225
264
 
226
- @udp.connect(@endpoint, @port, @ssrc)
265
+ @udp_mode = (ENCRYPTION_MODES & @ws_data['modes']).first
266
+
267
+ @udp.connect(@ws_data['ip'], @port, @ssrc)
227
268
  @udp.send_discovery
228
269
  when 4
229
270
  # Opcode 4 sends the secret key used for encryption
230
271
  @ws_data = packet['d']
272
+
231
273
  @ready = true
232
274
  @udp.secret_key = @ws_data['secret_key'].pack('C*')
275
+ @udp.mode = @ws_data['mode']
276
+ when 8
277
+ # Opcode 8 contains the heartbeat interval.
278
+ @heartbeat_interval = packet['d']['heartbeat_interval']
233
279
  end
234
280
  end
235
281
 
@@ -276,11 +322,6 @@ module Discordrb::Voice
276
322
 
277
323
  private
278
324
 
279
- # @return [String] the mode string that signifies whether encryption should be used or not
280
- def mode
281
- @udp.encrypted? ? ENCRYPTED_MODE : PLAIN_MODE
282
- end
283
-
284
325
  def heartbeat_loop
285
326
  @heartbeat_running = true
286
327
  while @heartbeat_running
@@ -295,7 +336,7 @@ module Discordrb::Voice
295
336
  end
296
337
 
297
338
  def init_ws
298
- host = "wss://#{@endpoint}:443"
339
+ host = "wss://#{@endpoint}:443/?v=#{VOICE_GATEWAY_VERSION}"
299
340
  @bot.debug("Connecting VWS to host: #{host}")
300
341
 
301
342
  # Connect the WS
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discordrb::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