v6d3music/v6d3music/run-bot.py
2022-04-17 16:13:09 +03:00

910 lines
28 KiB
Python

import asyncio
import concurrent.futures
import json
import os
import random
import re
import shlex
import string
import subprocess
import time
from collections import deque
from io import StringIO
from typing import Optional, AsyncIterable, Any, Iterable, TypeAlias
# noinspection PyPackageRequirements
import discord
import nacl.hash
from ptvp35 import Db, KVJson
from v6d1tokens.client import request_token
from v6d2ctx.context import Context, at, escape, monitor, Benchmark, Explicit, Implicit
from v6d2ctx.handle_content import handle_content
from v6d2ctx.lock_for import lock_for
from v6d2ctx.serve import serve
import v6d3music.extract
import v6d3music.ffmpegnormalaudio
from v6d3music.config import prefix, myroot
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
token = loop.run_until_complete(request_token('music', 'token'))
client = discord.Client(
intents=discord.Intents(
members=True,
guilds=True,
bans=True,
emojis=True,
invites=True,
voice_states=True,
guild_messages=True,
reactions=True
),
)
volume_db = Db(myroot / 'volume.db', kvrequest_type=KVJson)
queue_db = Db(myroot / 'queue.db', kvrequest_type=KVJson)
cache_db = Db(myroot / 'cache.db', kvrequest_type=KVJson)
cache_root = myroot / 'cache'
cache_root.mkdir(exist_ok=True)
vcs_restored = False
async def restore_vcs():
global vcs_restored
vcs: list[tuple[int, int, bool]] = queue_db.get('vcs', [])
try:
for vcgid, vccid, vc_is_paused in vcs:
try:
guild: discord.Guild = await client.fetch_guild(vcgid)
async with lock_for(guild, 'not in a guild'):
channels = await guild.fetch_channels()
channel: discord.VoiceChannel
channel, = [ch for ch in channels if ch.id == vccid]
vp: discord.VoiceProtocol = await channel.connect()
assert isinstance(vp, discord.VoiceClient)
vc = vp
await main_for_raw_vc(vc, create=True)
if vc_is_paused:
vc.pause()
except Exception as e:
print(f'vc {vcgid} {vccid} {vc_is_paused} failed', e)
else:
print(f'vc restored {vcgid} {vccid}')
finally:
vcs_restored = True
@client.event
async def on_ready():
print('ready')
await client.change_presence(activity=discord.Game(
name='феноменально',
))
if not vcs_restored:
await restore_vcs()
@at('commands', 'help')
async def help_(ctx: Context, args: list[str]) -> None:
match args:
case []:
await ctx.reply('music bot')
case [name]:
await ctx.reply(f'help for {name}: `{name} help`')
def speed_quotient(options: str) -> float:
options = options or ''
options = ''.join(c for c in options if not c.isspace())
options += ','
quotient: float = 1.0
asetrate: str
for asetrate in re.findall(r'asetrate=([0-9.]+?),', options):
try:
quotient *= float(asetrate) / discord.opus.Encoder.SAMPLING_RATE
except ValueError:
pass
atempo: str
for atempo in re.findall(r'atempo=([0-9.]+?),', options):
try:
quotient *= float(atempo)
except ValueError:
pass
quotient = max(0.1, min(10.0, quotient))
return quotient
def sparq(options: str) -> float:
return speed_quotient(options) * discord.opus.Encoder.FRAME_LENGTH / 1000
class YTAudio(discord.AudioSource):
source: discord.FFmpegAudio
def __init__(
self,
url: str,
origin: str,
description: str,
options: Optional[str],
rby: discord.Member,
already_read: int,
tor: bool
):
self.url = url
self.origin = origin
self.description = description
self.options = options
self.rby = rby
self.already_read = already_read
self.tor = tor
self.loaded = False
self.regenerating = False
self.set_source()
self._durations: dict[str, str] = {}
def set_source(self):
self.schedule_duration_update()
self.source = v6d3music.ffmpegnormalaudio.FFmpegNormalAudio(
self.url,
options=self.options,
before_options=self.before_options(),
tor=self.tor
)
def set_already_read(self, already_read: int):
self.already_read = already_read
self.set_source()
def set_seconds(self, seconds: float):
self.set_already_read(round(seconds / sparq(self.options)))
def source_seconds(self) -> float:
return self.already_read * sparq(self.options)
def source_timecode(self) -> str:
seconds = round(self.source_seconds())
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return f'{hours}:{minutes:02d}:{seconds:02d}'
def schedule_duration_update(self):
asyncio.get_running_loop().create_task(self.update_duration())
async def update_duration(self):
url: str = self.url
if url in self._durations:
return
self._durations.setdefault(url, '')
prompt = ''
if self.tor:
prompt = 'torify '
prompt += (
f'ffprobe -i {shlex.quote(url)}'
' -show_entries format=duration -v quiet -of csv="p=0" -sexagesimal'
)
p = subprocess.Popen(
prompt,
stdout=subprocess.PIPE,
shell=True
)
with Benchmark('FFP'):
code = await loop.run_in_executor(None, p.wait)
if code:
pass
else:
self._durations[url] = p.stdout.read().decode().strip().split('.')[0]
def duration(self) -> str:
duration = self._durations.get(self.url)
if duration is None:
self.schedule_duration_update()
return duration or '?:??:??'
def before_options(self):
before_options = ''
if 'https' in self.url:
before_options += (
'-reconnect 1 -reconnect_at_eof 0 -reconnect_streamed 1 -reconnect_delay_max 10 -copy_unknown'
)
if self.already_read:
before_options += (
f' -ss {self.source_seconds()}'
)
return before_options
def read(self) -> bytes:
if self.regenerating:
return FILL
self.already_read += 1
ret: bytes = self.source.read()
if ret:
self.loaded = True
elif not self.loaded:
if random.random() > .1:
self.regenerating = True
loop.create_task(self.regenerate())
return FILL
else:
print(f'dropped {self.origin}')
return ret
def cleanup(self):
self.source.cleanup()
def can_be_skipped_by(self, member: discord.Member) -> bool:
permissions: discord.Permissions = member.guild_permissions
if permissions.administrator:
return True
elif permissions.manage_permissions:
return True
elif permissions.manage_guild:
return True
elif permissions.manage_channels:
return True
elif permissions.manage_messages:
return True
else:
return self.rby == member
def hybernate(self):
return {
'url': self.url,
'origin': self.origin,
'description': self.description,
'options': self.options,
'rby': self.rby.id,
'already_read': self.already_read,
'tor': self.tor,
}
@classmethod
async def respawn(cls, guild: discord.Guild, respawn) -> 'YTAudio':
return YTAudio(
respawn['url'],
respawn['origin'],
respawn['description'],
respawn['options'],
guild.get_member(respawn['rby']) or await guild.fetch_member(respawn['rby']),
respawn['already_read'],
respawn.get('tor', False),
)
async def regenerate(self):
try:
print(f'regenerating {self.origin}')
self.url = await real_url(self.origin, True, self.tor)
self.source.cleanup()
self.set_source()
print(f'regenerated {self.origin}')
finally:
self.regenerating = False
FILL = b'\x00' * discord.opus.Encoder.FRAME_SIZE
def assert_admin(member: discord.Member):
permissions: discord.Permissions = member.guild_permissions
if not permissions.administrator:
raise Explicit('not an administrator')
class QueueAudio(discord.AudioSource):
def __init__(self, guild: discord.Guild, respawned: list[YTAudio]):
self.queue: deque[YTAudio] = deque()
self.queue.extend(respawned)
self.guild = guild
@staticmethod
async def respawned(guild: discord.Guild) -> list[YTAudio]:
respawned = []
try:
for audio_respawn in queue_db.get(guild.id, []):
try:
respawned.append(await YTAudio.respawn(guild, audio_respawn))
except Exception as e:
print('audio respawn failed', e)
raise
except Exception as e:
print('queue respawn failed', e)
return respawned
@classmethod
async def create(cls, guild: discord.Guild):
return cls(guild, await QueueAudio.respawned(guild))
async def save(self):
hybernated = []
for audio in list(self.queue):
await asyncio.sleep(0.01)
hybernated.append(audio.hybernate())
queue_db.set_nowait(self.guild.id, hybernated)
def append(self, audio: YTAudio):
self.queue.append(audio)
def read(self) -> bytes:
if not self.queue:
return FILL
audio = self.queue[0]
frame = audio.read()
if len(frame) != discord.opus.Encoder.FRAME_SIZE:
self.queue.popleft().cleanup()
frame = FILL
return frame
def skip_at(self, pos: int, member: discord.Member) -> bool:
if pos < len(self.queue):
audio = self.queue[pos]
if audio.can_be_skipped_by(member):
self.queue.remove(audio)
audio.cleanup()
return True
return False
def skip_audio(self, audio: YTAudio, member: discord.Member) -> bool:
if audio in self.queue:
if audio.can_be_skipped_by(member):
self.queue.remove(audio)
audio.cleanup()
return True
return False
def clear(self, member: discord.Member) -> None:
assert_admin(member)
self.cleanup()
def swap(self, member: discord.Member, a: int, b: int) -> None:
assert_admin(member)
if max(a, b) >= len(self.queue):
return
self.queue[a], self.queue[b] = self.queue[b], self.queue[a]
def move(self, member: discord.Member, a: int, b: int) -> None:
assert_admin(member)
if max(a, b) >= len(self.queue):
return
audio = self.queue[a]
self.queue.remove(audio)
self.queue.insert(b, audio)
async def format(self) -> str:
stream = StringIO()
for i, audio in enumerate(list(self.queue)):
stream.write(f'`[{i}]` `{audio.source_timecode()} / {audio.duration()}` {audio.description}\n')
return stream.getvalue()
def cleanup(self):
self.queue.clear()
for audio in self.queue:
try:
audio.cleanup()
except ValueError:
pass
class MainAudio(discord.PCMVolumeTransformer):
def __init__(self, queue: QueueAudio, volume: float):
self.queue = queue
super().__init__(self.queue, volume=volume)
async def set(self, volume: float, member: discord.Member):
assert_admin(member)
if volume < 0.01:
raise Explicit('volume too small')
if volume > 1:
raise Explicit('volume too big')
self.volume = volume
await volume_db.set(member.guild.id, volume)
def bytes_hash(b: bytes) -> str:
return nacl.hash.sha256(b).decode()
def recursive_hash(obj) -> str:
if isinstance(obj, str):
return bytes_hash(obj.encode())
elif isinstance(obj, tuple) or isinstance(obj, list):
return recursive_hash(';'.join(map(recursive_hash, obj)))
elif isinstance(obj, dict):
return recursive_hash([*obj.items()])
else:
raise TypeError
async def aextract(params: dict, url: str, **kwargs):
with Benchmark('AEX'):
with concurrent.futures.ProcessPoolExecutor() as pool:
return await loop.run_in_executor(
pool,
v6d3music.extract.extract,
params,
url,
kwargs
)
async def tor_extract(params: dict, url: str, **kwargs):
print(f'tor extracting {url}')
p = subprocess.Popen(
['torify', 'python', '-m', 'v6d3music.run-extract'],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True
)
p.stdin.write(f'{json.dumps(params)}\n')
p.stdin.write(f'{json.dumps(url)}\n')
p.stdin.write(f'{json.dumps(kwargs)}\n')
p.stdin.flush()
p.stdin.close()
code = await loop.run_in_executor(None, p.wait)
if code:
raise RuntimeError(code)
return json.loads(p.stdout.read())
async def cache_url(hurl: str, rurl: str, override: bool, tor: bool) -> None:
async with lock_for(('cache', hurl), 'cache failed'):
if not override and cache_db.get(f'url:{hurl}', None) is not None:
return
cachable: bool = cache_db.get(f'cachable:{hurl}', False)
if cachable:
print('caching', hurl)
path = cache_root / f'{hurl}.opus'
tmp_path = cache_root / f'{hurl}.tmp.opus'
args = []
if tor:
args.append('torify')
args.extend(
[
'ffmpeg', '-hide_banner', '-loglevel', 'warning',
'-reconnect', '1', '-reconnect_at_eof', '0',
'-reconnect_streamed', '1', '-reconnect_delay_max', '10', '-copy_unknown',
'-y', '-i', rurl, '-b:a', '128k', str(tmp_path)
]
)
p = subprocess.Popen(
args,
)
with Benchmark('CCH'):
code = await loop.run_in_executor(None, p.wait)
if code:
raise RuntimeError(code)
await loop.run_in_executor(None, tmp_path.rename, path)
await cache_db.set(f'url:{hurl}', str(path))
print('cached', hurl)
# await cache_db.set(f'cachable:{hurl}', False)
else:
await cache_db.set(f'cachable:{hurl}', True)
async def real_url(url: str, override: bool, tor: bool) -> str:
hurl: str = bytes_hash(url.encode())
if not override:
curl: Optional[str] = cache_db.get(f'url:{hurl}', None)
if curl is not None:
print('using cached', hurl)
return curl
args = []
if tor:
args.append('torify')
args.extend(
[
'youtube-dl', '--no-playlist', '-f', 'bestaudio', '-g', '--', url
]
)
p = subprocess.Popen(
args,
stdout=subprocess.PIPE
)
with Benchmark('URL'):
code = await loop.run_in_executor(None, p.wait)
if code:
raise RuntimeError(code)
rurl: str = p.stdout.readline().decode()[:-1]
loop.create_task(cache_url(hurl, rurl, override, tor))
return rurl
def options_for_effects(effects: str) -> Optional[str]:
return f'-af {shlex.quote(effects)}' if effects else None
async def create_ytaudio(
ctx: Context, info: dict[str, Any], effects: Optional[str], already_read: int, tor: bool
) -> YTAudio:
if effects:
if effects not in allowed_effects:
assert_admin(ctx.member)
if not set(effects) <= set(string.ascii_letters + string.digits + '*,=+-/()|.^:_'):
raise Explicit('malformed effects')
options = options_for_effects(effects)
else:
options = None
return YTAudio(
await real_url(info['url'], False, tor),
info['url'],
f'{escape(info.get("title", "unknown"))} `Rby` {ctx.member}',
options,
ctx.member,
already_read,
tor
)
async def entries_for_url(url: str, tor: bool) -> AsyncIterable[
dict[str, Any]
]:
ef = aextract
if tor:
ef = tor_extract
info = await ef(
{
'playlistend': 128,
'logtostderr': True
},
url,
download=False,
process=False
)
if 'entries' in info:
for entry in info['entries']:
yield entry
else:
yield info | {'url': url}
info_tuple: TypeAlias = tuple[dict[str, Any], str, int, bool]
async def create_ytaudios(ctx: Context, infos: list[info_tuple]) -> AsyncIterable[YTAudio]:
for audio in await asyncio.gather(
*[
create_ytaudio(ctx, info, effects, already_read, tor)
for
info, effects, already_read, tor
in
infos
]
):
yield audio
presets: dict[str, str] = {
'cursed': 'aeval=val(0)*2*sin(440*t)+val(1)*2*cos(622*t)|val(1)*2*sin(622*t)+val(0)*2*cos(440*t)',
'bassboost': 'bass=g=10',
'bassbooboost': 'bass=g=30',
'nightcore': 'asetrate=67882',
'daycore': 'atempo=.9,aecho=1.0:0.5:20:0.5',
'пришествие анимешне': 'bass=g=15,asetrate=67882,bass=g=15',
'difference': 'aeval=val(0)-val(1)|val(1)-val(0)',
'mono': 'aeval=.5*val(0)+.5*val(1)|.5*val(1)+.5*val(0)',
}
allowed_presets = ['bassboost', 'bassbooboost', 'nightcore', 'daycore', 'mono']
allowed_effects = {'', *(presets[key] for key in allowed_presets)}
async def entries_effects_for_args(args: list[str]) -> AsyncIterable[info_tuple]:
while args:
match args:
case [url, '-', effects, *args]:
pass
case [url, '+', preset, *args]:
effects = presets[preset]
case [url, *args]:
effects = None
case _:
raise RuntimeError
seconds = 0
match args:
case [h, m, s, *args] if h.isdecimal() and m.isdecimal() and s.isdecimal():
seconds = 3600 * int(h) + 60 * int(m) + int(s)
case [m, s, *args] if m.isdecimal() and s.isdecimal():
seconds = 60 * int(m) + int(s)
case [s, *args] if s.isdecimal():
seconds = int(s)
case [*args]:
pass
already_read = round(seconds / sparq(options_for_effects(effects)))
tor = False
match args:
case ['tor', *args]:
tor = True
case [*args]:
pass
async for info in entries_for_url(url, tor):
yield info, effects, already_read, tor
async def yt_audios(ctx: Context, args: list[str]) -> AsyncIterable[YTAudio]:
tuples: list[info_tuple] = []
async for info, effects, already_read, tor in entries_effects_for_args(args):
tuples.append((info, effects, already_read, tor))
if len(tuples) >= 5:
async for audio in create_ytaudios(ctx, tuples):
yield audio
tuples.clear()
async for audio in create_ytaudios(ctx, tuples):
yield audio
mainasrcs: dict[discord.Guild, MainAudio] = {}
async def catch(ctx: Context, args: list[str], reply: str, *catched: (Iterable[str] | str)):
catched = {(case,) if isinstance(case, str) else tuple(case) for case in catched}
if tuple(args) in catched:
await ctx.reply(reply.strip())
raise Implicit
@at('commands', '/')
@at('commands', 'play')
async def play(ctx: Context, args: list[str]) -> None:
await catch(
ctx, args,
f'''
`play ...args`
`play url [- effects] ...args`
`play url [+ preset] ...args`
presets: {shlex.join(allowed_presets)}
''',
(), 'help'
)
async with lock_for(ctx.guild, 'not in a guild'):
queue = await queue_for(ctx, create=True)
async for audio in yt_audios(ctx, args):
queue.append(audio)
await ctx.reply('done')
async def raw_vc_for(ctx: Context) -> discord.VoiceClient:
if ctx.guild is None:
raise Explicit('not in a guild')
vc: discord.VoiceProtocol = ctx.guild.voice_client
if vc is None or isinstance(vc, discord.VoiceClient) and not vc.is_connected():
vs: discord.VoiceState = ctx.member.voice
if vs is None:
raise Explicit('not connected')
vch: discord.VoiceChannel = vs.channel
if vch is None:
raise Explicit('not connected')
try:
vc: discord.VoiceProtocol = await vch.connect()
except discord.ClientException:
await ctx.guild.fetch_channels()
raise Explicit('try again later')
assert isinstance(vc, discord.VoiceClient)
return vc
async def main_for_raw_vc(vc: discord.VoiceClient, *, create: bool) -> MainAudio:
if vc.guild in mainasrcs:
source = mainasrcs[vc.guild]
else:
if create:
source = mainasrcs.setdefault(
vc.guild,
MainAudio(await QueueAudio.create(vc.guild), volume=volume_db.get(vc.guild.id, 0.2))
)
else:
raise Explicit('not playing')
if vc.source != source or create and not vc.is_playing():
vc.play(source)
return source
async def vc_main_for(ctx: Context, *, create: bool) -> tuple[discord.VoiceClient, MainAudio]:
vc = await raw_vc_for(ctx)
return vc, await main_for_raw_vc(vc, create=create)
async def vc_for(ctx: Context, *, create: bool) -> discord.VoiceClient:
vc, source = await vc_main_for(ctx, create=create)
return vc
async def main_for(ctx: Context, *, create: bool) -> MainAudio:
vc, source = await vc_main_for(ctx, create=create)
return source
async def queue_for(ctx: Context, *, create: bool) -> QueueAudio:
return (await main_for(ctx, create=create)).queue
@at('commands', 'skip')
async def skip(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`skip [first] [last]`
''', 'help')
match args:
case []:
queue = await queue_for(ctx, create=False)
queue.skip_at(0, ctx.member)
case [pos] if pos.isdecimal():
pos = int(pos)
queue = await queue_for(ctx, create=False)
queue.skip_at(pos, ctx.member)
case [pos0, pos1] if pos0.isdecimal() and pos1.isdecimal():
pos0, pos1 = int(pos0), int(pos1)
queue = await queue_for(ctx, create=False)
for i in range(pos0, pos1 + 1):
if not queue.skip_at(pos0, ctx.member):
pos0 += 1
case _:
raise Explicit('misformatted')
await ctx.reply('done')
@at('commands', 'to')
async def skip_to(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`to [[h]] [m] s`
''', 'help')
match args:
case [h, m, s] if h.isdecimal() and m.isdecimal() and s.isdecimal():
seconds = 3600 * int(h) + 60 * int(m) + int(s)
case [m, s] if m.isdecimal() and s.isdecimal():
seconds = 60 * int(m) + int(s)
case [s] if s.isdecimal():
seconds = int(s)
case _:
raise Explicit('misformatted')
queue = await queue_for(ctx, create=False)
queue.queue[0].set_seconds(seconds)
@at('commands', 'effects')
async def effects_(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`effects - effects`
`effects + preset`
''', 'help')
match args:
case ['-', effects]:
pass
case ['+', preset]:
effects = presets[preset]
case _:
raise Explicit('misformatted')
assert_admin(ctx.member)
queue = await queue_for(ctx, create=False)
yta = queue.queue[0]
seconds = yta.source_seconds()
yta.options = options_for_effects(effects)
yta.set_seconds(seconds)
@at('commands', 'queue')
async def queue_(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`queue`
`queue clear`
`queue resume`
`queue pause`
''', 'help')
match args:
case []:
await ctx.long((await (await queue_for(ctx, create=False)).format()).strip() or 'no queue')
case ['clear']:
(await queue_for(ctx, create=False)).clear(ctx.member)
await ctx.reply('done')
case ['resume']:
async with lock_for(ctx.guild, 'not in a guild'):
await queue_for(ctx, create=True)
await ctx.reply('done')
case ['pause']:
async with lock_for(ctx.guild, 'not in a guild'):
vc = await vc_for(ctx, create=True)
vc.pause()
await ctx.reply('done')
case _:
raise Explicit('misformatted')
@at('commands', 'swap')
async def swap(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`swap a b`
''', 'help')
match args:
case [a, b] if a.isdecimal() and b.isdecimal():
a, b = int(a), int(b)
(await queue_for(ctx, create=False)).swap(ctx.member, a, b)
case _:
raise Explicit('misformatted')
@at('commands', 'move')
async def move(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`move a b`
''', 'help')
match args:
case [a, b] if a.isdecimal() and b.isdecimal():
a, b = int(a), int(b)
(await queue_for(ctx, create=False)).move(ctx.member, a, b)
case _:
raise Explicit('misformatted')
@at('commands', 'volume')
async def volume_(ctx: Context, args: list[str]) -> None:
await catch(ctx, args, '''
`volume volume`
''', 'help')
match args:
case [volume]:
volume = float(volume)
await (await main_for(ctx, create=False)).set(volume, ctx.member)
case _:
raise Explicit('misformatted')
@at('commands', 'pause')
async def pause(ctx: Context, _args: list[str]) -> None:
vc = await vc_for(ctx, create=False)
vc.pause()
@at('commands', 'resume')
async def resume(ctx: Context, _args: list[str]) -> None:
vc = await vc_for(ctx, create=False)
vc.resume()
@client.event
async def on_message(message: discord.Message) -> None:
await handle_content(message, message.content, prefix)
async def save_queues():
for mainasrc in list(mainasrcs.values()):
await asyncio.sleep(0.01)
await mainasrc.queue.save()
async def save_vcs():
if vcs_restored:
vcs = []
vc: discord.VoiceClient
for vc in list(client.voice_clients):
await asyncio.sleep(0.01)
if vc.is_playing():
if vc.guild is not None and vc.channel is not None:
vcs.append((vc.guild.id, vc.channel.id, vc.is_paused()))
queue_db.set_nowait('vcs', vcs)
async def save_commit():
await queue_db.set('commit', time.time())
async def save_job():
while True:
await asyncio.sleep(1)
with Benchmark('SVQ'):
await save_queues()
with Benchmark('SVV'):
await save_vcs()
with Benchmark('SVC'):
await save_commit()
async def main():
async with volume_db, queue_db, cache_db:
await client.login(token)
loop.create_task(save_job())
if os.getenv('v6monitor'):
loop.create_task(monitor())
subprocess.Popen('tor')
await client.connect()
if __name__ == '__main__':
serve(main(), client, loop)