discordrb 1.4.8 → 1.5.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.

checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: ca25846076f05d2e3363e4f6efb7126d50659d17
4
- data.tar.gz: c3f210ead11cf9fe48a10de9b9cd719171bdd467
3
+ metadata.gz: bc7dfd36f58df8feea4180b9f2ee01246094ee67
4
+ data.tar.gz: e3abfaca8e1216f1ad67f0abfc93d5631369bb9f
5
5
  SHA512:
6
- metadata.gz: 156b8249969001de05be16fa0602cef916ae57e4d6ad976d515a7cb7d831d09e73518b86ee894d7d1f4d300cf79d59fbe42e2a4a9bfc86c1f67c4455e5a0eb03
7
- data.tar.gz: 8c6cb05c57bb799716818d099f6288b31381accb85cfbfd2899bf1179f59ae88f30fb61d7101cd19fe2d67ca9293278acf1c27668354ef6a091c4ccf83cd63f7
6
+ metadata.gz: 7c7226d0b7f7c7bf675942661d39a499d3161e97cbc872ab9931bc59a2881d0115217fe97db7fa4709cb19cf97b4172ac16ce81070ae4b8b26bae9142330e7f9
7
+ data.tar.gz: 242b86beeee1d91d9bb5de6f72d239ebb98e73911c1287d825f3668f93eae38ab1e3ec8f075f2b0cd23d28827d49fa21cdfb995248359e36544e278a2cbd6554
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ /.idea/
data/CHANGELOG.md CHANGED
@@ -1,4 +1,17 @@
1
1
  # Changelog
2
+
3
+ ## 1.5.0
4
+ * Voice support: discordrb can now connect to voice using `bot.voice_connect` and do the following things:
5
+ * Play files and URLs using `VoiceBot.play_file`
6
+ * Play arbitrary streams using `VoiceBot.play_io`
7
+ * Set the volume of future playbacks using `VoiceBot.volume=`
8
+ * Pause and resume playback (`VoiceBot.pause` and `VoiceBot.continue`)
9
+ * Authentication tokens are now cached and no login request will be made if a cached token is found. This is mostly to reduce strain on Discord's servers.
10
+
11
+ ### Bugfixes
12
+ * Some latent ID casting errors were fixed - those would probably never have been noticed anyway, but they're fixed now.
13
+ * `Bot.parse_mention` now works, it didn't work at all previously
14
+
2
15
  ## 1.4.8
3
16
  * The `User` class now has the methods `add_role` and `remove_role` which add a role to a user and remove it, respectively.
4
17
  * All data classes now have a useful `==` implementation.
@@ -13,7 +26,7 @@
13
26
  * `Message` now has a useful `to_s` method that just returns the content.
14
27
 
15
28
  ### Bugfixes
