diff --git a/.dockerignore b/.dockerignore index 3432d64..b7e85c8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -133,6 +133,8 @@ dmypy.json *.pyc hikka/api_token.py hikka/loaded_modules +loaded_modules +hikka/debug_modules hikka*.session* database-*.json *.swp @@ -154,4 +156,11 @@ config.json *.jpg *.jpeg *.webp -*.webm \ No newline at end of file +*.webm +*.tgs +*.mp4 +*.mp3 +*.ogg +*.m4a +*.mp3 +*.avi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 438cfd2..54114c5 100755 --- a/.gitignore +++ b/.gitignore @@ -159,4 +159,7 @@ config.json *.tgs *.mp4 *.mp3 -*.ogg \ No newline at end of file +*.ogg +*.m4a +*.mp3 +*.avi \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 41df5a9..c6fac9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,37 @@ -FROM python:3.8 +# Template +FROM python:3.8-slim-buster as main + +# Add files ADD . / -ENV OKTETO=true -RUN pip install -r requirements.txt -RUN pip install -r optional_requirements.txt -RUN apt update && apt install ffmpeg libavcodec-dev libavutil-dev libavformat-dev libswscale-dev libavdevice-dev -y + +# Tell Hikka, that it's running docker +# Currently it's used only in .info badge +ENV DOCKER=true + +# Suppress weird gitpython error +ENV GIT_PYTHON_REFRESH=quiet + +# Do not user pip cache dir +ENV PIP_NO_CACHE_DIR=1 + +# Install mandatory pip requirements +RUN pip install \ + --no-warn-script-location \ + --no-cache-dir \ + -r requirements.txt + +# Install mandatory apt packages +RUN apt update && apt install \ + libcairo2 git -y --no-install-recommends + +# Clean the cache +RUN rm -rf /var/lib/apt/lists /var/cache/apt/archives /tmp/* + +# Expose IP address EXPOSE 8080 + +# Create data dir RUN mkdir /data -CMD ["python3", "-m", "hikka"] \ No newline at end of file + +# Run Hikka +CMD ["python3", "-m", "hikka"] diff --git a/Okteto b/Okteto new file mode 100644 index 0000000..fca00ee --- /dev/null +++ b/Okteto @@ -0,0 +1,45 @@ +# Template +FROM python:3.8-slim-buster as main + +# Add files +ADD . / + +# Tell Hikka, that it's running on Okteto +ENV OKTETO=true + +# Suppress weird gitpython error +ENV GIT_PYTHON_REFRESH=quiet + +# Do not user pip cache dir +ENV PIP_NO_CACHE_DIR=1 + +# Install mandatory pip requirements +RUN pip install \ + --no-warn-script-location \ + --no-cache-dir \ + -r requirements.txt + +# Install non-mandatory pip requirements +# As we are running Okteto, we don't care about resources consuming +RUN pip install \ + --no-warn-script-location \ + --no-cache-dir \ + -r optional_requirements.txt + +# Install mandatory apt packages +RUN apt update && apt install \ + libcairo2 git ffmpeg libavcodec-dev \ + libavutil-dev libavformat-dev libswscale-dev \ + libavdevice-dev -y --no-install-recommends + +# Clean the cache +RUN rm -rf /var/lib/apt/lists /var/cache/apt/archives /tmp/* + +# Expose IP address +EXPOSE 8080 + +# Create data dir +RUN mkdir /data + +# Run Hikka +CMD ["python3", "-m", "hikka"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3f08c06 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3" +services: + worker: + build: . + volumes: + - worker:/data + +volumes: + worker: diff --git a/hikka/__main__.py b/hikka/__main__.py index c1d8897..4de930f 100755 --- a/hikka/__main__.py +++ b/hikka/__main__.py @@ -36,6 +36,7 @@ if ( getpass.getuser() == "root" and "--root" not in " ".join(sys.argv) and "OKTETO" not in os.environ + and "DOCKER" not in os.environ ): print("🚫" * 30) print("NEVER EVER RUN USERBOT FROM ROOT") diff --git a/hikka/database.py b/hikka/database.py index 4c12d58..b2531c5 100755 --- a/hikka/database.py +++ b/hikka/database.py @@ -22,7 +22,7 @@ from . import utils DATA_DIR = ( os.path.normpath(os.path.join(utils.get_base_dir(), "..")) - if "OKTETO" not in os.environ + if "OKTETO" not in os.environ and "DOCKER" not in os.environ else "/data" ) diff --git a/hikka/loader.py b/hikka/loader.py index 26644cd..03af2ec 100755 --- a/hikka/loader.py +++ b/hikka/loader.py @@ -82,10 +82,11 @@ class StringLoader(SourceLoader): self.origin = origin def get_code(self, fullname: str) -> str: - if not (source := self.get_source(fullname)): - return None - - return compile(source, self.origin, "exec", dont_inherit=True) + return ( + compile(source, self.origin, "exec", dont_inherit=True) + if (source := self.get_source(fullname)) + else None + ) def get_filename(self, *args, **kwargs) -> str: return self.origin @@ -208,7 +209,7 @@ en_keys = "`qwertyuiop[]asdfghjkl;'zxcvbnm,./~@#$%^&QWERTYUIOP{}ASDFGHJKL:\"|ZXC DATA_DIR = ( os.path.normpath(os.path.join(utils.get_base_dir(), "..")) - if "OKTETO" not in os.environ + if "OKTETO" not in os.environ and "DOCKER" not in os.environ else "/data" ) diff --git a/hikka/main.py b/hikka/main.py index 404ecbb..e0a1285 100755 --- a/hikka/main.py +++ b/hikka/main.py @@ -41,6 +41,8 @@ import sqlite3 import sys from math import ceil from typing import Union +import uvloop +import subprocess from telethon import TelegramClient, events from telethon.errors.rpcerrorlist import ( @@ -68,18 +70,18 @@ except ImportError: else: web_available = True -is_okteto = "OKTETO" in os.environ - omit_log = False DATA_DIR = ( os.path.normpath(os.path.join(utils.get_base_dir(), "..")) - if not is_okteto + if "OKTETO" not in os.environ and "DOCKER" not in os.environ else "/data" ) CONFIG_PATH = os.path.join(DATA_DIR, "config.json") +uvloop.install() + def run_config( db: database.Database, @@ -139,9 +141,11 @@ def gen_port() -> int: """ Generates random free port in case of VDS, and 8080 in case of Okteto + In case of Docker, also return 8080, as it's already + exposed by default :returns: Integer value of generated port """ - if "OKTETO" in os.environ: + if "OKTETO" in os.environ or "DOCKER" in os.environ: return 8080 # But for own server we generate new free port, and assign to it @@ -386,35 +390,36 @@ class Hikka: importlib.invalidate_caches() self._get_api_token() - async def fetch_clients_from_web(self): - """Imports clients from web module""" - for client in self.web.clients: - session = SQLiteSession( - os.path.join( - self.arguments.data_root or DATA_DIR, - f"hikka-+{'x' * (len(client.phone) - 5)}{client.phone[-4:]}-{(await client.get_me()).id}", - ) + async def save_client_session(self, client: TelegramClient): + session = SQLiteSession( + os.path.join( + self.arguments.data_root or DATA_DIR, + f"hikka-+{'x' * (len(client.phone) - 5)}{client.phone[-4:]}-{(await client.get_me()).id}", ) + ) - session.set_dc( - client.session.dc_id, - client.session.server_address, - client.session.port, - ) - session.auth_key = client.session.auth_key - session.save() - client.session = session - # Set db attribute to this client in order to save - # custom bot nickname from web - client.hikka_db = database.Database(client) - await client.hikka_db.init() - - self.clients = list(set(self.clients + self.web.clients)) + session.set_dc( + client.session.dc_id, + client.session.server_address, + client.session.port, + ) + session.auth_key = client.session.auth_key + session.save() + client.session = session + # Set db attribute to this client in order to save + # custom bot nickname from web + client.hikka_db = database.Database(client) + await client.hikka_db.init() def _web_banner(self): """Shows web banner""" print("✅ Web mode ready for configuration") - print(f"🌐 Please visit http://127.0.0.1:{self.web.port}") + ip = ( + "127.0.0.1" + if "DOCKER" not in os.environ + else subprocess.run(["hostname", "-i"], stdout=subprocess.PIPE).stdout + ) + print(f"🌐 Please visit http://{ip}:{self.web.port}") async def wait_for_web_auth(self, token: str): """Waits for web auth confirmation in Telegram""" @@ -449,9 +454,12 @@ class Hikka: return True - def _init_clients(self): - """Reads session from disk and inits them""" - for phone_id, phone in self.phones.items(): + def _init_clients(self) -> bool: + """ + Reads session from disk and inits them + :returns: `True` if at least one client started successfully + """ + for phone_id, phone in self.phones.copy().items(): session = os.path.join( self.arguments.data_root or DATA_DIR, f'hikka{f"-{phone_id}" if phone_id else ""}', @@ -473,7 +481,7 @@ class Hikka: install_entity_caching(client) - self.clients.append(client) + self.clients += [client] except sqlite3.OperationalError: print( "Check that this is the only instance running. " @@ -482,20 +490,22 @@ class Hikka: continue except (TypeError, AuthKeyDuplicatedError): os.remove(os.path.join(DATA_DIR, f"{session}.session")) - self.main() + del self.phones[phone_id] except (ValueError, ApiIdInvalidError): # Bad API hash/ID run_config({}, self.arguments.data_root) - return + return False except PhoneNumberInvalidError: print( "Phone number is incorrect. Use international format (+XX...) " "and don't put spaces in it." ) - continue + del self.phones[phone_id] except InteractiveAuthRequired: print(f"Session {session} was terminated and re-auth is required") - continue + del self.phones[phone_id] + + return bool(self.phones) def _init_loop(self): """Initializes main event loop and starts handler for each client""" @@ -654,11 +664,13 @@ class Hikka: save_config_key("port", self.arguments.port) self._get_token() - if not self.clients and not self.phones and not self._initial_setup(): + if ( + not self.clients # Search for already inited clients + and not self.phones # Search for already added phones / sessions + or not self._init_clients() # Attempt to read sessions from env + ) and not self._initial_setup(): # Otherwise attempt to run setup return - self._init_clients() - self.loop.set_exception_handler( lambda _, x: logging.error( f"Exception on event loop! {x['message']}", diff --git a/hikka/modules/hikka_security.py b/hikka/modules/hikka_security.py index c8d0ba6..d69137e 100755 --- a/hikka/modules/hikka_security.py +++ b/hikka/modules/hikka_security.py @@ -154,10 +154,11 @@ class HikkaSecurityMod(loader.Module): is_inline: bool, ): cmd = ( - self.allmodules.commands[command] - if not is_inline - else self.allmodules.inline_handlers[command] + self.allmodules.inline_handlers[command] + if is_inline + else self.allmodules.commands[command] ) + mask = self._db.get(security.__name__, "masks", {}).get( f"{cmd.__module__}.{cmd.__name__}", getattr(cmd, "security", security.DEFAULT_PERMISSIONS), @@ -188,7 +189,9 @@ class HikkaSecurityMod(loader.Module): await call.edit( self.strings("permissions").format( - self.get_prefix() if not is_inline else f"@{self.inline.bot_username} ", + f"@{self.inline.bot_username} " + if is_inline + else self.get_prefix(), command, ), reply_markup=self._build_markup(cmd, is_inline), @@ -231,7 +234,7 @@ class HikkaSecurityMod(loader.Module): return utils.chunks( [ { - "text": f"{('🚫' if not level else '✅')} {self.strings[group]}", + "text": f"{'✅' if level else '🚫'} {self.strings[group]}", "callback": self.inline__switch_perm, "args": ( command.__name__.rsplit("cmd", maxsplit=1)[0], @@ -243,12 +246,20 @@ class HikkaSecurityMod(loader.Module): for group, level in perms.items() ], 2, - ) + [[{"text": self.strings("close_menu"), "callback": self.inline_close}]] + ) + [ + [ + { + "text": self.strings("close_menu"), + "callback": self.inline_close, + } + ] + ] + return utils.chunks( [ { - "text": f"{('🚫' if not level else '✅')} {self.strings[group]}", + "text": f"{'✅' if level else '🚫'} {self.strings[group]}", "callback": self.inline__switch_perm, "args": ( command.__name__.rsplit("_inline_handler", maxsplit=1)[0], @@ -267,7 +278,7 @@ class HikkaSecurityMod(loader.Module): return utils.chunks( [ { - "text": f"{('🚫' if not level else '✅')} {self.strings[group]}", + "text": f"{'✅' if level else '🚫'} {self.strings[group]}", "callback": self.inline__switch_perm_bm, "args": (group, not level, is_inline), } @@ -286,6 +297,12 @@ class HikkaSecurityMod(loader.Module): def _perms_map(perms: int, is_inline: bool) -> dict: return ( { + "sudo": bool(perms & SUDO), + "support": bool(perms & SUPPORT), + "everyone": bool(perms & EVERYONE), + } + if is_inline + else { "sudo": bool(perms & SUDO), "support": bool(perms & SUPPORT), "group_owner": bool(perms & GROUP_OWNER), @@ -300,12 +317,6 @@ class HikkaSecurityMod(loader.Module): "pm": bool(perms & PM), "everyone": bool(perms & EVERYONE), } - if not is_inline - else { - "sudo": bool(perms & SUDO), - "support": bool(perms & SUPPORT), - "everyone": bool(perms & EVERYONE), - } ) def _get_current_perms( @@ -477,7 +488,7 @@ class HikkaSecurityMod(loader.Module): list(set(self._db.get(main.__name__, "nonickusers", []) + [user.id])), ) - call.edit( + await call.edit( self.strings("user_nn").format( user.id, utils.escape_html(get_display_name(user)), @@ -494,9 +505,10 @@ class HikkaSecurityMod(loader.Module): self._db.set( security.__name__, group, - list(set(self._db.get(security.__name__, group, [])) - set([user.id])), + list(set(self._db.get(security.__name__, group, [])) - {user.id}), ) + m = self.strings(f"{group}_removed").format( user.id, utils.escape_html(get_display_name(user)), diff --git a/hikka/modules/loader.py b/hikka/modules/loader.py index 2a0dc9c..e01fdbc 100755 --- a/hikka/modules/loader.py +++ b/hikka/modules/loader.py @@ -39,7 +39,7 @@ import sys import uuid from collections import ChainMap from importlib.machinery import ModuleSpec -from typing import List, Optional, Union +from typing import Optional, Union from urllib.parse import urlparse import requests @@ -48,7 +48,7 @@ from telethon.tl.types import Message from .. import loader, main, utils from ..compat import geek -from ..inline.types import InlineCall, InlineMessage +from ..inline.types import InlineCall logger = logging.getLogger(__name__) @@ -102,7 +102,7 @@ def get_git_api(url): branch = m.group(2) path_ = m.group(3) - api_url = "https://api.github.com/repos{}/contents".format(m.group(1)) + api_url = f"https://api.github.com/repos{m.group(1)}/contents" if path_ is not None and len(path_) > 0: api_url += path_ diff --git a/hikka/modules/okteto.py b/hikka/modules/okteto.py index 26a4db1..6e8a4ad 100644 --- a/hikka/modules/okteto.py +++ b/hikka/modules/okteto.py @@ -15,7 +15,10 @@ import time from telethon.errors.rpcerrorlist import YouBlockedUserError from telethon.tl.functions.contacts import UnblockRequest -from telethon.tl.functions.messages import GetScheduledHistoryRequest +from telethon.tl.functions.messages import ( + GetScheduledHistoryRequest, + DeleteScheduledMessagesRequest, +) from telethon.tl.types import Message from .. import loader, main, utils @@ -54,8 +57,12 @@ class OktetoMod(loader.Module): if messages: logger.info("Deleting previously scheduled Okteto pinger messages") - for message in messages: - await message.delete() + await client( + DeleteScheduledMessagesRequest( + self._bot, + [message.id for message in messages], + ) + ) raise loader.SelfUnload diff --git a/hikka/utils.py b/hikka/utils.py index a8bf391..5b127e9 100755 --- a/hikka/utils.py +++ b/hikka/utils.py @@ -45,7 +45,6 @@ import git import grapheme import requests import telethon -from aiogram.types import CallbackQuery from telethon.hints import Entity from telethon.tl.custom.message import Message from telethon.tl.functions.account import UpdateNotifySettingsRequest @@ -83,7 +82,7 @@ from telethon.tl.types import ( User, ) -from .inline.types import InlineCall +from .inline.types import InlineCall, InlineMessage FormattingEntity = Union[ MessageEntityUnknown, @@ -170,7 +169,7 @@ def get_entity_id(entity: Entity) -> int: def escape_html(text: str, /) -> str: """Pass all untrusted/potentially corrupt input here""" - return str(text).replace("&", "&").replace("<", "<").replace(">", ">") + return text.replace("&", "&").replace("<", "<").replace(">", ">") def escape_quotes(text: str, /) -> str: @@ -279,16 +278,16 @@ def relocate_entities( async def answer( - message: Union[Message, CallbackQuery, InlineCall], + message: Union[Message, InlineCall, InlineMessage], response: str, **kwargs, -) -> Union[CallbackQuery, Message]: +) -> Union[InlineCall, InlineMessage, Message]: """Use this to give the response to a command""" # Compatibility with FTG\GeekTG if isinstance(message, list) and message: message = message[0] - if isinstance(message, (CallbackQuery, InlineCall)): + if isinstance(message, (InlineMessage, InlineCall)): await message.edit(response) return message @@ -578,16 +577,23 @@ def chunks(_list: Union[list, tuple, set], n: int, /) -> list: def get_named_platform() -> str: """Returns formatted platform name""" - if os.path.isfile("/proc/device-tree/model"): - with open("/proc/device-tree/model") as f: - model = f.read() - return f"🍇 {model}" if model.startswith("Raspberry") else f"❓ {model}" - - is_termux = bool(os.popen('echo $PREFIX | grep -o "com.termux"').read()) + try: + if os.path.isfile("/proc/device-tree/model"): + with open("/proc/device-tree/model") as f: + model = f.read() + return f"🍇 {model}" if model.startswith("Raspberry") else f"❓ {model}" + except Exception: + # In case of weird fs, aka Termux + pass + is_termux = "com.termux" in os.environ.get("PREFIX", "") is_okteto = "OKTETO" in os.environ + is_docker = "DOCKER" in os.environ is_lavhost = "LAVHOST" in os.environ + if is_docker: + return "🐳 Docker" + if is_termux: return "🕶 Termux" @@ -607,7 +613,7 @@ def uptime() -> int: def formatted_uptime() -> str: """Returnes formmated uptime""" - return "{}".format(str(timedelta(seconds=uptime()))) + return f"{str(timedelta(seconds=uptime()))}" def ascii_face() -> str: @@ -616,18 +622,13 @@ def ascii_face() -> str: random.choice( [ "ヽ(๑◠ܫ◠๑)ノ", - "☜(⌒▽⌒)☞", - "/|\\ ^._.^ /|\\", "(◕ᴥ◕ʋ)", "ᕙ(`▽´)ᕗ", - "(☞゚∀゚)☞", "(✿◠‿◠)", "(▰˘◡˘▰)", "(˵ ͡° ͜ʖ ͡°˵)", "ʕっ•ᴥ•ʔっ", "( ͡° ᴥ ͡°)", - "ʕ♥ᴥ♥ʔ", - "\\m/,(> . <)_\\m/", "(๑•́ ヮ •̀๑)", "٩(^‿^)۶", "(っˆڡˆς)", @@ -635,22 +636,49 @@ def ascii_face() -> str: "⊙ω⊙", "٩(^ᴗ^)۶", "(´・ω・)っ由", - "※\\(^o^)/※", - "٩(*❛⊰❛)~❤", "( ͡~ ͜ʖ ͡°)", "✧♡(◕‿◕✿)", "โ๏௰๏ใ ื", "∩。• ᵕ •。∩ ♡", "(♡´౪`♡)", "(◍>◡<◍)⋈。✧♡", - "♥(ˆ⌣ˆԅ)", "╰(✿´⌣`✿)╯♡", "ʕ•ᴥ•ʔ", "ᶘ ◕ᴥ◕ᶅ", "▼・ᴥ・▼", - "【≽ܫ≼】", "ฅ^•ﻌ•^ฅ", "(΄◞ิ౪◟ิ‵)", + "٩(^ᴗ^)۶", + "ᕴーᴥーᕵ", + "ʕ→ᴥ←ʔ", + "ʕᵕᴥᵕʔ", + "ʕᵒᴥᵒʔ", + "ᵔᴥᵔ", + "(✿╹◡╹)", + "(๑→ܫ←)", + "ʕ·ᴥ· ʔ", + "(ノ≧ڡ≦)", + "(≖ᴗ≖✿)", + "(〜^∇^ )〜", + "( ノ・ェ・ )ノ", + "~( ˘▾˘~)", + "(〜^∇^)〜", + "ヽ(^ᴗ^ヽ)", + "(´・ω・`)", + "₍ᐢ•ﻌ•ᐢ₎*・゚。", + "(。・・)_且", + "(=`ω´=)", + "(*•‿•*)", + "(*゚∀゚*)", + "(☉⋆‿⋆☉)", + "ɷ◡ɷ", + "ʘ‿ʘ", + "(。-ω-)ノ", + "( ・ω・)ノ", + "(=゚ω゚)ノ", + "(・ε・`*) …", + "ʕっ•ᴥ•ʔっ", + "(*˘︶˘*)", ] ) ) diff --git a/hikka/version.py b/hikka/version.py index 48121d2..2e6891b 100644 --- a/hikka/version.py +++ b/hikka/version.py @@ -1,2 +1,2 @@ """Represents current userbot version""" -__version__ = (1, 1, 21) +__version__ = (1, 1, 22) diff --git a/hikka/web/root.py b/hikka/web/root.py index dd91d6b..1526904 100644 --- a/hikka/web/root.py +++ b/hikka/web/root.py @@ -50,7 +50,7 @@ from telethon.tl.functions.contacts import UnblockRequest DATA_DIR = ( os.path.normpath(os.path.join(utils.get_base_dir(), "..")) - if "OKTETO" not in os.environ + if "OKTETO" not in os.environ and "DOCKER" not in os.environ else "/data" ) @@ -67,7 +67,7 @@ def restart(*argv): class Web: sign_in_clients = {} - clients = [] + _pending_clients = [] _sessions = [] _ratelimit = {} @@ -108,10 +108,11 @@ class Web: return self.clients_set.wait() def _check_session(self, request) -> bool: - if not main.hikka.clients: - return True - - return request.cookies.get("session", None) in self._sessions + return ( + request.cookies.get("session", None) in self._sessions + if main.hikka.clients + else True + ) async def _check_bot( self, @@ -151,7 +152,7 @@ class Web: return web.Response(status=401) text = await request.text() - client = self.clients[0] + client = self._pending_clients[0] db = database.Database(client) await db.init() @@ -287,18 +288,30 @@ class Web: del self.sign_in_clients[phone] client.phone = f"+{user.phone}" - self.clients.append(client) + + # At this step we don't want `main.hikka` to "know" about our client + # so it doesn't create bot immediately. That's why we only save its session + # in case user closes web early. It will be handled on restart + # If user finishes login further, client will be passed to main + await main.hikka.save_client_session(client) + + # But now it's pending + self._pending_clients += [client] + return web.Response() async def finish_login(self, request): if not self._check_session(request): return web.Response(status=401) - if not self.clients: + if not self._pending_clients: return web.Response(status=400) first_session = not bool(main.hikka.clients) - await main.hikka.fetch_clients_from_web() + + # Client is ready to pass in to dispatcher + main.hikka.clients = list(set(main.hikka.clients + self._pending_clients)) + self._pending_clients = [] self.clients_set.set() diff --git a/okteto-stack.yaml b/okteto-stack.yaml index eb25b84..c0bef4a 100644 --- a/okteto-stack.yaml +++ b/okteto-stack.yaml @@ -2,16 +2,18 @@ name: hikka services: worker: public: true - build: . + build: + context: . + dockerfile: Okteto replicas: 1 ports: - 8080 resources: - cpu: 900m - memory: 2Gi + cpu: "1" + memory: 3Gi volumes: - worker:/data volumes: worker: - size: 1Gi + size: 3Gi diff --git a/requirements.txt b/requirements.txt index ba9ef41..d255a46 100755 --- a/requirements.txt +++ b/requirements.txt @@ -9,5 +9,6 @@ requests==2.27.1 aiogram==2.19 websockets==10.2 grapheme==0.6.0 +uvloop==0.16.0 # Python 3.8+