discordrb 3.3.0 → 3.5.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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +152 -0
  3. data/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  4. data/.github/ISSUE_TEMPLATE/feature_request.md +24 -0
  5. data/.github/pull_request_template.md +37 -0
  6. data/.github/workflows/codeql.yml +65 -0
  7. data/.markdownlint.json +4 -0
  8. data/.rubocop.yml +39 -36
  9. data/CHANGELOG.md +874 -552
  10. data/Gemfile +2 -0
  11. data/LICENSE.txt +1 -1
  12. data/README.md +80 -86
  13. data/Rakefile +2 -0
  14. data/bin/console +1 -0
  15. data/discordrb-webhooks.gemspec +9 -6
  16. data/discordrb.gemspec +21 -18
  17. data/lib/discordrb/allowed_mentions.rb +36 -0
  18. data/lib/discordrb/api/application.rb +202 -0
  19. data/lib/discordrb/api/channel.rb +236 -47
  20. data/lib/discordrb/api/interaction.rb +54 -0
  21. data/lib/discordrb/api/invite.rb +5 -5
  22. data/lib/discordrb/api/server.rb +94 -66
  23. data/lib/discordrb/api/user.rb +17 -11
  24. data/lib/discordrb/api/webhook.rb +63 -6
  25. data/lib/discordrb/api.rb +55 -16
  26. data/lib/discordrb/await.rb +0 -1
  27. data/lib/discordrb/bot.rb +480 -93
  28. data/lib/discordrb/cache.rb +31 -24
  29. data/lib/discordrb/colour_rgb.rb +43 -0
  30. data/lib/discordrb/commands/command_bot.rb +35 -12
  31. data/lib/discordrb/commands/container.rb +21 -24
  32. data/lib/discordrb/commands/parser.rb +20 -20
  33. data/lib/discordrb/commands/rate_limiter.rb +4 -3
  34. data/lib/discordrb/container.rb +209 -20
  35. data/lib/discordrb/data/activity.rb +271 -0
  36. data/lib/discordrb/data/application.rb +50 -0
  37. data/lib/discordrb/data/attachment.rb +71 -0
  38. data/lib/discordrb/data/audit_logs.rb +345 -0
  39. data/lib/discordrb/data/channel.rb +993 -0
  40. data/lib/discordrb/data/component.rb +229 -0
  41. data/lib/discordrb/data/embed.rb +251 -0
  42. data/lib/discordrb/data/emoji.rb +82 -0
  43. data/lib/discordrb/data/integration.rb +122 -0
  44. data/lib/discordrb/data/interaction.rb +800 -0
  45. data/lib/discordrb/data/invite.rb +137 -0
  46. data/lib/discordrb/data/member.rb +372 -0
  47. data/lib/discordrb/data/message.rb +414 -0
  48. data/lib/discordrb/data/overwrite.rb +108 -0
  49. data/lib/discordrb/data/profile.rb +91 -0
  50. data/lib/discordrb/data/reaction.rb +33 -0
  51. data/lib/discordrb/data/recipient.rb +34 -0
  52. data/lib/discordrb/data/role.rb +248 -0
  53. data/lib/discordrb/data/server.rb +1004 -0
  54. data/lib/discordrb/data/user.rb +264 -0
  55. data/lib/discordrb/data/voice_region.rb +45 -0
  56. data/lib/discordrb/data/voice_state.rb +41 -0
  57. data/lib/discordrb/data/webhook.rb +238 -0
  58. data/lib/discordrb/data.rb +28 -4180
  59. data/lib/discordrb/errors.rb +46 -4
  60. data/lib/discordrb/events/bans.rb +7 -5
  61. data/lib/discordrb/events/channels.rb +3 -1
  62. data/lib/discordrb/events/guilds.rb +16 -9
  63. data/lib/discordrb/events/interactions.rb +482 -0
  64. data/lib/discordrb/events/invites.rb +125 -0
  65. data/lib/discordrb/events/members.rb +6 -2
  66. data/lib/discordrb/events/message.rb +72 -27
  67. data/lib/discordrb/events/presence.rb +35 -18
  68. data/lib/discordrb/events/raw.rb +1 -3
  69. data/lib/discordrb/events/reactions.rb +49 -4
  70. data/lib/discordrb/events/threads.rb +96 -0
  71. data/lib/discordrb/events/typing.rb +6 -4
  72. data/lib/discordrb/events/voice_server_update.rb +47 -0
  73. data/lib/discordrb/events/voice_state_update.rb +15 -10
  74. data/lib/discordrb/events/webhooks.rb +9 -6
  75. data/lib/discordrb/gateway.rb +99 -71
  76. data/lib/discordrb/id_object.rb +39 -0
  77. data/lib/discordrb/light/integrations.rb +1 -1
  78. data/lib/discordrb/light/light_bot.rb +1 -1
  79. data/lib/discordrb/logger.rb +4 -4
  80. data/lib/discordrb/paginator.rb +57 -0
  81. data/lib/discordrb/permissions.rb +159 -39
  82. data/lib/discordrb/version.rb +1 -1
  83. data/lib/discordrb/voice/encoder.rb +16 -7
  84. data/lib/discordrb/voice/network.rb +99 -47
  85. data/lib/discordrb/voice/sodium.rb +98 -0
  86. data/lib/discordrb/voice/voice_bot.rb +33 -25
  87. data/lib/discordrb/webhooks.rb +2 -0
  88. data/lib/discordrb.rb +107 -1
  89. metadata +126 -54
  90. data/.codeclimate.yml +0 -16
  91. data/.travis.yml +0 -33
  92. data/bin/travis_build_docs.sh +0 -17
  93. /data/{CONTRIBUTING.md → .github/CONTRIBUTING.md} +0 -0
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discordrb
4
+ # Utility class for wrapping paginated endpoints. It is [Enumerable](https://ruby-doc.org/core-2.5.1/Enumerable.html),
5
+ # similar to an `Array`, so most of the same methods can be used to filter the results of the request
6
+ # that it wraps. If you simply want an array of all of the results, `#to_a` can be called.
7
+ class Paginator
8
+ include Enumerable
9
+
10
+ # Creates a new {Paginator}
11
+ # @param limit [Integer] the maximum number of items to request before stopping
12
+ # @param direction [:up, :down] the order in which results are returned in
13
+ # @yield [Array, nil] the last page of results, or nil if this is the first iteration.
14
+ # This should be used to request the next page of results.
15
+ # @yieldreturn [Array] the next page of results
16
+ def initialize(limit, direction, &block)
17
+ @count = 0
18
+ @limit = limit
19
+ @direction = direction
20
+ @block = block
21
+ end
22
+
23
+ # Yields every item produced by the wrapped request, until it returns
24
+ # no more results or the configured `limit` is reached.
25
+ def each
26
+ last_page = nil
27
+ until limit_check
28
+ page = @block.call(last_page)
29
+ return if page.empty?
30
+
31
+ enumerator = case @direction
32
+ when :down
33
+ page.each
34
+ when :up
35
+ page.reverse_each
36
+ end
37
+
38
+ enumerator.each do |item|
39
+ yield item
40
+ @count += 1
41
+ break if limit_check
42
+ end
43
+
44
+ last_page = page
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # Whether the paginator limit has been exceeded
51
+ def limit_check
52
+ return false if @limit.nil?
53
+
54
+ @count >= @limit
55
+ end
56
+ end
57
+ end
@@ -4,44 +4,54 @@ 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
- 0 => :create_instant_invite, # 1
11
- 1 => :kick_members, # 2
12
- 2 => :ban_members, # 4
13
- 3 => :administrator, # 8
14
- 4 => :manage_channels, # 16
15
- 5 => :manage_server, # 32
16
- 6 => :add_reactions, # 64
17
- 7 => :view_audit_log, # 128
18
- 8 => :priority_speaker, # 256
19
- # 9 # 512
20
- 10 => :read_messages, # 1024
21
- 11 => :send_messages, # 2048
22
- 12 => :send_tts_messages, # 4096
23
- 13 => :manage_messages, # 8192
24
- 14 => :embed_links, # 16384
25
- 15 => :attach_files, # 32768
26
- 16 => :read_message_history, # 65536
27
- 17 => :mention_everyone, # 131072
28
- 18 => :use_external_emoji, # 262144
29
- # 19 # 524288
30
- 20 => :connect, # 1048576
31
- 21 => :speak, # 2097152
32
- 22 => :mute_members, # 4194304
33
- 23 => :deafen_members, # 8388608
34
- 24 => :move_members, # 16777216
35
- 25 => :use_voice_activity, # 33554432
36
- 26 => :change_nickname, # 67108864
37
- 27 => :manage_nicknames, # 134217728
38
- 28 => :manage_roles, # 268435456, also Manage Permissions
39
- 29 => :manage_webhooks, # 536870912
40
- 30 => :manage_emojis # 1073741824
9
+ 0 => :create_instant_invite, # 1
10
+ 1 => :kick_members, # 2
11
+ 2 => :ban_members, # 4
12
+ 3 => :administrator, # 8
13
+ 4 => :manage_channels, # 16
14
+ 5 => :manage_server, # 32
15
+ 6 => :add_reactions, # 64
16
+ 7 => :view_audit_log, # 128
17
+ 8 => :priority_speaker, # 256
18
+ 9 => :stream, # 512
19
+ 10 => :read_messages, # 1024
20
+ 11 => :send_messages, # 2048
21
+ 12 => :send_tts_messages, # 4096
22
+ 13 => :manage_messages, # 8192
23
+ 14 => :embed_links, # 16384
24
+ 15 => :attach_files, # 32768
25
+ 16 => :read_message_history, # 65536
26
+ 17 => :mention_everyone, # 131072
27
+ 18 => :use_external_emoji, # 262144
28
+ 19 => :view_server_insights, # 524288
29
+ 20 => :connect, # 1048576
30
+ 21 => :speak, # 2097152
31
+ 22 => :mute_members, # 4194304
32
+ 23 => :deafen_members, # 8388608
33
+ 24 => :move_members, # 16777216
34
+ 25 => :use_voice_activity, # 33554432
35
+ 26 => :change_nickname, # 67108864
36
+ 27 => :manage_nicknames, # 134217728
37
+ 28 => :manage_roles, # 268435456, also Manage Permissions
38
+ 29 => :manage_webhooks, # 536870912
39
+ 30 => :manage_emojis, # 1073741824, also Manage Stickers
40
+ 31 => :use_slash_commands, # 2147483648
41
+ 32 => :request_to_speak, # 4294967296
42
+ 33 => :manage_events, # 8589934592
43
+ 34 => :manage_threads, # 17179869184
44
+ 35 => :use_public_threads, # 34359738368
45
+ 36 => :use_private_threads, # 68719476736
46
+ 37 => :use_external_stickers, # 137438953472
47
+ 38 => :send_messages_in_threads, # 274877906944
48
+ 39 => :use_embedded_activities, # 549755813888
49
+ 40 => :moderate_members # 1099511627776
41
50
  }.freeze
