rubycord 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/lib/rubycord/allowed_mentions.rb +34 -0
  3. data/lib/rubycord/api/application.rb +200 -0
  4. data/lib/rubycord/api/channel.rb +597 -0
  5. data/lib/rubycord/api/interaction.rb +52 -0
  6. data/lib/rubycord/api/invite.rb +42 -0
  7. data/lib/rubycord/api/server.rb +557 -0
  8. data/lib/rubycord/api/user.rb +153 -0
  9. data/lib/rubycord/api/webhook.rb +138 -0
  10. data/lib/rubycord/api.rb +356 -0
  11. data/lib/rubycord/await.rb +49 -0
  12. data/lib/rubycord/bot.rb +1757 -0
  13. data/lib/rubycord/cache.rb +259 -0
  14. data/lib/rubycord/colour_rgb.rb +41 -0
  15. data/lib/rubycord/commands/command_bot.rb +519 -0
  16. data/lib/rubycord/commands/container.rb +110 -0
  17. data/lib/rubycord/commands/events.rb +9 -0
  18. data/lib/rubycord/commands/parser.rb +325 -0
  19. data/lib/rubycord/commands/rate_limiter.rb +142 -0
  20. data/lib/rubycord/container.rb +753 -0
  21. data/lib/rubycord/data/activity.rb +269 -0
  22. data/lib/rubycord/data/application.rb +48 -0
  23. data/lib/rubycord/data/attachment.rb +109 -0
  24. data/lib/rubycord/data/audit_logs.rb +343 -0
  25. data/lib/rubycord/data/channel.rb +996 -0
  26. data/lib/rubycord/data/component.rb +227 -0
  27. data/lib/rubycord/data/embed.rb +249 -0
  28. data/lib/rubycord/data/emoji.rb +80 -0
  29. data/lib/rubycord/data/integration.rb +120 -0
  30. data/lib/rubycord/data/interaction.rb +798 -0
  31. data/lib/rubycord/data/invite.rb +135 -0
  32. data/lib/rubycord/data/member.rb +370 -0
  33. data/lib/rubycord/data/message.rb +412 -0
  34. data/lib/rubycord/data/overwrite.rb +106 -0
  35. data/lib/rubycord/data/profile.rb +89 -0
  36. data/lib/rubycord/data/reaction.rb +31 -0
  37. data/lib/rubycord/data/recipient.rb +32 -0
  38. data/lib/rubycord/data/role.rb +246 -0
  39. data/lib/rubycord/data/server.rb +1002 -0
  40. data/lib/rubycord/data/user.rb +261 -0
  41. data/lib/rubycord/data/voice_region.rb +43 -0
  42. data/lib/rubycord/data/voice_state.rb +39 -0
  43. data/lib/rubycord/data/webhook.rb +232 -0
  44. data/lib/rubycord/data.rb +40 -0
  45. data/lib/rubycord/errors.rb +737 -0
  46. data/lib/rubycord/events/await.rb +46 -0
  47. data/lib/rubycord/events/bans.rb +58 -0
  48. data/lib/rubycord/events/channels.rb +186 -0
  49. data/lib/rubycord/events/generic.rb +126 -0
  50. data/lib/rubycord/events/guilds.rb +191 -0
  51. data/lib/rubycord/events/interactions.rb +480 -0
  52. data/lib/rubycord/events/invites.rb +123 -0
  53. data/lib/rubycord/events/lifetime.rb +29 -0
  54. data/lib/rubycord/events/members.rb +91 -0
  55. data/lib/rubycord/events/message.rb +337 -0
  56. data/lib/rubycord/events/presence.rb +127 -0
  57. data/lib/rubycord/events/raw.rb +45 -0
  58. data/lib/rubycord/events/reactions.rb +156 -0
  59. data/lib/rubycord/events/roles.rb +86 -0
  60. data/lib/rubycord/events/threads.rb +94 -0
  61. data/lib/rubycord/events/typing.rb +70 -0
  62. data/lib/rubycord/events/voice_server_update.rb +45 -0
  63. data/lib/rubycord/events/voice_state_update.rb +103 -0
  64. data/lib/rubycord/events/webhooks.rb +62 -0
  65. data/lib/rubycord/gateway.rb +867 -0
  66. data/lib/rubycord/id_object.rb +37 -0
  67. data/lib/rubycord/light/data.rb +60 -0
  68. data/lib/rubycord/light/integrations.rb +71 -0
  69. data/lib/rubycord/light/light_bot.rb +56 -0
  70. data/lib/rubycord/light.rb +6 -0
  71. data/lib/rubycord/logger.rb +118 -0
  72. data/lib/rubycord/paginator.rb +55 -0
  73. data/lib/rubycord/permissions.rb +251 -0
  74. data/lib/rubycord/version.rb +5 -0
  75. data/lib/rubycord/voice/encoder.rb +113 -0
  76. data/lib/rubycord/voice/network.rb +366 -0
  77. data/lib/rubycord/voice/sodium.rb +96 -0
  78. data/lib/rubycord/voice/voice_bot.rb +408 -0
  79. data/lib/rubycord/webhooks/builder.rb +100 -0
  80. data/lib/rubycord/webhooks/client.rb +132 -0
  81. data/lib/rubycord/webhooks/embeds.rb +248 -0
  82. data/lib/rubycord/webhooks/modal.rb +78 -0
  83. data/lib/rubycord/webhooks/version.rb +7 -0
  84. data/lib/rubycord/webhooks/view.rb +192 -0
  85. data/lib/rubycord/webhooks.rb +12 -0
  86. data/lib/rubycord/websocket.rb +70 -0
  87. data/lib/rubycord.rb +140 -0
  88. 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