"""Main bot page"""
# ÂŠī¸ Dan Gazizullin, 2021-2023
# This file is a part of Heroku Userbot
# đ https://github.com/hikariatama/Heroku
# You can redistribute it and/or modify it under the terms of the GNU AGPLv3
# đ https://www.gnu.org/licenses/agpl-3.0.html
# ÂŠī¸ Codrago, 2024-2025
# This file is a part of Heroku Userbot
# đ https://github.com/coddrago/Heroku
# You can redistribute it and/or modify it under the terms of the GNU AGPLv3
# đ https://www.gnu.org/licenses/agpl-3.0.html
import asyncio
import collections
import functools
import logging
import os
import re
import string
import time
import aiohttp_jinja2
import requests
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiohttp import web
from herokutl.errors import (
FloodWaitError,
PasswordHashInvalidError,
PhoneCodeExpiredError,
PhoneCodeInvalidError,
SessionPasswordNeededError,
YouBlockedUserError,
)
from herokutl.password import compute_check
from herokutl.sessions import MemorySession
from herokutl.tl.functions.account import GetPasswordRequest
from herokutl.tl.functions.auth import CheckPasswordRequest
from herokutl.tl.functions.contacts import UnblockRequest
from herokutl.utils import parse_phone
from .. import database, main, utils
from .._internal import restart
from ..tl_cache import CustomTelegramClient
from ..version import __version__
DATA_DIR = (
"/data"
if "DOCKER" in os.environ
else os.path.normpath(os.path.join(utils.get_base_dir(), ".."))
)
logger = logging.getLogger(__name__)
class Web:
def __init__(self, **kwargs):
self.sign_in_clients = {}
self._pending_client = None
self._qr_login = None
self._qr_task = None
self._2fa_needed = None
self._sessions = []
self._ratelimit = {}
self.api_token = kwargs.pop("api_token")
self.data_root = kwargs.pop("data_root")
self.connection = kwargs.pop("connection")
self.proxy = kwargs.pop("proxy")
self.app.router.add_get("/", self.root)
self.app.router.add_put("/set_api", self.set_tg_api)
self.app.router.add_post("/send_tg_code", self.send_tg_code)
self.app.router.add_post("/check_session", self.check_session)
self.app.router.add_post("/web_auth", self.web_auth)
self.app.router.add_post("/tg_code", self.tg_code)
self.app.router.add_post("/finish_login", self.finish_login)
self.app.router.add_post("/custom_bot", self.custom_bot)
self.app.router.add_post("/init_qr_login", self.init_qr_login)
self.app.router.add_post("/get_qr_url", self.get_qr_url)
self.app.router.add_post("/qr_2fa", self.qr_2fa)
self.app.router.add_post("/can_add", self.can_add)
self.api_set = asyncio.Event()
self.clients_set = asyncio.Event()
async def schedule_restart(self,One=None):
# Yeah-yeah, ikr, but it's the only way to restart
await asyncio.sleep(1)
await main.heroku.save_client_session(self._pending_client, delay_restart=False)
restart()
@property
def _platform_emoji(self) -> str:
return {
"vds": "https://github.com/hikariatama/assets/raw/master/waning-crescent-moon_1f318.png",
"lavhost": "https://github.com/hikariatama/assets/raw/master/victory-hand_270c-fe0f.png",
"termux": "https://github.com/hikariatama/assets/raw/master/smiling-face-with-sunglasses_1f60e.png",
"docker": "https://github.com/hikariatama/assets/raw/master/spouting-whale_1f433.png",
}[(
"lavhost"
if "LAVHOST" in os.environ
else (
"termux"
if "com.termux" in os.environ.get("PREFIX", "")
else "docker" if "DOCKER" in os.environ else "vds"
)
)]
@aiohttp_jinja2.template("root.jinja2")
async def root(self, _):
return {
"skip_creds": self.api_token is not None,
"tg_done": bool(self.client_data),
"lavhost": "LAVHOST" in os.environ,
"platform_emoji": self._platform_emoji,
}
async def check_session(self, request: web.Request) -> web.Response:
return web.Response(body=("1" if self._check_session(request) else "0"))
def wait_for_api_token_setup(self):
return self.api_set.wait()
def wait_for_clients_setup(self):
return self.clients_set.wait()
def _check_session(self, request: web.Request) -> bool:
return (
request.cookies.get("session", None) in self._sessions
if main.heroku.clients
else True
)
async def _check_bot(
self,
client: CustomTelegramClient,
username: str,
) -> bool:
async with client.conversation("@BotFather", exclusive=False) as conv:
try:
m = await conv.send_message("/token")
except YouBlockedUserError:
await client(UnblockRequest(id="@BotFather"))
m = await conv.send_message("/token")
r = await conv.get_response()
await m.delete()
await r.delete()
if not hasattr(r, "reply_markup") or not hasattr(r.reply_markup, "rows"):
return False
for row in r.reply_markup.rows:
for button in row.buttons:
if username != button.text.strip("@"):
continue
m = await conv.send_message("/cancel")
r = await conv.get_response()
await m.delete()
await r.delete()
return True
async def custom_bot(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401)
text = await request.text()
client = self._pending_client
db = database.Database(client)
await db.init()
text = text.strip("@")
if any(
litera not in (string.ascii_letters + string.digits + "_")
for litera in text
) or not text.lower().endswith("bot"):
return web.Response(body="OCCUPIED")
try:
await client.get_entity(f"@{text}")
except ValueError:
pass
else:
if not await self._check_bot(client, text):
return web.Response(body="OCCUPIED")
db.set("heroku.inline", "custom_bot", text)
return web.Response(body="OK")
async def set_tg_api(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401, body="Authorization required")
text = await request.text()
if len(text) < 36:
return web.Response(
status=400,
body="API ID and HASH pair has invalid length",
)
api_id = text[32:]
api_hash = text[:32]
if any(c not in string.hexdigits for c in api_hash) or any(
c not in string.digits for c in api_id
):
return web.Response(
status=400,
body="You specified invalid API ID and/or API HASH",
)
main.save_config_key("api_id", int(api_id))
main.save_config_key("api_hash", api_hash)
self.api_token = collections.namedtuple("api_token", ("ID", "HASH"))(
api_id,
api_hash,
)
self.api_set.set()
return web.Response(body="ok")
async def _qr_login_poll(self):
logged_in = False
self._2fa_needed = False
logger.debug("Waiting for QR login to complete")
while not logged_in:
try:
logged_in = await self._qr_login.wait(10)
except asyncio.TimeoutError:
logger.debug("Recreating QR login")
try:
await self._qr_login.recreate()
except SessionPasswordNeededError:
self._2fa_needed = True
return
except SessionPasswordNeededError:
self._2fa_needed = True
break
logger.debug("QR login completed. 2FA needed: %s", self._2fa_needed)
self._qr_login = True
async def init_qr_login(self, request: web.Request) -> web.Response:
if self.client_data and "LAVHOST" in os.environ:
return web.Response(status=403, body="Forbidden by LavHost EULA")
if not self._check_session(request):
return web.Response(status=401)
if self._pending_client is not None:
self._pending_client = None
self._qr_login = None
if self._qr_task:
self._qr_task.cancel()
self._qr_task = None
self._2fa_needed = False
logger.debug("QR login cancelled, new session created")
client = self._get_client()
self._pending_client = client
await client.connect()
self._qr_login = await client.qr_login()
self._qr_task = asyncio.ensure_future(self._qr_login_poll())
return web.Response(body=self._qr_login.url)
async def get_qr_url(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401)
if self._qr_login is True:
if self._2fa_needed:
return web.Response(status=403, body="2FA")
asyncio.ensure_future(self.schedule_restart(self))
# self.schedule_restart()
return web.Response(status=200, body="SUCCESS")
if self._qr_login is None:
await self.init_qr_login(request)
if self._qr_login is None:
return web.Response(
status=500,
body="Internal Server Error: Unable to initialize QR login",
)
return web.Response(status=201, body=self._qr_login.url)
def _get_client(self) -> CustomTelegramClient:
return CustomTelegramClient(
MemorySession(),
self.api_token.ID,
self.api_token.HASH,
connection=self.connection,
proxy=self.proxy,
connection_retries=None,
device_model=main.get_app_name(),
system_version="Windows 10",
app_version=".".join(map(str, __version__)) + " x64",
lang_code="en",
system_lang_code="en-US",
)
async def can_add(self, request: web.Request) -> web.Response:
if self.client_data and "LAVHOST" in os.environ:
return web.Response(status=403, body="Forbidden by host EULA")
return web.Response(status=200, body="Yes")
async def send_tg_code(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401, body="Authorization required")
if self.client_data and "LAVHOST" in os.environ:
return web.Response(status=403, body="Forbidden by host EULA")
if self._pending_client:
return web.Response(status=208, body="Already pending")
text = await request.text()
phone = parse_phone(text)
if not phone:
return web.Response(status=400, body="Invalid phone number")
client = self._get_client()
self._pending_client = client
await client.connect()
try:
await client.send_code_request(phone)
except FloodWaitError as e:
return web.Response(status=429, body=self._render_fw_error(e))
return web.Response(body="ok")
@staticmethod
def _render_fw_error(e: FloodWaitError) -> str:
seconds, minutes, hours = (
e.seconds % 3600 % 60,
e.seconds % 3600 // 60,
e.seconds // 3600,
)
seconds, minutes, hours = (
f"{seconds} second(-s)",
f"{minutes} minute(-s) " if minutes else "",
f"{hours} hour(-s) " if hours else "",
)
return (
f"You got FloodWait for {hours}{minutes}{seconds}. Wait the specified"
" amount of time and try again."
)
async def qr_2fa(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401)
text = await request.text()
logger.debug("2FA code received for QR login: %s", text)
try:
await self._pending_client._on_login(
(
await self._pending_client(
CheckPasswordRequest(
compute_check(
await self._pending_client(GetPasswordRequest()),
text.strip(),
)
)
)
).user
)
except PasswordHashInvalidError:
logger.debug("Invalid 2FA code")
return web.Response(
status=403,
body="Invalid 2FA password",
)
except FloodWaitError as e:
logger.debug("FloodWait for 2FA code")
return web.Response(
status=421,
body=(self._render_fw_error(e)),
)
logger.debug("2FA code accepted, logging in")
asyncio.ensure_future(self.schedule_restart(self))
# self.schedule_restart()
return web.Response(status=200, body="SUCCESS")
async def tg_code(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401)
text = await request.text()
if len(text) < 6:
return web.Response(status=400)
split = text.split("\n", 2)
if len(split) not in (2, 3):
return web.Response(status=400)
code = split[0]
phone = parse_phone(split[1])
password = split[2]
if (
(len(code) != 5 and not password)
or any(c not in string.digits for c in code)
or not phone
):
return web.Response(status=400)
if not password:
try:
await self._pending_client.sign_in(phone, code=code)
except SessionPasswordNeededError:
return web.Response(
status=401,
body="2FA Password required",
)
except PhoneCodeExpiredError:
return web.Response(status=404, body="Code expired")
except PhoneCodeInvalidError:
return web.Response(status=403, body="Invalid code")
except FloodWaitError as e:
return web.Response(
status=421,
body=(self._render_fw_error(e)),
)
else:
try:
await self._pending_client.sign_in(phone, password=password)
except PasswordHashInvalidError:
return web.Response(
status=403,
body="Invalid 2FA password",
)
except FloodWaitError as e:
return web.Response(
status=421,
body=(self._render_fw_error(e)),
)
asyncio.ensure_future(self.schedule_restart(self))
# self.schedule_restart()
return web.Response(status=200, body="SUCCESS")
async def finish_login(self, request: web.Request) -> web.Response:
if not self._check_session(request):
return web.Response(status=401)
if not self._pending_client:
return web.Response(status=400)
first_session = not bool(main.heroku.clients)
# Client is ready to pass in to dispatcher
main.heroku.clients = list(set(main.heroku.clients + [self._pending_client]))
self._pending_client = None
self.clients_set.set()
if not first_session:
restart()
return web.Response()
async def web_auth(self, request: web.Request) -> web.Response:
if self._check_session(request):
return web.Response(body=request.cookies.get("session", "unauthorized"))
token = utils.rand(8)
markup = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="đ Authorize user",
callback_data=f"authorize_web_{token}",
)
]
]
)
ips = request.headers.get("X-FORWARDED-FOR", None) or request.remote
cities = []
for ip in re.findall(r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}", ips):
if ip not in self._ratelimit:
self._ratelimit[ip] = []
if (
len(
list(
filter(lambda x: time.time() - x < 3 * 60, self._ratelimit[ip])
)
)
>= 3
):
return web.Response(status=429)
self._ratelimit[ip] = list(
filter(lambda x: time.time() - x < 3 * 60, self._ratelimit[ip])
)
self._ratelimit[ip] += [time.time()]
try:
res = (
await utils.run_sync(
requests.get,
f"https://freegeoip.app/json/{ip}",
)
).json()
cities += [
f"{utils.get_lang_flag(res['country_code'])} {res['country_name']} {res['region_name']} {res['city']} {res['zip_code']}"
]
except Exception:
pass
cities = (
("đĸ Possible cities:\n\n" + "\n".join(cities) + "\n")
if cities
else ""
)
ops = []
for user in self.client_data.values():
try:
bot = user[0].inline.bot
msg = await bot.send_message(
chat_id=user[1].tg_id,
text=(
"đĒđ Click button below to confirm web application"
f" ops\n\nClient IP: {ips}\n{cities}\nIf you did"
" not request any codes, simply ignore this message"
),
disable_web_page_preview=True,
reply_markup=markup,
)
ops += [
functools.partial(
bot.delete_message,
chat_id=msg.chat.id,
message_id=msg.message_id,
)
]
except Exception:
pass
session = f"heroku_{utils.rand(16)}"
if not ops:
# If no auth message was sent, just leave it empty
# probably, request was a bug and user doesn't have
# inline bot or did not authorize any sessions
return web.Response(body=session)
if not await main.heroku.wait_for_web_auth(token):
for op in ops:
await op()
return web.Response(body="TIMEOUT")
for op in ops:
await op()
self._sessions += [session]
return web.Response(body=session)