42
51
 
43
- Flags.each do |position, flag|
52
+ FLAGS.each do |position, flag|
44
53
  attr_reader flag
54
+
45
55
  define_method "can_#{flag}=" do |value|
46
56
  new_bits = @bits
47
57
  if value
@@ -49,7 +59,7 @@ module Discordrb
49
59
  else
50
60
  new_bits &= ~(1 << position)
51
61
  end
52
- @writer.write(new_bits) if @writer
62
+ @writer&.write(new_bits)
53
63
  @bits = new_bits
54
64
  init_vars
55
65
  end
@@ -69,7 +79,7 @@ module Discordrb
69
79
 
70
80
  # Initialize the instance variables based on the bitset.
71
81
  def init_vars
72
- Flags.each do |position, flag|
82
+ FLAGS.each do |position, flag|
73
83
  flag_set = ((@bits >> position) & 0x1) == 1
74
84
  instance_variable_set "@#{flag}", flag_set
75
85
  end
@@ -85,7 +95,7 @@ module Discordrb
85
95
  def self.bits(list)
86
96
  value = 0
87
97
 
88
- Flags.each do |position, flag|
98
+ FLAGS.each do |position, flag|
89
99
  value += 2**position if list.include? flag
90
100
  end
91
101
 
@@ -101,7 +111,7 @@ module Discordrb
101
111
  # permission.can_speak = true
