Cet article est une continuation du précédent Discord Bot avec fonction d'enregistrement commençant par Python: (4) Lecture de fichiers musicaux.
Dans cet article, comme étape préparatoire pour commencer à implémenter l'enregistrement vocal, essayez d'accéder à l'API de communication vocale à l'aide de l'API Discord pour comprendre comment la communication est effectuée.
Nous prévoyons d'écrire 7 articles au total et avons fini de rédiger jusqu'à 5 articles.
Comme vous pouvez le voir en supprimant "Débutant" de l'étiquette, le processus suivant est un peu gênant et implique principalement la partie de la couche basse.
Un diagramme schématique de la communication lors de l'envoi et de la réception de la voix avec Discord est présenté ci-dessous.
Puisqu'il s'agit d'un diagramme schématique, une explication détaillée est omise, mais je pense que ce serait bien si nous pouvions comprendre que la connexion se fait par différents processus.
Ce flux est mis en œuvre sans utiliser discord.py, et la transmission et la réception de l'audio Discord sont traitées en détail.
Toutes les informations ultérieures sont une référence officielle (Voice Connection Gateway, Normal Gateway Il est décrit sur la base de docs / topics / gateway # gateways)).
Discord Gateway dispose d'une passerelle qui envoie et reçoit des informations sur la voix de passerelle normale. Pour obtenir l'URL du point final pour la connexion à la passerelle vocale, authentifiez-vous d'abord avec la passerelle normale, puis connectez-vous à la passerelle vocale. Des informations seront envoyées.
Tout d'abord, créez un script pour essayer la connexion WebSocket en Python.
op10 Hello
import json
import asyncio
import aiohttp
from pprint import pprint
class Gateway:
def __init__(self, loop=None):
if loop is None:
loop = asyncio.get_event_loop()
self.endpoint = 'wss://gateway.discord.gg/?v=6&encoding=json'
loop.create_task(self.receive_data())
async def receive_data(self):
async with aiohttp.ClientSession() as session:
socket = await session.ws_connect(self.endpoint)
while True:
packet = await socket.receive()
if packet.type in (aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR):
print(packet)
print('==Fin de connexion==')
break
pprint(json.loads(packet.data))
if __name__ == "__main__":
loop = asyncio.get_event_loop()
ws = Gateway(loop)
loop.run_forever()
Lorsque cela est exécuté, la coroutine receive_data
pour se connecter à la passerelle et afficher les données reçues de la passerelle une par une est appelée à partir de la fonction create_task
dans la passerelle et commence le traitement. Une fois cette opération exécutée, les données suivantes seront envoyées à partir de Discord Gateway.
{'d': {'_trace': ['["gateway-prd-main-xwmj",{"micros":0.0}]'],
'heartbeat_interval': 41250},
'op': 10,
's': None,
't': None}
Les données envoyées par Discord utilisent «d» et «op», et rarement «t» pour représenter les données. Dans «op», le type de données est stocké, et dans «d», le corps d'information des données est stocké. «t» est fondamentalement «Aucun», mais si vous avez besoin de transmettre des informations plus détaillées, ce sera une chaîne de caractères qui transmettra les détails de ces informations.
op1 Heartbeat
Ici, ʻop = 10. Il s'agit d'une réponse appelée «Hello», qui correspond aux données envoyées lorsque vous vous connectez pour la première fois comme son nom l'indique. Les données importantes dans Hello sont «heartbeat_interval». Ici, il s'agit de «41250», qui doit envoyer une simple donnée appelée «Heartbeat» pour vous dire que la passerelle est toujours connectée chaque milliseconde spécifiée (41,25 secondes). Il y a. Créez une classe qui hérite de
threading.Threaden tant que classe auxiliaire qui exécute ce processus Heartbeat. En écrivant le processus souhaité dans la fonction
run` et en appelant la fonction de démarrage depuis l'instance, le processus sera exécuté dans un autre thread.
import json
import asyncio
import aiohttp
import threading
from pprint import pprint
class HeartbeatHandler(threading.Thread):
def __init__(self, ws, interval):
self.ws = ws
self.interval = interval
self.stop_ev = threading.Event()
super().__init__()
def run(self):
self.send()
while not self.stop_ev.wait(self.interval):
self.send()
def send(self):
data = self.get_payload()
asyncio.run_coroutine_threadsafe(
self.ws.socket.send_json(data),
self.ws.loop
)
print('==Envoyer==')
print(data)
def stop(self):
self.stop_ev.set()
def get_payload(self):
raise NotImplementedError
class GatewayHeartbeat(HeartbeatHandler):
def __init__(self, ws, interval):
super().__init__(ws, interval)
def get_payload(self):
return {'op': 1, 'd': None}
class Gateway:
def __init__(self, loop=None):
if loop is None:
self.loop = asyncio.get_event_loop()
else:
self.loop = loop
self.endpoint = 'wss://gateway.discord.gg/?v=6&encoding=json'
self.loop.create_task(self.receive_data())
async def receive_data(self):
async with aiohttp.ClientSession() as session:
self.socket = await session.ws_connect(self.endpoint)
while True:
packet = await self.socket.receive()
if packet.type in (aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR):
print(packet)
print('==Fin de connexion==')
break
print('==Recevoir==')
pprint(json.loads(packet.data))
await self.handle_message(json.loads(packet.data))
if hasattr(self, 'heartbeat'):
self.heartbeat.stop()
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 10:
self.heartbeat = GatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
return
if __name__ == "__main__":
loop = asyncio.get_event_loop()
ws = Gateway(loop)
loop.run_forever()
Ça s'appelle run_coroutine_threadsafe
** Quoi qu'il en soit! !! Puisqu'il existe une fonction **, utilisez-la. Lorsque ceci est exécuté, l'état de la communication entre eux est émis toutes les 40 secondes.
==Recevoir==
{'d': {'_trace': ['["gateway-prd-main-w7j9",{"micros":0.0}]'],
'heartbeat_interval': 41250},
'op': 10,
's': None,
't': None}
==Envoyer==
{'op': 1, 'd': None}
==Recevoir==
{'d': None, 'op': 11, 's': None, 't': None}
==Envoyer==
{'op': 1, 'd': None}
==Recevoir==
{'d': None, 'op': 11, 's': None, 't': None}
...
Si cela n'est pas fait, la connexion sera déconnectée de Discord Gateway après 40 secondes. Cependant, si vous n'effectuez que Heartbeat, vous pouvez recevoir une demande de reconnexion du côté Discord. Pour le moment, nous n'effectuerons pas de traitement tel que la reconnexion ici.
op2 Identify
Ensuite, vous devez envoyer un jeton Bot pour informer la passerelle des informations de connexion. Ces informations sont envoyées par op2, mais en plus du jeton Bot, des informations de connexion simples sont ajoutées aux propriétés
de la charge utile. De plus, si vous exploitez un Bot à grande échelle et effectuez un «Sharding», un traitement supplémentaire est nécessaire, mais ici nous effectuerons le traitement en supposant que le Sharding n'est pas utilisé avec un Bot à petite échelle.
class Gateway:
def __init__(self, loop=None):
if loop is None:
self.loop = asyncio.get_event_loop()
else:
self.loop = loop
self.endpoint = 'wss://gateway.discord.gg/?v=6&encoding=json'
self.loop.create_task(self.receive_data())
self.identified = asyncio.Event()
async def receive_data(self):
async with aiohttp.ClientSession() as session:
self.socket = await session.ws_connect(self.endpoint)
while True:
packet = await self.socket.receive()
if packet.type in (aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR):
print('==Fin de connexion==')
print(packet)
break
print('==Recevoir==')
pprint(json.loads(packet.data))
await self.handle_message(json.loads(packet.data))
if hasattr(self, 'heartbeat'):
self.heartbeat.stop()
async def identify(self):
payload = {
'op': 2,
'd': {
'token': 'BOT_TOKEN',
'properties': {
'$os': 'linux',
'$browser': 'python',
'$device': 'python',
},
'v': 3
}
}
print('==Envoyer==')
print(payload)
await self.socket.send_json(payload)
self.identified.set()
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 10:
self.heartbeat = GatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
await self.identify()
return
L'authentification est effectuée en envoyant le jeton Bot, et les informations Bot et les informations du serveur sur lequel le Bot est installé seront reçues. Il est facile de l'oublier car diverses informations sont envoyées, mais si les informations t = READY
sont envoyées avec ʻop = 0, cela signifie que vous êtes prêt à communiquer entre vous en utilisant Gateway. De plus, le
session_id dans le
d` est utilisé pour la connexion vocale, donc enregistrez-le.
{'d': {
...
'session_id': 'f0d7bba081bc0df51e43c1eef8092adcb',
...
},
'op': 0,
's': 1,
't': 'READY'}
op4 Gateway Voice State Update
Afin d'obtenir les informations pour se connecter à la passerelle vocale, il est nécessaire d'envoyer la connexion à la passerelle normale avec ʻop = 4`.
Lorsque ʻop = 4`, spécifiez l'ID du serveur et du canal audio et l'état de coupure de lui-même et envoyez-le à la passerelle. Cela vous donnera l'URL du point de terminaison de la passerelle vocale utilisée par le serveur.
class Gateway:
...
async def voice_state_update(self):
payload = {
'op': 4,
'd': {
'guild_id': '705...',
'channel_id': '706...',
"self_mute": False, #S'il faut couper le son
"self_deaf": False, #S'il faut couper le son du haut-parleur
}
}
print('==Envoyer==')
print(payload)
await self.socket.send_json(payload)
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 10:
self.heartbeat = GatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
await self.identify()
return
if op == 0:
if t == 'READY':
self.session_id = d['session_id']
await self.voice_state_update()
Quand ceci est exécuté, le Bot sera connecté au canal audio et les deux données suivantes seront reçues.
==Recevoir==
{'d': {'channel_id': '705...',
'deaf': False,
'guild_id': '706...',
'member': ...,
'mute': False,
'self_deaf': False,
'self_mute': False,
'self_video': False,
'session_id': 'f0d7bba081bc0df51e43c1eef8092adcb',
'suppress': False,
'user_id': '743...'},
'op': 0,
's': 3,
't': 'VOICE_STATE_UPDATE'}
==Recevoir==
{'d': {'endpoint': 'japan396.discord.media:80',
'guild_id': '705...',
'token': '0123456789abcdef'},
'op': 0,
's': 4,
't': 'VOICE_SERVER_UPDATE'}
Le «point final» de «VOICE_SERVER_UPDATE» ci-dessous est le point final de la passerelle vocale, et «token» est utilisé comme jeton d'authentification.
op3 Heartbeat
De là, la communication avec la passerelle vocale démarre.
Créez une nouvelle classe pour la communication WebSocket avec le point de terminaison obtenu précédemment.
class Gateway:
...
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 10:
self.heartbeat = GatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
await self.identify()
return
if op == 0:
if t == 'READY':
self.session_id = d['session_id']
await self.voice_state_update()
if t == 'VOICE_SERVER_UPDATE':
self.voice_endpoint = d['endpoint']
self.token = d['token']
self.voice_gw = VoiceGateway(self, self.loop)
class VoiceGateway:
def __init__(self, gateway, loop=None):
self.gateway = gateway
if loop is None:
self.loop = asyncio.get_event_loop()
else:
self.loop = loop
self.endpoint = f'wss://{gateway.voice_endpoint.replace(":80", "")}/?v=4'
self.loop.create_task(self.receive_data())
self.identified = asyncio.Event()
async def receive_data(self):
async with aiohttp.ClientSession() as session:
self.socket = await session.ws_connect(self.endpoint)
while True:
packet = await self.socket.receive()
if packet.type in (aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.ERROR):
print('**Fin de connexion**')
print(packet)
break
print('**Recevoir**')
pprint(json.loads(packet.data))
await self.handle_message(json.loads(packet.data))
async def handle_message(self, msg):
pass
Si vous faites cela et que cela fonctionne, il renverra l'intervalle Heartbeat ainsi que la première passerelle.
==Recevoir==
{'d': ...
'op': 0,
's': 5,
't': 'VOICE_SERVER_UPDATE'}
**Recevoir**
{'d': {'heartbeat_interval': 13750.25, 'v': 4}, 'op': 8}
Pour conserver la connexion, envoyez cette fois Heartbeat avec ʻop = 3`. Un horodatage est donné comme données.
import json
import asyncio
import aiohttp
import threading
import time # <-ajouter à
from pprint import pprint
class VoiceGatewayHeartbeat(HeartbeatHandler):
def __init__(self, ws, interval):
super().__init__(ws, interval)
def get_payload(self):
#Temps en milliseconde'd'Mis à
return {'op': 3, 'd': time.time_ns()//1000}
class VoiceGateway:
...
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 8:
self.heartbeat = VoiceGatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
return
Étant donné que ce battement de cœur doit être utilisé après l'authentification du bot, la communication n'est pas démarrée et elle est laissée telle quelle.
op0 Identify
Pour vous authentifier avec la passerelle vocale, utilisez l'ID de serveur, l'ID utilisateur du bot, session_id
et token
comme charge utile et envoyez avec ʻop = 0`.
class VoiceGateway:
...
async def identify(self):
payload = {
'op': 0,
'd': {
'token': self.gateway.token,
'user_id': '743853432007557210',
'server_id': '705052322761277540',
'session_id': self.gateway.session_id,
}
}
print('**Envoyer**')
print(payload)
await self.socket.send_json(payload)
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 8:
await self.identify()
self.heartbeat = VoiceGatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
return
Si authentifié avec succès, vous recevrez «op2 Ready». «IP» et «port» correspondent à l'adresse pour obtenir des informations vocales, «modes» correspond à la méthode de cryptage de la voix prise en charge par Discord, et «ssrc» correspond à l'identifiant.
**Recevoir**
{'d': {'heartbeat_interval': 13750.25, 'v': 4}, 'op': 8}
**Envoyer**
{'op': 0, 'd': {'token': '871d40956f7cf34a', 'user_id': '743853432007557210', 'server_id': '705052322761277540', 'session_id': 'c412a670dbed864b559a25009459f15a'}}
==Envoyer==
{'op': 3, 'd': 1598314493140616}
**Recevoir**
{'d': {'experiments': ['bwe_conservative_link_estimate',
'bwe_remote_locus_client'],
'ip': '123.123.123.123',
'modes': ['aead_aes256_gcm',
'xsalsa20_poly1305_lite',
'xsalsa20_poly1305_suffix',
'xsalsa20_poly1305'],
'port': 50004,
'ssrc': 364117},
'op': 2}
**Recevoir**
{'d': 1598314493140616, 'op': 6}
==Envoyer==
{'op': 3, 'd': 1598314506891112}
**Recevoir**
{'d': 1598314506891112, 'op': 6}
Je fais une connexion UDP à l'adresse IP obtenue lors de la communication précédente et j'obtiens des données vocales, mais cette adresse IP est obscurcie via NAT -connections # ip-discovery), vous devez donc obtenir l'adresse et le port ouverts au public. Pour l'obtenir, envoyez le paquet UDP suivant au serveur de ʻip,
port`.
champ | La description | Taille |
---|---|---|
type | 0x1 | 2 octets |
longueur | 70 | 2 octets |
SSRC | Entier non signé | 4 octets |
adresse IP | code ascii(Le surplus est0x0 (Caractère nul)Pack. 0 lors de l'envoi) |
64 octets |
Port | Entier non signé(0 lors de l'envoi) | 2 octets |
Lorsqu'il est envoyé, le même paquet de 74 octets contenant des données dans l'adresse IP et le port est envoyé, de sorte que les informations IP et de port sont obtenues à partir de ce paquet.
import json
import asyncio
import aiohttp
import threading
import time
import socket # <-ajouter à
import struct # <-ajouter à
from pprint import pprint
class VoiceGateway:
...
async def ip_discovering(self):
self.udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udp.setblocking(False)
packet = bytearray(74)
packet[:2] = struct.pack('>H', 1)
packet[2:4] = struct.pack('>H', 70)
packet[4:8] = struct.pack('>I', self.ssrc)
self.udp.sendto(bytes(packet), (self.ip, self.port))
data = await self.loop.sock_recv(self.udp, 2048)
self.external_ip, self.external_port = struct.unpack_from(
'>64sH', data, 8
)
self.external_ip = self.external_ip.decode(encoding='ascii').rstrip('\x00')
print(self.external_ip, self.external_port)
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 8:
await self.identify()
self.heartbeat = VoiceGatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
return
if op == 2:
self.ip = d['ip']
self.port = d['port']
self.modes = d['modes']
self.ssrc = d['ssrc']
await self.ip_discovering()
Struct
(package standard) est utilisé pour créer des paquets de données UDP. Lorsque ceci est exécuté, un paquet UDP est reçu à l'aide de la boucle d'événements et l'adresse IP et le port sont envoyés à la console.
**Recevoir**
{'d': ...,
'op': 2}
201.158.201.158 54345
La raison de passer par un processus aussi fastidieux est d'obtenir la clé pour déchiffrer la voix cryptée. En envoyant l'adresse IP externe et le port obtenus dans ce processus à la passerelle vocale, vous pouvez obtenir la clé de décryptage en tant que réponse. Ce qu'on appelle libsodium est utilisé pour le cryptage de la voix, et dans le cas de Python, le cryptage et le décryptage à l'aide de libsodium peuvent être effectués en ajoutant le package PyNaCl.
op1 Select Protocol
Rendre possible l'obtention de la clé à utiliser avec libsodium. Pour mode
dans la charge utile de op1, il est nécessaire de sélectionner l'une des méthodes de cryptage parmi les modes obtenus dans ʻop2 plus tôt, mais ici nous utiliserons systématiquement
xsalsa20_poly1305. .. Si vous envoyez ʻop1
, ʻop4 Session Description` sera envoyée comme réponse. Il y a une clé de déchiffrement dans cette charge utile, alors retirez-la.
class VoiceGateway:
...
async def select_protocol(self):
payload = {
'op': 1,
'd': {
'protocol': 'udp',
'data': {
'address': self.external_ip,
'port': self.external_port,
'mode': 'xsalsa20_poly1305'
}
}
}
print('**Envoyer**')
print(payload)
await self.socket.send_json(payload)
async def receive_audio_packet(self):
while True:
data = await self.loop.sock_recv(self.udp, 2048)
print('**Réception vocale**')
print(data)
async def handle_message(self, msg):
op = msg.get('op')
d = msg.get('d')
t = msg.get('t')
if op == 8:
await self.identify()
self.heartbeat = VoiceGatewayHeartbeat(
self, d['heartbeat_interval'] / 1000
)
self.heartbeat.start()
return
if op == 2:
self.ip = d['ip']
self.port = d['port']
self.modes = d['modes']
self.ssrc = d['ssrc']
await self.ip_discovering()
await self.select_protocol()
if op == 4:
self.secret_key = d['secret_key']
self.loop.create_task(self.receive_audio_packet())
Après avoir reçu «op4», les données vocales seront envoyées au socket UDP, donc create_task est exécuté pour démarrer la tâche de réception des données vocales.
**Envoyer**
{'op': 1, 'd': {'protocol': 'udp', 'data': {'address': '106.73.199.128', 'port': 42057, 'mode': 'xsalsa20_poly1305'}}}
**Recevoir**
{'d': {'audio_codec': 'opus',
...
'mode': 'xsalsa20_poly1305',
'secret_key': [244,
157,
...
214],
'video_codec': None},
'op': 4}
**Réception vocale**
b'\x81\xc9\x00\x07\x00\x07\xdd(\x9fI\xb9\xd6\x00G\xce\xa2\xa4\x85M[\xed\xd3\x0fu\x15\x89|\xa6W\x1e\xc3U\x06\xc8\xd5S\x8fJ\x08\xfcx\xff\xe9\x83k\xca\xa9\xec'
**Réception vocale**
b'\x81\xc9\x00\x07\x00\x07\xdd(\x00\x9c^\x83\x90\xc5V\xafX\xff\x14\x97\xf5\xf1/\xad\x15\x89|\xa6W\x1e\xc3U\x06\xc8\xd5S\x8fJ\x08\xfcx\xff\xe9\x83k\xcb\xa9\x02'
**Réception vocale**
b'\x81\xc9\x00\x07\x00\x07\xdd(j\x88B\\O\xd0\rs`\xc1_\x92\xc6\xe6\xe7=\x15\x89|\xa6W\x1e\xc3U\x06\xc8\xd5S\x8fJ\x08\xfcx\xff\xe9\x83k\xc8\xa9\xfd'
**Réception vocale**
b'\x81\xc9\x00\x07\x00\x07\xdd(\x05\x02\xf56\x8a\x13\x9e\xc2\xb6\x8c,\xe6r5\x0e\n\x15\x89|\xa6W\x1e\xc3U\x06\xc8\xd5S\x8fJ\x08\xfcx\xff\xe9\x83k\xc9\xa9\x14'
Les protocoles utilisés pour envoyer et recevoir l'audio Discord sont RTP et RTCP. Chaque paquet qui stocke des données vocales est un paquet RTP qui envoie des données vocales pendant 20 ms à la fois, et un paquet RTCP envoie des informations supplémentaires sur ces données vocales.
Pour faire la distinction entre RTP et RTCP, concentrez-vous sur la valeur du deuxième octet du paquet. Selon la définition du protocole, le deuxième octet de RTCP est plage 200 à 204, donc il peut être identifié ici.
Pour calculer la longueur de l'en-tête RTP, faites attention à X = «1er octet 4e bit» et CC = «1er octet 5-8». Je n'expliquerai pas le rôle de chaque bit, mais
Si $ X = 0 $
Si $ X = 1 $
Il peut être calculé comme suit. len (EX_header) est une valeur indiquant la longueur d'en-tête supplémentaire, qui correspond à la valeur de 2 octets de l'octet «14 + 4 × CC».
Pour plus de détails, veuillez consulter le tableau sur Wikipedia.
Cette fois, il n'y a pas de problème si vous ne pouvez obtenir que Timestamp
qui est l'heure de transmission de la voix dans l'en-tête RTP, donc [API Reference](https://discord.com/developers/docs/topics/voice-connections#encrypting-and -sending-voice-voice-packet-structure) et extraire les 4ème-8ème octets.
Vous pouvez maintenant récupérer les données audio pour le moment.
À partir de ces informations, il est possible de séparer la charge utile et l'en-tête du paquet RTP, mais des problèmes subsistent.
Dans le premier cas, vous avez déjà la clé, vous pouvez donc la déchiffrer en fonction de celle-ci. Ce dernier nécessite un traitement un peu compliqué et rend une bibliothèque C appelée libopus
disponible à partir de Python, et si vous appelez sa fonction de décodage, elle peut être enregistrée en tant que données Wav normales.
Si vous parvenez à effacer ces deux, vous pourrez sauvegarder les données audio. La prochaine fois, j'étendrai le discord.py existant en fonction des connaissances acquises à partir de cette couche inférieure sur la façon de récupérer les données et d'enregistrer les données audio.
Recommended Posts