mirror of https://github.com/sgoudham/Enso-Bot.git
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
464 lines
16 KiB
Python
464 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
The MIT License (MIT)
|
|
|
|
Copyright (c) 2015-2020 Rapptz
|
|
|
|
Permission is hereby granted, free of charge, to any person obtaining a
|
|
copy of this software and associated documentation files (the "Software"),
|
|
to deal in the Software without restriction, including without limitation
|
|
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
and/or sell copies of the Software, and to permit persons to whom the
|
|
Software is furnished to do so, subject to the following conditions:
|
|
|
|
The above copyright notice and this permission notice shall be included in
|
|
all copies or substantial portions of the Software.
|
|
|
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
DEALINGS IN THE SOFTWARE.
|
|
"""
|
|
|
|
"""Some documentation to refer to:
|
|
|
|
- Our main web socket (mWS) sends opcode 4 with a guild ID and channel ID.
|
|
- The mWS receives VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE.
|
|
- We pull the session_id from VOICE_STATE_UPDATE.
|
|
- We pull the token, endpoint and server_id from VOICE_SERVER_UPDATE.
|
|
- Then we initiate the voice web socket (vWS) pointing to the endpoint.
|
|
- We send opcode 0 with the user_id, server_id, session_id and token using the vWS.
|
|
- The vWS sends back opcode 2 with an ssrc, port, modes(array) and hearbeat_interval.
|
|
- We send a UDP discovery packet to endpoint:port and receive our IP and our port in LE.
|
|
- Then we send our IP and port via vWS with opcode 1.
|
|
- When that's all done, we receive opcode 4 from the vWS.
|
|
- Finally we can transmit data to endpoint:port.
|
|
"""
|
|
|
|
import asyncio
|
|
import socket
|
|
import logging
|
|
import struct
|
|
import threading
|
|
|
|
from . import opus
|
|
from .backoff import ExponentialBackoff
|
|
from .gateway import *
|
|
from .errors import ClientException, ConnectionClosed
|
|
from .player import AudioPlayer, AudioSource
|
|
|
|
try:
|
|
import nacl.secret
|
|
has_nacl = True
|
|
except ImportError:
|
|
has_nacl = False
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
class VoiceClient:
|
|
"""Represents a Discord voice connection.
|
|
|
|
You do not create these, you typically get them from
|
|
e.g. :meth:`VoiceChannel.connect`.
|
|
|
|
Warning
|
|
--------
|
|
In order to use PCM based AudioSources, you must have the opus library
|
|
installed on your system and loaded through :func:`opus.load_opus`.
|
|
Otherwise, your AudioSources must be opus encoded (e.g. using :class:`FFmpegOpusAudio`)
|
|
or the library will not be able to transmit audio.
|
|
|
|
Attributes
|
|
-----------
|
|
session_id: :class:`str`
|
|
The voice connection session ID.
|
|
token: :class:`str`
|
|
The voice connection token.
|
|
endpoint: :class:`str`
|
|
The endpoint we are connecting to.
|
|
channel: :class:`abc.Connectable`
|
|
The voice channel connected to.
|
|
loop: :class:`asyncio.AbstractEventLoop`
|
|
The event loop that the voice client is running on.
|
|
"""
|
|
def __init__(self, state, timeout, channel):
|
|
if not has_nacl:
|
|
raise RuntimeError("PyNaCl library needed in order to use voice")
|
|
|
|
self.channel = channel
|
|
self.main_ws = None
|
|
self.timeout = timeout
|
|
self.ws = None
|
|
self.socket = None
|
|
self.loop = state.loop
|
|
self._state = state
|
|
# this will be used in the AudioPlayer thread
|
|
self._connected = threading.Event()
|
|
|
|
self._handshaking = False
|
|
self._handshake_check = asyncio.Lock()
|
|
self._handshake_complete = asyncio.Event()
|
|
|
|
self.mode = None
|
|
self._connections = 0
|
|
self.sequence = 0
|
|
self.timestamp = 0
|
|
self._runner = None
|
|
self._player = None
|
|
self.encoder = None
|
|
self._lite_nonce = 0
|
|
|
|
warn_nacl = not has_nacl
|
|
supported_modes = (
|
|
'xsalsa20_poly1305_lite',
|
|
'xsalsa20_poly1305_suffix',
|
|
'xsalsa20_poly1305',
|
|
)
|
|
|
|
@property
|
|
def guild(self):
|
|
"""Optional[:class:`Guild`]: The guild we're connected to, if applicable."""
|
|
return getattr(self.channel, 'guild', None)
|
|
|
|
@property
|
|
def user(self):
|
|
""":class:`ClientUser`: The user connected to voice (i.e. ourselves)."""
|
|
return self._state.user
|
|
|
|
def checked_add(self, attr, value, limit):
|
|
val = getattr(self, attr)
|
|
if val + value > limit:
|
|
setattr(self, attr, 0)
|
|
else:
|
|
setattr(self, attr, val + value)
|
|
|
|
# connection related
|
|
|
|
async def start_handshake(self):
|
|
log.info('Starting voice handshake...')
|
|
|
|
guild_id, channel_id = self.channel._get_voice_state_pair()
|
|
state = self._state
|
|
self.main_ws = ws = state._get_websocket(guild_id)
|
|
self._connections += 1
|
|
|
|
# request joining
|
|
await ws.voice_state(guild_id, channel_id)
|
|
|
|
try:
|
|
await asyncio.wait_for(self._handshake_complete.wait(), timeout=self.timeout)
|
|
except asyncio.TimeoutError:
|
|
await self.terminate_handshake(remove=True)
|
|
raise
|
|
|
|
log.info('Voice handshake complete. Endpoint found %s (IP: %s)', self.endpoint, self.endpoint_ip)
|
|
|
|
async def terminate_handshake(self, *, remove=False):
|
|
guild_id, channel_id = self.channel._get_voice_state_pair()
|
|
self._handshake_complete.clear()
|
|
await self.main_ws.voice_state(guild_id, None, self_mute=True)
|
|
|
|
log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', channel_id, guild_id)
|
|
if remove:
|
|
log.info('The voice client has been removed for Channel ID %s (Guild ID %s)', channel_id, guild_id)
|
|
key_id, _ = self.channel._get_voice_client_key()
|
|
self._state._remove_voice_client(key_id)
|
|
|
|
async def _create_socket(self, server_id, data):
|
|
async with self._handshake_check:
|
|
if self._handshaking:
|
|
log.info("Ignoring voice server update while handshake is in progress")
|
|
return
|
|
self._handshaking = True
|
|
|
|
self._connected.clear()
|
|
self.session_id = self.main_ws.session_id
|
|
self.server_id = server_id
|
|
self.token = data.get('token')
|
|
endpoint = data.get('endpoint')
|
|
|
|
if endpoint is None or self.token is None:
|
|
log.warning('Awaiting endpoint... This requires waiting. ' \
|
|
'If timeout occurred considering raising the timeout and reconnecting.')
|
|
return
|
|
|
|
self.endpoint = endpoint.replace(':80', '')
|
|
self.endpoint_ip = socket.gethostbyname(self.endpoint)
|
|
|
|
if self.socket:
|
|
try:
|
|
self.socket.close()
|
|
except Exception:
|
|
pass
|
|
|
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.socket.setblocking(False)
|
|
|
|
if self._handshake_complete.is_set():
|
|
# terminate the websocket and handle the reconnect loop if necessary.
|
|
self._handshake_complete.clear()
|
|
await self.ws.close(4000)
|
|
return
|
|
|
|
self._handshake_complete.set()
|
|
|
|
async def connect(self, *, reconnect=True, _tries=0, do_handshake=True):
|
|
log.info('Connecting to voice...')
|
|
try:
|
|
del self.secret_key
|
|
except AttributeError:
|
|
pass
|
|
|
|
if do_handshake:
|
|
await self.start_handshake()
|
|
|
|
try:
|
|
self.ws = await DiscordVoiceWebSocket.from_client(self)
|
|
self._handshaking = False
|
|
self._connected.clear()
|
|
while not hasattr(self, 'secret_key'):
|
|
await self.ws.poll_event()
|
|
self._connected.set()
|
|
except (ConnectionClosed, asyncio.TimeoutError):
|
|
if reconnect and _tries < 5:
|
|
log.exception('Failed to connect to voice... Retrying...')
|
|
await asyncio.sleep(1 + _tries * 2.0)
|
|
await self.terminate_handshake()
|
|
await self.connect(reconnect=reconnect, _tries=_tries + 1)
|
|
else:
|
|
raise
|
|
|
|
if self._runner is None:
|
|
self._runner = self.loop.create_task(self.poll_voice_ws(reconnect))
|
|
|
|
async def poll_voice_ws(self, reconnect):
|
|
backoff = ExponentialBackoff()
|
|
while True:
|
|
try:
|
|
await self.ws.poll_event()
|
|
except (ConnectionClosed, asyncio.TimeoutError) as exc:
|
|
if isinstance(exc, ConnectionClosed):
|
|
# The following close codes are undocumented so I will document them here.
|
|
# 1000 - normal closure (obviously)
|
|
# 4014 - voice channel has been deleted.
|
|
# 4015 - voice server has crashed
|
|
if exc.code in (1000, 4014, 4015):
|
|
log.info('Disconnecting from voice normally, close code %d.', exc.code)
|
|
await self.disconnect()
|
|
break
|
|
|
|
if not reconnect:
|
|
await self.disconnect()
|
|
raise
|
|
|
|
retry = backoff.delay()
|
|
log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry)
|
|
self._connected.clear()
|
|
await asyncio.sleep(retry)
|
|
await self.terminate_handshake()
|
|
try:
|
|
await self.connect(reconnect=True)
|
|
except asyncio.TimeoutError:
|
|
# at this point we've retried 5 times... let's continue the loop.
|
|
log.warning('Could not connect to voice... Retrying...')
|
|
continue
|
|
|
|
async def disconnect(self, *, force=False):
|
|
"""|coro|
|
|
|
|
Disconnects this voice client from voice.
|
|
"""
|
|
if not force and not self.is_connected():
|
|
return
|
|
|
|
self.stop()
|
|
self._connected.clear()
|
|
|
|
try:
|
|
if self.ws:
|
|
await self.ws.close()
|
|
|
|
await self.terminate_handshake(remove=True)
|
|
finally:
|
|
if self.socket:
|
|
self.socket.close()
|
|
|
|
async def move_to(self, channel):
|
|
"""|coro|
|
|
|
|
Moves you to a different voice channel.
|
|
|
|
Parameters
|
|
-----------
|
|
channel: :class:`abc.Snowflake`
|
|
The channel to move to. Must be a voice channel.
|
|
"""
|
|
guild_id, _ = self.channel._get_voice_state_pair()
|
|
await self.main_ws.voice_state(guild_id, channel.id)
|
|
|
|
def is_connected(self):
|
|
"""Indicates if the voice client is connected to voice."""
|
|
return self._connected.is_set()
|
|
|
|
# audio related
|
|
|
|
def _get_voice_packet(self, data):
|
|
header = bytearray(12)
|
|
|
|
# Formulate rtp header
|
|
header[0] = 0x80
|
|
header[1] = 0x78
|
|
struct.pack_into('>H', header, 2, self.sequence)
|
|
struct.pack_into('>I', header, 4, self.timestamp)
|
|
struct.pack_into('>I', header, 8, self.ssrc)
|
|
|
|
encrypt_packet = getattr(self, '_encrypt_' + self.mode)
|
|
return encrypt_packet(header, data)
|
|
|
|
def _encrypt_xsalsa20_poly1305(self, header, data):
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key))
|
|
nonce = bytearray(24)
|
|
nonce[:12] = header
|
|
|
|
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext
|
|
|
|
def _encrypt_xsalsa20_poly1305_suffix(self, header, data):
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key))
|
|
nonce = nacl.utils.random(nacl.secret.SecretBox.NONCE_SIZE)
|
|
|
|
return header + box.encrypt(bytes(data), nonce).ciphertext + nonce
|
|
|
|
def _encrypt_xsalsa20_poly1305_lite(self, header, data):
|
|
box = nacl.secret.SecretBox(bytes(self.secret_key))
|
|
nonce = bytearray(24)
|
|
|
|
nonce[:4] = struct.pack('>I', self._lite_nonce)
|
|
self.checked_add('_lite_nonce', 1, 4294967295)
|
|
|
|
return header + box.encrypt(bytes(data), bytes(nonce)).ciphertext + nonce[:4]
|
|
|
|
|
|
def play(self, source, *, after=None):
|
|
"""Plays an :class:`AudioSource`.
|
|
|
|
The finalizer, ``after`` is called after the source has been exhausted
|
|
or an error occurred.
|
|
|
|
If an error happens while the audio player is running, the exception is
|
|
caught and the audio player is then stopped. If no after callback is
|
|
passed, any caught exception will be displayed as if it were raised.
|
|
|
|
Parameters
|
|
-----------
|
|
source: :class:`AudioSource`
|
|
The audio source we're reading from.
|
|
after: Callable[[:class:`Exception`], Any]
|
|
The finalizer that is called after the stream is exhausted.
|
|
This function must have a single parameter, ``error``, that
|
|
denotes an optional exception that was raised during playing.
|
|
|
|
Raises
|
|
-------
|
|
ClientException
|
|
Already playing audio or not connected.
|
|
TypeError
|
|
Source is not a :class:`AudioSource` or after is not a callable.
|
|
OpusNotLoaded
|
|
Source is not opus encoded and opus is not loaded.
|
|
"""
|
|
|
|
if not self.is_connected():
|
|
raise ClientException('Not connected to voice.')
|
|
|
|
if self.is_playing():
|
|
raise ClientException('Already playing audio.')
|
|
|
|
if not isinstance(source, AudioSource):
|
|
raise TypeError('source must an AudioSource not {0.__class__.__name__}'.format(source))
|
|
|
|
if not self.encoder and not source.is_opus():
|
|
self.encoder = opus.Encoder()
|
|
|
|
self._player = AudioPlayer(source, self, after=after)
|
|
self._player.start()
|
|
|
|
def is_playing(self):
|
|
"""Indicates if we're currently playing audio."""
|
|
return self._player is not None and self._player.is_playing()
|
|
|
|
def is_paused(self):
|
|
"""Indicates if we're playing audio, but if we're paused."""
|
|
return self._player is not None and self._player.is_paused()
|
|
|
|
def stop(self):
|
|
"""Stops playing audio."""
|
|
if self._player:
|
|
self._player.stop()
|
|
self._player = None
|
|
|
|
def pause(self):
|
|
"""Pauses the audio playing."""
|
|
if self._player:
|
|
self._player.pause()
|
|
|
|
def resume(self):
|
|
"""Resumes the audio playing."""
|
|
if self._player:
|
|
self._player.resume()
|
|
|
|
@property
|
|
def source(self):
|
|
"""Optional[:class:`AudioSource`]: The audio source being played, if playing.
|
|
|
|
This property can also be used to change the audio source currently being played.
|
|
"""
|
|
return self._player.source if self._player else None
|
|
|
|
@source.setter
|
|
def source(self, value):
|
|
if not isinstance(value, AudioSource):
|
|
raise TypeError('expected AudioSource not {0.__class__.__name__}.'.format(value))
|
|
|
|
if self._player is None:
|
|
raise ValueError('Not playing anything.')
|
|
|
|
self._player._set_source(value)
|
|
|
|
def send_audio_packet(self, data, *, encode=True):
|
|
"""Sends an audio packet composed of the data.
|
|
|
|
You must be connected to play audio.
|
|
|
|
Parameters
|
|
----------
|
|
data: :class:`bytes`
|
|
The :term:`py:bytes-like object` denoting PCM or Opus voice data.
|
|
encode: :class:`bool`
|
|
Indicates if ``data`` should be encoded into Opus.
|
|
|
|
Raises
|
|
-------
|
|
ClientException
|
|
You are not connected.
|
|
opus.OpusError
|
|
Encoding the data failed.
|
|
"""
|
|
|
|
self.checked_add('sequence', 1, 65535)
|
|
if encode:
|
|
encoded_data = self.encoder.encode(data, self.encoder.SAMPLES_PER_FRAME)
|
|
else:
|
|
encoded_data = data
|
|
packet = self._get_voice_packet(encoded_data)
|
|
try:
|
|
self.socket.sendto(packet, (self.endpoint_ip, self.voice_port))
|
|
except BlockingIOError:
|
|
log.warning('A packet has been dropped (seq: %s, timestamp: %s)', self.sequence, self.timestamp)
|
|
|
|
self.checked_add('timestamp', opus.Encoder.SAMPLES_PER_FRAME, 4294967295)
|