102
112
  # @example Create a permissions object that could allow/deny read messages, connect, and speak by an array of symbols
103
113
  # Permissions.new [:read_messages, :connect, :speak]
104
- # @param bits [Integer, Array<Symbol>] The permission bits that should be set from the beginning, or an array of permission flag symbols
114
+ # @param bits [String, Integer, Array<Symbol>] The permission bits that should be set from the beginning, or an array of permission flag symbols
105
115
  # @param writer [RoleWriter] The writer that should be used to update data when a permission is set.
106
116
  def initialize(bits = 0, writer = nil)
107
117
  @writer = writer
@@ -109,16 +119,126 @@ module Discordrb
109
119
  @bits = if bits.is_a? Array
110
120
  self.class.bits(bits)
111
121
  else
112
- bits
122
+ bits.to_i
113
123
  end
114
124
 
115
125
  init_vars
116
126
  end
117
127
 
128
+ # Return an array of permission flag symbols for this class's permissions
129
+ # @example Get the permissions for the bits "9"
130
+ # permissions = Permissions.new(9)
131
+ # permissions.defined_permissions #=> [:create_instant_invite, :administrator]
132
+ # @return [Array<Symbol>] the permissions
133
+ def defined_permissions
134
+ FLAGS.filter_map { |value, name| (@bits & (1 << value)).positive? ? name : nil }
135
+ end
136
+
118
137
  # Comparison based on permission bits
