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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +110 -1
- data/README.md +4 -0
- data/discordrb.gemspec +1 -0
- data/lib/discordrb/bot.rb +73 -2
- data/lib/discordrb/data.rb +1 -1
- data/lib/discordrb/events/typing.rb +1 -1
- data/lib/discordrb/logger.rb +3 -3
- data/lib/discordrb/token_cache.rb +140 -0
- data/lib/discordrb/version.rb +1 -1
- data/lib/discordrb/voice/encoder.rb +37 -0
- data/lib/discordrb/voice/network.rb +216 -0
- data/lib/discordrb/voice/voice_bot.rb +152 -0
- metadata +20 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bc7dfd36f58df8feea4180b9f2ee01246094ee67
|
4
|
+
data.tar.gz: e3abfaca8e1216f1ad67f0abfc93d5631369bb9f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7c7226d0b7f7c7bf675942661d39a499d3161e97cbc872ab9931bc59a2881d0115217fe97db7fa4709cb19cf97b4172ac16ce81070ae4b8b26bae9142330e7f9
|
7
|
+
data.tar.gz: 242b86beeee1d91d9bb5de6f72d239ebb98e73911c1287d825f3668f93eae38ab1e3ec8f075f2b0cd23d28827d49fa21cdfb995248359e36544e278a2cbd6554
|
data/.gitignore
CHANGED
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
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
|
|
data/lib/discordrb/data.rb
CHANGED
@@ -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
|
data/lib/discordrb/logger.rb
CHANGED
@@ -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}",
|
12
|
-
e.backtrace.each { |line| debug(line,
|
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
|
data/lib/discordrb/version.rb
CHANGED
@@ -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
|
+
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-
|
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
|