16
- * The `TypingEvent` `user` property is now initialized correctly.
29
+ * The `TypingEvent` `user` property is now initialized correctly (#29, thanks @purintal)
17
30
 
18
31
  ## 1.4.6
19
32
  *Bugfix-only release.*
@@ -24,3 +37,99 @@
24
37
  ## 1.4.5
25
38
  * The `Bot.game` property can now be set to an arbitrary string.
26
39
  * Discord mentions are handled in the old way again, after Discord reverted an API change.
40
+
41
+ ## 1.4.4
42
+ * Add `Server.leave_server` as an alias for `delete_server`
43
+ * Use the new Discord mention format (mentions array). **Reverted in 1.4.5**
44
+ * Discord rate limited API calls are now handled correctly - discordrb will try again after the specified time.
45
+ * Debug logging is now handled by a separate `Logger` class
46
+
47
+ ### Bugfixes
48
+ * Message timestamps are now parsed correctly.
49
+ * The quickadders for awaits (`User.await`, `Channel.await` etc.) now add the correct awaits.
50
+
51
+ ## 1.4.3
52
+ * Added a method `Bot.find_user` analogous to `Bot.find`.
53
+
54
+ ### Bugfixes
55
+ * Remove a leftover debug line (#23, thanks @VxJasonxV)
56
+
57
+ ## 1.4.2
58
+ * discordrb will now send a user agent in the format requested by the Discord devs.
59
+
60
+ ## 1.4.1
61
+ *Bugfix-only release.*
62
+
63
+ ### Bugfixes
64
+ * Empty messages will now never be sent
65
+ * The command-not-found message in `CommandBot` can now be disabled properly
66
+
67
+ ## 1.4.0
68
+ * All methods and classes where the words "colour" or "color" are used now have had aliases added with the respective other spelling. (Internally, everything uses "colour" now).
69
+ * discordrb now supports everything on the Discord API comparison, except for voice (see also #22)
70
+ * Roles can now be created, edited and deleted and their permissions modified.
71
+ * There is now a method to get a channel's message history.
72
+ * The bot's user profile can now be edited.
73
+ * Servers can now be created, edited and deleted.
74
+ * The user can now display a "typing" message in a channel.
75
+ * Invites can now be created and deleted, and an `Invite` class was made to represent them.
76
+ * Command and event handling is now threaded, with each command/event handler execution in a separate thread.
77
+ * Debug messages now specify the current thread's name.
78
+ * discordrb now handles created/updated/deleted servers properly with events added to handle them.
79
+ * The list of games handled by Discord will now be updated automatically.
80
+
81
+ ### Bugfixes
82
+ * Fixed a bug where command handling would crash if the command didn't exist.
83
+
84
+ ## 1.3.12
85
+ * Add an attribute `Bot.should_parse_self` (false by default) that prevents the bot from raising an event if it receives a message from itself.
86
+ * `User.bot?` and `Message.from_bot?` were implemented to check whether the user is the bot or the message was sent by it.
87
+ * Add an event for private messages specifically (`Bot.pm` and `PrivateMessageEvent`)
88
+
89
+ ### Bugfixes
90
+ * Fix the `MessageEvent` attribute that checks whether the message is from the bot not working at all.
91
+
92
+ ## 1.3.11
93
+ * Add a user selector (`:bot`) that is usable in the `from:` `MessageEvent` attribute to check whether the message was sent by a bot.
94
+
95
+ ### Bugfixes
96
+ * `Channel.private?` now checks for the server being nil instead of the `is_private` attribute provided by Discord as the latter is unreliable. (wtf)
97
+
98
+ ## 1.3.10
99
+ * Add a method `Channel.private?` to check for a PM channel
100
+ * Add a `MessageEvent` attribute (`:private`) to check whether a message was sent in a PM channel
101
+ * Add various aliases to `MessageEvent` attributes
102
+ * Allow regexes to check for strings in `MessageEvent` attributes
103
+
104
+ ### Bugfixes
105
+ * The `matches_all` method would break in certain edge cases. This didn't really affect discordrb and I don't think anyone else uses that method (it's pretty useless otherwise). This has been fixed
106
+
107
+ ## 1.3.9
108
+ * Add awaits, a powerful way to add temporary event handlers.
109
+ * Add a `Bot.find` method to fuzzy-search for channels.
110
+ * Add methods to kick, ban and unban users.
111
+
112
+ ### Bugfixes
113
+ * Permission overrides now work correctly for private channels (i. e. they don't exist at all)
114
+ * Users joining and leaving servers are now handled correctly.
115
+
116
+ ## 1.3.8
117
+ * Added `Bot.users` and `Bot.servers` readers to get the list of users and servers.
118
+
119
+ ### Bugfixes
120
+ * POST requests to API calls that don't need a payload will now send a `nil` payload instead. This fixes the bot being unable to join any servers and various other latent problems. (#21, thanks @davidkus)
121
+
122
+ ## 1.3.7
123
+ *Bugfix-only release.*
124
+
125
+ ### Bugfixes
126
+ * Fix the command bot being included wrong, which caused crashes upon startup.
127
+
128
+ ## 1.3.6
129
+ * The bot can now be stopped from the script using the new method `Bot.stop`.
130
+
131
+ ### Bugfixes
132
+ * Fix some wrong file requires which caused crashes sometimes.
133
+
134
+ ## 1.3.5
135
+ * The bot can now be run asynchronously using `Bot.run(:async)` to do further initialization after the bot was started.
data/README.md CHANGED
@@ -67,6 +67,10 @@ bot.run
67
67
 
68
68
  This bot responds to every "Ping!" with a "Pong!".
69
69
 
70
+ ## Support
71
+
72
+ You can find me (@meew0, ID 66237334693085184) on the unofficial Discord API server - if you have a question, just ask there, I or somebody else will probably answer you: https://discord.gg/0SBTUU1wZTWfFQL2
73
+
70
74
  ## Development
71
75
 
72
76
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake false` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/discordrb.gemspec CHANGED
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
22
22
  spec.add_dependency 'faye-websocket'
23
23
  spec.add_dependency 'rest-client'
24
24
  spec.add_dependency 'activesupport'
25
+ spec.add_dependency 'opus-ruby'
25
26
 
26
27
  spec.required_ruby_version = '>= 2.1.0'
27
28
 
data/lib/discordrb/bot.rb CHANGED
@@ -21,6 +21,9 @@ require 'discordrb/api'
21
21
  require 'discordrb/exceptions'
22
22
  require 'discordrb/data'
23
23
  require 'discordrb/await'
24
+ require 'discordrb/token_cache'
25
+
26
+ require 'discordrb/voice/voice_bot'
24
27
 
25
28
  module Discordrb
26
29
  # Represents a Discord bot, including servers, users, etc.
@@ -77,6 +80,9 @@ module Discordrb
77
80
  @email = email
78
81
  @password = password
79
82
 
83
+ debug('Creating token cache')
84
+ @token_cache = Discordrb::TokenCache.new
85
+ debug('Token cache created successfully')
80
86
  @token = login
81
87
 
82
88
  @event_handlers = {}
@@ -192,6 +198,38 @@ module Discordrb
192
198
  API.join_server(@token, resolved)
193
199
  end
194
200
 
201
+ attr_reader :voice
202
+
203
+ def voice_connect(channel)
204
+ if @voice
205
+ debug('Voice bot exists already! Destroying it')
206
+ @voice.destroy
207
+ @voice = nil
208
+ end
209
+
210
+ @voice_channel = channel
211
+ debug("Got voice channel: #{@voice_channel}")
212
+
213
+ data = {
214
+ op: 4,
215
+ d: {
216
+ guild_id: @voice_channel.server.id.to_s,
217
+ channel_id: @voice_channel.id.to_s,
218
+ self_mute: false,
219
+ self_deaf: false
220
+ }
221
+ }
222
+ debug("Voice channel init packet is: #{data.to_json}")
223
+
224
+ @should_connect_to_voice = true
225
+ @ws.send(data.to_json)
226
+ debug('Voice channel init packet sent! Now waiting.')
227
+
228
+ sleep(0.05) until @voice
229
+ debug('Voice connect succeeded!')
230
+ @voice
231
+ end
232
+
195
233
  # Revokes an invite to a server. Will fail unless you have the *Manage Server* permission.
196
234
  # It is recommended that you use {Invite#delete} instead.
197
235
  # @param code [String, Invite] The invite to revoke. For possible formats see {#resolve_invite_code}.
@@ -330,7 +368,7 @@ module Discordrb
330
368
  def parse_mention(mention)
331
369
  # Mention format: <@id>
332
370
  return nil unless /\<@(?<id>\d+)\>?/ =~ mention
333
- user(id)
371
+ user(id.to_i)
334
372
  end
335
373
 
336
374
  # Sets the currently playing game to the specified game.
@@ -590,6 +628,24 @@ module Discordrb
590
628
  channel = nil
591
629
  channel = @channels[channel_id.to_i] if channel_id
592
630
  user.move(channel)
631
+
632
+ @session_id = data['session_id']
633
+ end
634
+
635
+ # Internal handler for VOICE_SERVER_UPDATE
636
+ def update_voice_server(data)
637
+ debug("Voice server update received! should connect: #{@should_connect_to_voice}")
638
+ return unless @should_connect_to_voice
639
+ @should_connect_to_voice = false
640
+ debug('Updating voice server!')
641
+
642
+ token = data['token']
643
+ endpoint = data['endpoint']
644
+ channel = @voice_channel
645
+
646
+ debug('Got data, now creating the bot.')
647
+ @voice = Discordrb::Voice::VoiceBot.new(channel, self, token, @session_id, endpoint)
648
+ @voice
593
649
  end
594
650
 
595
651
  # Internal handler for CHANNEL_CREATE
@@ -679,7 +735,7 @@ module Discordrb
679
735
 
680
736
  # Internal handler for GUILD_DELETE
681
737
  def delete_guild(data)
682
- id = data['id']
738
+ id = data['id'].to_i
683
739
 
684
740
  @users.each do |_, user|
685
741
  user.delete_roles(id)
@@ -734,6 +790,13 @@ module Discordrb
734
790
  debug('Logging in')
735
791
  login_attempts ||= 0
736
792
 
793
+ # First, attempt to get the token from the cache
794
+ token = @token_cache.token(@email, @password)
795
+ if token
796
+ debug("Token successfully obtained from cache: #{token}")
797
+ return token
798
+ end
799
+
737
800
  # Login
738
801
  login_response = API.login(@email, @password)
739
802
  fail HTTPStatusException, login_response.code if login_response.code >= 400
@@ -743,6 +806,10 @@ module Discordrb
743
806
  fail InvalidAuthenticationException unless login_response_object['token']
744
807
 
745
808
  debug("Received token: #{login_response_object['token']}")
809
+
810
+ # Cache the token
811
+ @token_cache.store_token(@email, @password, login_response_object['token'])
812
+
746
813
  login_response_object['token']
747
814
  rescue Exception => e
748
815
  response_code = login_response.nil? ? 0 : login_response.code ######## mackmm145
@@ -877,6 +944,10 @@ module Discordrb
877
944
 
878
945
  event = VoiceStateUpdateEvent.new(data, self)
879
946
  raise_event(event)
947
+ when 'VOICE_SERVER_UPDATE'
948
+ update_voice_server(data)
949
+
950
+ # no event as this is irrelevant to users
880
951
  when 'CHANNEL_CREATE'
881
952
  create_channel(data)
882
953
 
@@ -587,7 +587,7 @@ module Discordrb
587
587
  user.server_deaf = element['deaf']
588
588
  user.self_mute = element['self_mute']
589
589
  user.self_mute = element['self_mute']
590
- channel_id = element['channel_id']
590
+ channel_id = element['channel_id'].to_i
591
591
  channel = channel_id ? @channels_by_id[channel_id] : nil
592
592
  user.move(channel)
593
593
  end
@@ -8,7 +8,7 @@ module Discordrb::Events
8
8
  def initialize(data, bot)
9
9
  @user_id = data['user_id'].to_i
10
10
  @user = bot.user(@user_id)
11
- @channel_id = data['channel_id']
11
+ @channel_id = data['channel_id'].to_i
12
12
  @channel = bot.channel(@channel_id)
13
13
  @timestamp = Time.at(data['timestamp'].to_i)
14
14
  end
@@ -7,9 +7,9 @@ module Discordrb
7
7
  puts "[DEBUG : #{Thread.current[:discordrb_name]} @ #{Time.now}] #{message}" if @debug || important
8
8
  end
9
9
 
10
- def log_exception(e)
11
- debug("Exception: #{e.inspect}", true)
12
- e.backtrace.each { |line| debug(line, true) }
10
+ def log_exception(e, important = true)
11
+ debug("Exception: #{e.inspect}", important)
12
+ e.backtrace.each { |line| debug(line, important) }
13
13
  end
14
14
  end
15
15
  end
@@ -0,0 +1,140 @@
1
+ require 'base64'
2
+ require 'json'
3
+ require 'openssl'
4
+ require 'discordrb/api'
5
+
6
+ # Discordrb
7
+ module Discordrb
8
+ # Amount of bytes the key should be long (32 bytes = 256 bits -> AES256)
9
+ KEYLEN = 32
10
+
11
+ # Represents a cached token with encryption data
12
+ class CachedToken
13
+ def initialize(data = nil)
14
+ if data
15
+ @verify_salt = Base64.decode64(data['verify_salt'])
16
+ @password_hash = Base64.decode64(data['password_hash'])
17
+ @encrypt_salt = Base64.decode64(data['encrypt_salt'])
18
+ @iv = Base64.decode64(data['iv'])
19
+ @encrypted_token = Base64.decode64(data['encrypted_token'])
20
+ else
21
+ generate_salts
22
+ end
23
+ end
24
+
25
+ def data
26
+ {
27
+ verify_salt: Base64.encode64(@verify_salt),
28
+ password_hash: Base64.encode64(@password_hash),
29
+ encrypt_salt: Base64.encode64(@encrypt_salt),
30
+ iv: Base64.encode64(@iv),
31
+ encrypted_token: Base64.encode64(@encrypted_token)
32
+ }
33
+ end
34
+
35
+ def verify_password(password)
36
+ hash_password(password) == @password_hash
37
+ end
38
+
39
+ def generate_verify_hash(password)
40
+ @password_hash = hash_password(password)
41
+ end
42
+
43
+ def obtain_key(password)
44
+ @key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(password, @encrypt_salt, 20_000, KEYLEN)
45
+ end
46
+
47
+ def generate_salts
48
+ @verify_salt = OpenSSL::Random.random_bytes(KEYLEN)
49
+ @encrypt_salt = OpenSSL::Random.random_bytes(KEYLEN)
50
+ end
51
+
52
+ def decrypt_token(password)
53
+ key = obtain_key(password)
54
+ decipher = OpenSSL::Cipher::AES256.new(:CBC)
55
+ decipher.decrypt
56
+ decipher.key = key
57
+ decipher.iv = @iv
58
+ decipher.update(@encrypted_token) + decipher.final
59
+ end
60
+
61
+ def encrypt_token(password, token)
62
+ key = obtain_key(password)
63
+ cipher = OpenSSL::Cipher::AES256.new(:CBC)
64
+ cipher.encrypt
65
+ cipher.key = key
66
+ @iv = cipher.random_iv
67
+ @encrypted_token = cipher.update(token) + cipher.final
68
+ @encrypted_token
69
+ end
70
+
71
+ def test_token(token)
72
+ Discordrb::API.gateway(token)
73
+ end
74
+
75
+ private
76
+
77
+ def hash_password(password)
78
+ digest = OpenSSL::Digest::SHA256.new
79
+ OpenSSL::PKCS5.pbkdf2_hmac(password, @verify_salt, 20_000, digest.digest_length, digest)
80
+ end
81
+ end
82
+
83
+ # Path where the cache file will be stored
84
+ CACHE_PATH = Dir.home + '/.discordrb_token_cache.json'
85
+
86
+ # Represents a token file
87
+ class TokenCache
88
+ def initialize
89
+ if File.file? CACHE_PATH
90
+ @data = JSON.parse(File.read(CACHE_PATH))
91
+ else
92
+ LOGGER.debug("Cache file #{CACHE_PATH} not found. Using empty cache")
93
+ @data = {}
94
+ end
95
+ end
96
+
97
+ def token(email, password)
98
+ if @data[email]
99
+ begin
100
+ cached = CachedToken.new(@data[email])
101
+ if cached.verify_password(password)
102
+ token = cached.decrypt_token(password)
103
+ if token
104
+ begin
105
+ cached.test_token(token)
106
+ token
107
+ rescue => e; fail_token('Token cached, verified and decrypted, but rejected by Discord', email, e)
108
+ end
109
+ else; fail_token('Token cached and verified, but decryption failed', email)
110
+ end
111
+ else; fail_token('Token verification failed', email)
112
+ end
113
+ rescue => e; fail_token('Token cached but invalid', email, e)
114
+ end
115
+ else; fail_token('Token not cached at all')
116
+ end
117
+ end
118
+
119
+ def store_token(email, password, token)
120
+ cached = CachedToken.new
121
+ cached.generate_verify_hash(password)
122
+ cached.encrypt_token(password, token)
123
+ @data[email] = cached.data
124
+ write_cache
125
+ end
126
+
127
+ def write_cache
128
+ File.write(CACHE_PATH, @data.to_json)
129
+ end
130
+
131
+ private
132
+
133
+ def fail_token(msg, email = nil, e = nil)
134
+ LOGGER.debug("Token not retrieved from cache - #{msg}")
135
+ LOGGER.log_exception(e, false) if e
136
+ @data.delete(email) if email
137
+ nil
138
+ end
139
+ end
140
+ end
@@ -1,4 +1,4 @@
1
1
  # Discordrb and all its functionality, in this case only the version.
2
2
  module Discordrb
3
- VERSION = '1.4.8'
3
+ VERSION = '1.5.0'
4
4
  end
@@ -0,0 +1,37 @@
1
+ require 'opus-ruby'
2
+
3
+ # Discord voice chat support
4
+ module Discordrb::Voice
5
+ # Wrapper class around opus-ruby
6
+ class Encoder
7
+ attr_accessor :volume
8
+
9
+ def initialize
10
+ @sample_rate = 48_000
11
+ @frame_size = 960
12
+ @channels = 2
13
+ @volume = 1.0
14
+ @opus = Opus::Encoder.new(@sample_rate, @frame_size, @channels)
15
+ end
16
+
17
+ def encode(buffer)
18
+ @opus.encode(buffer, 1920)
19
+ end
20
+
21
+ def destroy
22
+ @opus.destroy
23
+ end
24
+
25
+ def encode_file(file)
26
+ command = "ffmpeg -loglevel 0 -i #{file.path} -f s16le -ar 48000 -ac 2 -af volume=#{@volume} pipe:1"
27
+ IO.popen(command)
28
+ end
29
+
30
+ def encode_io(io)
31
+ ret_io, writer = IO.pipe
32
+ command = "ffmpeg -loglevel 0 -i - -f s16le -ar 48000 -ac 2 -af volume=#{@volume} pipe:1"
33
+ spawn(command, in: io, out: writer)
34
+ ret_io
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,216 @@
1
+ require 'websocket-client-simple'
2
+ require 'resolv'
3
+ require 'socket'
4
+ require 'json'
5
+
6
+ module Discordrb::Voice
7
+ # Represents a UDP connection to a voice server
8
+ class VoiceUDP
9
+ # Only creates a socket as the discovery reply may come before the data is initialized.
10
+ def initialize
11
+ @socket = UDPSocket.new
12
+ end
13
+
14
+ # Initializes the data from opcode 2
15
+ def connect(endpoint, port, ssrc)
16
+ @endpoint = endpoint
17
+ @endpoint = @endpoint[6..-1] if @endpoint.start_with? 'wss://'
18
+ @endpoint.gsub!(':80', '') # The endpoint may contain a port, we don't want that
19
+ @endpoint = Resolv.getaddress @endpoint
20
+
21
+ @port = port
22
+ @ssrc = ssrc
23
+ end
24
+
25
+ def receive_discovery_reply
26
+ # Wait for a UDP message
27
+ message = @socket.recvmsg.first
28
+ ip = message[4..-3].delete("\0")
29
+ port = message[-2..-1].to_i
30
+ [ip, port]
31
+ end
32
+
33
+ def send_audio(buf, sequence, time)
34
+ packet = [0x80, 0x78, sequence, time, @ssrc].pack('CCnNN') + buf
35
+ send_packet(packet)
36
+ end
37
+
38
+ def send_discovery
39
+ discovery_packet = [@ssrc].pack('N')
40
+
41
+ # Add 66 zeroes so the packet is 70 bytes long
42
+ discovery_packet += "\0" * 66
43
+ send_packet(discovery_packet)
44
+ end
45
+
46
+ private
47
+
48
+ def send_packet(packet)
49
+ @socket.send(packet, 0, @endpoint, @port)
50
+ end
51
+ end
52
+
53
+ # Represents a websocket connection to the voice server
54
+ class VoiceWS
55
+ attr_reader :udp
56
+
57
+ def initialize(channel, bot, token, session, endpoint)
58
+ @channel = channel
59
+ @bot = bot
60
+ @token = token
61
+ @session = session
62
+
63
+ @endpoint = endpoint
64
+ @endpoint.gsub!(':80', '')
65
+
66
+ @udp = VoiceUDP.new
67
+ end
68
+
69
+ # Send a connection init packet (op 0)
70
+ def send_init(server_id, bot_user_id, session_id, token)
71
+ @client.send({
72
+ op: 0,
73
+ d: {
74
+ server_id: server_id,
75
+ user_id: bot_user_id,
76
+ session_id: session_id,
77
+ token: token
78
+ }
79
+ }.to_json)
80
+ end
81
+
82
+ # Sends the UDP connection packet (op 1)
83
+ def send_udp_connection(ip, port, mode)
84
+ @client.send({
85
+ op: 1,
86
+ d: {
87
+ protocol: 'udp',
88
+ data: {
89
+ address: ip,
90
+ port: port,
91
+ mode: mode
92
+ }
93
+ }
94
+ }.to_json)
95
+ end
96
+
97
+ # Send a heartbeat (op 3), has to be done every @heartbeat_interval seconds or the connection will terminate
98
+ def send_heartbeat
99
+ millis = Time.now.strftime('%s%L').to_i
100
+ @bot.debug("Sending voice heartbeat at #{millis}")
101
+
102
+ @client.send({
103
+ 'op' => 3,
104
+ 'd' => nil
105
+ }.to_json)
106
+ end
107
+
108
+ # Send a speaking packet (op 5). This determines the green circle around the avatar in the voice channel
109
+ def send_speaking(value)
110
+ @bot.debug("Speaking: #{value}")
111
+ @client.send({
112
+ op: 5,
113
+ d: {
114
+ speaking: value,
115
+ delay: 0
116
+ }
117
+ }.to_json)
118
+ end
119
+
120
+ # Event handlers; public for websocket-simple to work correctly
121
+ def websocket_open
122
+ # Send the init packet
123
+ send_init(@channel.server.id, @bot.bot_user.id, @session, @token)
124
+ end
125
+
126
+ def websocket_message(msg)
127
+ @bot.debug("Received VWS message! #{msg}")
128
+ packet = JSON.parse(msg)
129
+
130
+ case packet['op']
131
+ when 2
132
+ # Opcode 2 contains data to initialize the UDP connection
133
+ @ws_data = packet['d']
134
+
135
+ @heartbeat_interval = @ws_data['heartbeat_interval']
136
+ @ssrc = @ws_data['ssrc']
137
+ @port = @ws_data['port']
138
+ @udp_mode = @ws_data['modes'][0]
139
+
140
+ @udp.connect(@endpoint, @port, @ssrc)
141
+ @udp.send_discovery
142
+ when 4
143
+ # I'm not 100% sure what this packet does, but I'm keeping it for future compatibility.
144
+ @ws_data = packet['d']
145
+ @ready = true
146
+ @mode = @ws_data['mode']
147
+ end
148
+ end
149
+
150
+ # Communication goes like this:
151
+ # me discord
152
+ # | |
153
+ # websocket connect -> |
154
+ # | |
155
+ # | <- websocket opcode 2
156
+ # | |
157
+ # UDP discovery -> |
158
+ # | |
159
+ # | <- UDP reply packet
160
+ # | |
161
+ # websocket opcode 1 -> |
162
+ # | |
163
+ # ...
164
+ def connect
165
+ # Connect websocket
166
+ @thread = Thread.new do
167
+ Thread.current[:discordrb_name] = 'vws'
168
+ init_ws
169
+ end
170
+
171
+ @bot.debug('Started websocket initialization, now waiting for UDP discovery reply')
172
+
173
+ # Now wait for opcode 2 and the resulting UDP reply packet
174
+ ip, port = @udp.receive_discovery_reply
175
+ @bot.debug("UDP discovery reply received! #{ip} #{port}")
176
+
177
+ # Send UDP init packet with received UDP data
178
+ send_udp_connection(ip, port, @udp_mode)
179
+ end
180
+
181
+ def destroy
182
+ @thread.kill if @thread
183
+ end
184
+
185
+ private
186
+
187
+ def heartbeat_loop
188
+ loop do
189
+ if @heartbeat_interval
190
+ sleep @heartbeat_interval / 1000.0
191
+ send_heartbeat
192
+ else
193
+ # If no interval has been set yet, sleep a second and check again
194
+ sleep 1
195
+ end
196
+ end
197
+ end
198
+
199
+ def init_ws
200
+ host = "wss://#{@endpoint}:443"
201
+ @bot.debug("Connecting VWS to host: #{host}")
202
+ @client = WebSocket::Client::Simple.connect(host)
203
+
204
+ # Change some instance to local variables for the blocks
205
+ instance = self
206
+
207
+ @client.on(:open) { instance.websocket_open }
208
+ @client.on(:message) { |msg| instance.websocket_message(msg.data) }
209
+ @client.on(:error) { |e| puts e.to_s }
210
+ @client.on(:close) { |e| puts e.to_s }
211
+
212
+ # Block any further execution
213
+ heartbeat_loop
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,152 @@
1
+ require 'discordrb/voice/encoder'
2
+ require 'discordrb/voice/network'
3
+
4
+ # Voice support
5
+ module Discordrb::Voice
6
+ # How long one packet should ideally be (20 ms as defined by Discord)
7
+ IDEAL_LENGTH = 20.0
8
+
9
+ # How many bytes of data to read (1920 bytes * 2 channels)
10
+ DATA_LENGTH = 1920 * 2
11
+
12
+ # A voice connection consisting of a UDP socket and a websocket client
13
+ class VoiceBot
14
+ attr_reader :stream_time
15
+
16
+ def initialize(channel, bot, token, session, endpoint)
17
+ @bot = bot
18
+ @ws = VoiceWS.new(channel, bot, token, session, endpoint)
19
+ @udp = @ws.udp
20
+
21
+ @sequence = @time = 0
22
+
23
+ @encoder = Encoder.new
24
+ @ws.connect
25
+ end
26
+
27
+ # Set the volume. Only applies to future playbacks
28
+ def volume=(value)
29
+ @encoder.volume = value
30
+ end
31
+
32
+ # Pause playback
33
+ def pause
34
+ @paused = true
35
+ end
36
+
37
+ # Continue playback
38
+ def continue
39
+ @paused = false
40
+ end
41
+
42
+ def speaking=(value)
43
+ @playing = value
44
+ @ws.send_speaking(value)
45
+ end
46
+
47
+ def stop_playing
48
+ @was_playing_before = @playing
49
+ @speaking = false
50
+ @io.close if @io
51
+ @io = nil
52
+ sleep IDEAL_LENGTH / 1000.0 if @was_playing_before
53
+ end
54
+
55
+ def destroy
56
+ stop_playing
57
+ @ws.destroy
58
+ @encoder.destroy
59
+ end
60
+
61
+ def play(encoded_io)
62
+ stop_playing if @playing
63
+ @io = encoded_io
64
+ play_internal
65
+ end
66
+
67
+ def play_file(file)
68
+ play @encoder.encode_file(file)
69
+ end
70
+
71
+ def play_io(io)
72
+ play @encoder.encode_io(io)
73
+ end
74
+
75
+ alias_method :play_stream, :play_io
76
+
77
+ private
78
+
79
+ # Plays the data from the @io stream as Discord requires it
80
+ def play_internal
81
+ count = 0
82
+ @playing = true
83
+ @retry_attempts = 3
84
+
85
+ # Default play length (ms), will be adjusted later
86
+ @length = IDEAL_LENGTH
87
+
88
+ self.speaking = true
89
+ loop do
90
+ break unless @playing
91
+
92
+ if count % 100 == 10
93
+ # Starting from the tenth packet, perform length adjustment every 100 packets (2 seconds)
94
+ @length_adjust = Time.now.nsec
95
+ end
96
+
97
+ # Read some data from the buffer
98
+ buf = nil
99
+ begin
100
+ buf = @io.readpartial(DATA_LENGTH)
101
+ rescue EOFError
102
+ @bot.debug('EOF while reading, breaking immediately')
103
+ break
104
+ end
105
+
106
+ # Check whether the buffer has enough data
107
+ if !buf || buf.length != DATA_LENGTH
108
+ @bot.debug("No data is available! Retrying #{@retry_attempts} more times")
109
+ if @retry_attempts == 0
110
+ break
111
+ else
112
+ @retry_attempts -= 1
113
+ next
114
+ end
115
+ end
116
+
117
+ # Track packet count, sequence and time (Discord requires this)
118
+ count += 1
119
+ (@sequence + 10 < 65_535) ? @sequence += 1 : @sequence = 0
120
+ (@time + 9600 < 4_294_967_295) ? @time += 960 : @time = 0
121
+
122
+ # Encode the packet and send it
123
+ @udp.send_audio(@encoder.encode(buf), @sequence, @time)
124
+
125
+ # Set the stream time (for tracking how long we've been playing)
126
+ @stream_time = count * @length / 1000
127
+
128
+ # Perform length adjustment
129
+ if @length_adjust
130
+ # Difference between length_adjust and now in ms
131
+ ms_diff = (Time.now.nsec - @length_adjust) / 1_000_000.0
132
+ if ms_diff >= 0
133
+ @length = IDEAL_LENGTH - ms_diff
134
+ @bot.debug("Length adjustment: new length #{@length}")
135
+ end
136
+ @length_adjust = nil
137
+ end
138
+
139
+ # If paused, wait
140
+ sleep 0.1 while @paused
141
+
142
+ # Wait `length` ms, then send the next packet
143
+ sleep @length / 1000.0
144
+ end
145
+
146
+ @bot.debug('Performing final cleanup after stream ended')
147
+
148
+ # Final cleanup
149
+ stop_playing
150
+ end
151
+ end
152
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: discordrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.8
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - meew0
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-01-06 00:00:00.000000000 Z
11
+ date: 2016-01-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faye-websocket
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: opus-ruby
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: bundler
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -129,7 +143,11 @@ files:
129
143
  - lib/discordrb/exceptions.rb
130
144
  - lib/discordrb/logger.rb
131
145
  - lib/discordrb/permissions.rb
146
+ - lib/discordrb/token_cache.rb
132
147
  - lib/discordrb/version.rb
148
+ - lib/discordrb/voice/encoder.rb
149
+ - lib/discordrb/voice/network.rb
150
+ - lib/discordrb/voice/voice_bot.rb
133
151
  homepage: https://github.com/meew0/discordrb
134
152
  licenses:
135
153
  - MIT