119
138
  def ==(other)
120
139
  false unless other.is_a? Discordrb::Permissions
121
140
  bits == other.bits
122
141
  end
123
142
  end
143
+
144
+ # Mixin to calculate resulting permissions from overrides etc.
145
+ module PermissionCalculator
146
+ # Checks whether this user can do the particular action, regardless of whether it has the permission defined,
147
+ # through for example being the server owner or having the Manage Roles permission
148
+ # @param action [Symbol] The permission that should be checked. See also {Permissions::FLAGS} for a list.
149
+ # @param channel [Channel, nil] If channel overrides should be checked too, this channel specifies where the overrides should be checked.
150
+ # @example Check if the bot can send messages to a specific channel in a server.
151
+ # bot_profile = bot.profile.on(event.server)
152
+ # can_send_messages = bot_profile.permission?(:send_messages, channel)
153
+ # @return [true, false] whether or not this user has the permission.
154
+ def permission?(action, channel = nil)
155
+ # If the member is the server owner, it irrevocably has all permissions.
156
+ return true if owner?
157
+
158
+ # First, check whether the user has Manage Roles defined.
159
+ # (Coincidentally, Manage Permissions is the same permission as Manage Roles, and a
160
+ # Manage Permissions deny overwrite will override Manage Roles, so we can just check for
161
+ # Manage Roles once and call it a day.)
162
+ return true if defined_permission?(:administrator, channel)
163
+
164
+ # Otherwise, defer to defined_permission
165
+ defined_permission?(action, channel)
166
+ end
167
+
168
+ # Checks whether this user has a particular permission defined (i.e. not implicit, through for example
169
+ # Manage Roles)
170
+ # @param action [Symbol] The permission that should be checked. See also {Permissions::FLAGS} for a list.
171
+ # @param channel [Channel, nil] If channel overrides should be checked too, this channel specifies where the overrides should be checked.
172
+ # @example Check if a member has the Manage Channels permission defined in the server.
173
+ # has_manage_channels = member.defined_permission?(:manage_channels)
174
+ # @return [true, false] whether or not this user has the permission defined.
175
+ def defined_permission?(action, channel = nil)
176
+ # For slash commands we may not have access to the server or role
177
+ # permissions. In this case we use the permissions given to us by the
178
+ # interaction. If attempting to check against a specific channel the check
179
+ # is skipped.
180
+ return @permissions.__send__(action) if @permissions && channel.nil?
181
+
182
+ # Get the permission the user's roles have
183
+ role_permission = defined_role_permission?(action, channel)
184
+
185
+ # Once we have checked the role permission, we have to check the channel overrides for the
186
+ # specific user
187
+ user_specific_override = permission_overwrite(action, channel, id) # Use the ID reader as members have no ID instance variable
188
+
189
+ # Merge the two permissions - if an override is defined, it has to be allow, otherwise we only care about the role
190
+ return role_permission unless user_specific_override
191
+
192
+ user_specific_override == :allow
193
+ end
194
+
195
+ # Define methods for querying permissions
196
+ Discordrb::Permissions::FLAGS.each_value do |flag|
197
+ define_method "can_#{flag}?" do |channel = nil|
198
+ permission? flag, channel
199
+ end
200
+ end
201
+
202
+ alias_method :can_administrate?, :can_administrator?
203
+
204
+ private
205
+
206
+ def defined_role_permission?(action, channel)
207
+ roles_to_check = [@server.everyone_role] + roles
208
+
209
+ # For each role, check if
210
+ # (1) the channel explicitly allows or permits an action for the role and
211
+ # (2) if the user is allowed to do the action if the channel doesn't specify
212
+ roles_to_check.sort_by(&:position).reduce(false) do |can_act, role|
213
+ # Get the override defined for the role on the channel
214
+ channel_allow = permission_overwrite(action, channel, role.id)
215
+ if channel_allow
216
+ # If the channel has an override, check whether it is an allow - if yes,
217
+ # the user can act, if not, it can't
218
+ break true if channel_allow == :allow
219
+
220
+ false
221
+ else
222
+ # Otherwise defer to the role
223
+ role.permissions.instance_variable_get("@#{action}") || can_act
224
+ end
225
+ end
226
+ end
227
+
228
+ def permission_overwrite(action, channel, id)
229
+ # If no overwrites are defined, or no channel is set, no overwrite will be present
230
+ return nil unless channel && channel.permission_overwrites[id]
231
+
232
+ # Otherwise, check the allow and deny objects
233
+ allow = channel.permission_overwrites[id].allow
234
+ deny = channel.permission_overwrites[id].deny
235
+ if allow.instance_variable_get("@#{action}")
236
+ :allow
237
+ elsif deny.instance_variable_get("@#{action}")
238
+ :deny
239
+ end
240
+
241
+ # If there's no variable defined, nil will implicitly be returned
242
+ end
243
+ end
124
244
  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.5.0'
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
46
+
47
+ # @!visibility private
48
+ attr_writer :mode
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
@@ -61,9 +69,9 @@ module Discordrb::Voice
61
69
  # @return [Array(String, Integer)] the IP and port received from the discovery reply.
62
70
  def receive_discovery_reply
63
71
  # Wait for a UDP message
64
- message = @socket.recv(70)
65
- ip = message[4..-3].delete("\0")
66
- port = message[-2..-1].to_i
72
+ message = @socket.recv(74)
73
+ ip = message[8..-3].delete("\0")
74
+ port = message[-2..].unpack1('n')
67
75
  [ip, port]
68
76
  end
69
77
 
@@ -76,40 +84,81 @@ 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)
89
+
90
+ data = header + buf
81
91
 
82
- send_packet(header + buf)
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
86
99
  # be received using {#receive_discovery_reply}
87
100
  def send_discovery
88
- discovery_packet = [@ssrc].pack('N')
101
+ # Create empty packet
102
+ discovery_packet = ''
103
+
104
+ # Add Type request (0x1 = request, 0x2 = response)
105
+ discovery_packet += [0x1].pack('n')
106
+
107
+ # Add Length (excluding Type and itself = 70)
108
+ discovery_packet += [70].pack('n')
89
109
 
90
- # Add 66 zeroes so the packet is 70 bytes long
110
+ # Add SSRC
111
+ discovery_packet += [@ssrc].pack('N')
112
+
113
+ # Add 66 zeroes so the packet is 74 bytes long
91
114
  discovery_packet += "\0" * 66
115
+
92
116
  send_packet(discovery_packet)
93
117
  end
94
118
 
95
119
  private
96
120
 
97
- # Encrypts audio data using RbNaCl
98
- # @param header [String] The header of the packet, to be used as the nonce
121
+ # Encrypts audio data using libsodium
99
122
  # @param buf [String] The encoded audio data to be encrypted
123
+ # @param nonce [String] The nonce to be used to encrypt the data
100
124
  # @return [String] the audio data, encrypted
101
- def encrypt_audio(header, buf)
125
+ def encrypt_audio(buf, nonce)
102
126
  raise 'No secret key found, despite encryption being enabled!' unless @secret_key
103
- box = RbNaCl::SecretBox.new(@secret_key)
104
127
 
105
- # The nonce is the header of the voice packet with 12 null bytes appended
106
- nonce = header + ([0] * 12).pack('C*')
128
+ secret_box = Discordrb::Voice::SecretBox.new(@secret_key)
107
129
 
108
- box.encrypt(nonce, buf)
130
+ # Nonces must be 24 bytes in length. We right pad with null bytes for poly1305 and poly1305_lite
131
+ secret_box.box(nonce.ljust(24, "\0"), buf)
109
132
  end
110
133
 
111
134
  def send_packet(packet)
112
- @socket.send(packet, 0, @endpoint, @port)
135
+ @socket.send(packet, 0, @ip, @port)
136
+ end
137
+
138
+ # @param header [String] The header of the packet, to be used as the nonce
139
+ # @return [String]
140
+ # @note
141
+ # The nonce generated depends on the encryption mode.
142
+ # In xsalsa20_poly1305 the nonce is the header plus twelve null bytes for padding.
143
+ # In xsalsa20_poly1305_suffix, the nonce is 24 random bytes
144
+ # In xsalsa20_poly1305_lite, the nonce is an incremental 4 byte int.
145
+ def generate_nonce(header)
146
+ case @mode
147
+ when 'xsalsa20_poly1305'
148
+ header
149
+ when 'xsalsa20_poly1305_suffix'
150
+ Random.urandom(24)
151
+ when 'xsalsa20_poly1305_lite'
152
+ case @lite_nonce
153
+ when nil, 0xff_ff_ff_ff
154
+ @lite_nonce = 0
155
+ else
156
+ @lite_nonce += 1
157
+ end
158
+ [@lite_nonce].pack('N')
159
+ else
160
+ raise "`#{@mode}' is not a supported encryption mode"
161
+ end
113
162
  end
114
163
  end
115
164
 
@@ -117,6 +166,9 @@ module Discordrb::Voice
117
166
  # used to manage general data about the connection, such as sending the speaking packet, which determines the green
118
167
  # circle around users on Discord, and obtaining UDP connection info.
119
168
  class VoiceWS
169
+ # The version of the voice gateway that's supposed to be used.
170
+ VOICE_GATEWAY_VERSION = 4
171
+
120
172
  # @return [VoiceUDP] the UDP voice connection over which the actual audio data is sent.
121
173
  attr_reader :udp
122
174
 
@@ -127,14 +179,14 @@ module Discordrb::Voice
127
179
  # @param session [String] The voice session ID Discord sends over the regular websocket
128
180
  # @param endpoint [String] The endpoint URL to connect to
129
181
  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
182
+ raise 'libsodium is unavailable - unable to create voice bot! Please read https://github.com/shardlab/discordrb/wiki/Installing-libsodium' unless LIBSODIUM_AVAILABLE
131
183
 
132
184
  @channel = channel
133
185
  @bot = bot
134
186
  @token = token
135
187
  @session = session
136
188
 
137
- @endpoint = endpoint.gsub(':80', '')
189
+ @endpoint = endpoint.split(':').first
138
190
 
139
191
  @udp = VoiceUDP.new
140
192
  end
@@ -181,12 +233,12 @@ module Discordrb::Voice
181
233
 
182
234
  @client.send({
183
235
  op: 3,
184
- d: nil
236
+ d: millis
185
237
  }.to_json)
186
238
  end
187
239
 
188
240
  # 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
241
+ # @param value [true, false, Integer] Whether or not the bot should be speaking, can also be a bitmask denoting audio type.
190
242
  def send_speaking(value)
191
243
  @bot.debug("Speaking: #{value}")
192
244
  @client.send({
@@ -218,18 +270,23 @@ module Discordrb::Voice
218
270
  # Opcode 2 contains data to initialize the UDP connection
219
271
  @ws_data = packet['d']
220
272
 
221
- @heartbeat_interval = @ws_data['heartbeat_interval']
222
273
  @ssrc = @ws_data['ssrc']
223
274
  @port = @ws_data['port']
224
- @udp_mode = mode
225
275
 
226
- @udp.connect(@endpoint, @port, @ssrc)
276
+ @udp_mode = (ENCRYPTION_MODES & @ws_data['modes']).first
277
+
278
+ @udp.connect(@ws_data['ip'], @port, @ssrc)
227
279
  @udp.send_discovery
228
280
  when 4
229
281
  # Opcode 4 sends the secret key used for encryption
230
282
  @ws_data = packet['d']
283
+
231
284
  @ready = true
232
285
  @udp.secret_key = @ws_data['secret_key'].pack('C*')
286
+ @udp.mode = @ws_data['mode']
287
+ when 8
288
+ # Opcode 8 contains the heartbeat interval.
289
+ @heartbeat_interval = packet['d']['heartbeat_interval']
233
290
  end
234
291
  end
235
292
 
@@ -276,11 +333,6 @@ module Discordrb::Voice
276
333
 
277
334
  private
278
335
 
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
336
  def heartbeat_loop
285
337
  @heartbeat_running = true
286
338
  while @heartbeat_running
@@ -295,7 +347,7 @@ module Discordrb::Voice
295
347
  end
296
348
 
297
349
  def init_ws
298
- host = "wss://#{@endpoint}:443"
350
+ host = "wss://#{@endpoint}:443/?v=#{VOICE_GATEWAY_VERSION}"
299
351
  @bot.debug("Connecting VWS to host: #{host}")
300
352
 
301
353
  # Connect the WS