123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469 |
- """Main bot page"""
- # Friendly Telegram (telegram userbot)
- # Copyright (C) 2018-2021 The Authors
- # This program is free software: you can redistribute it and/or modify
- # it under the terms of the GNU Affero General Public License as published by
- # the Free Software Foundation, either version 3 of the License, or
- # (at your option) any later version.
- # This program is distributed in the hope that it will be useful,
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- # GNU Affero General Public License for more details.
- # You should have received a copy of the GNU Affero General Public License
- # along with this program. If not, see <https://www.gnu.org/licenses/>.
- # █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
- # █▀█ █ █ █ █▀█ █▀▄ █
- # © Copyright 2022
- # https://t.me/hikariatama
- #
- # 🔒 Licensed under the GNU AGPLv3
- # 🌐 https://www.gnu.org/licenses/agpl-3.0.html
- import asyncio
- import collections
- import os
- import string
- from aiohttp import web
- import aiohttp_jinja2
- import atexit
- import functools
- import logging
- import sys
- import re
- import requests
- import time
- from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
- import telethon
- from telethon.errors.rpcerrorlist import YouBlockedUserError, FloodWaitError
- from telethon.tl.functions.contacts import UnblockRequest
- from .. import utils, main, database, heroku
- from ..tl_cache import CustomTelegramClient
- DATA_DIR = (
- os.path.normpath(os.path.join(utils.get_base_dir(), ".."))
- if "OKTETO" not in os.environ and "DOCKER" not in os.environ
- else "/data"
- )
- def restart(*argv):
- os.execl(
- sys.executable,
- sys.executable,
- "-m",
- os.path.relpath(utils.get_base_dir()),
- *argv,
- )
- class Web:
- sign_in_clients = {}
- _pending_client = None
- _sessions = []
- _ratelimit = {}
- def __init__(self, **kwargs):
- self.api_token = kwargs.pop("api_token")
- self.data_root = kwargs.pop("data_root")
- self.connection = kwargs.pop("connection")
- self.proxy = kwargs.pop("proxy")
- super().__init__(**kwargs)
- self.app.router.add_get("/", self.root)
- self.app.router.add_put("/setApi", self.set_tg_api)
- self.app.router.add_post("/sendTgCode", 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("/okteto", self.okteto)
- self.app.router.add_post("/tgCode", self.tg_code)
- self.app.router.add_post("/finishLogin", self.finish_login)
- self.app.router.add_post("/custom_bot", self.custom_bot)
- self.api_set = asyncio.Event()
- self.clients_set = asyncio.Event()
- @aiohttp_jinja2.template("root.jinja2")
- async def root(self, _):
- return {
- "skip_creds": self.api_token is not None,
- "tg_done": bool(self.client_data),
- "okteto": "OKTETO" in os.environ,
- "lavhost": "LAVHOST" in os.environ,
- "heroku": "DYNO" in os.environ,
- }
- async def check_session(self, request):
- 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) -> bool:
- return (
- request.cookies.get("session", None) in self._sessions
- if main.hikka.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):
- 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("hikka.inline", "custom_bot", text)
- return web.Response(body="OK")
- async def set_tg_api(self, request):
- 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",
- )
- if "DYNO" not in os.environ:
- # On Heroku it'll be saved later
- with open(
- os.path.join(self.data_root or DATA_DIR, "api_token.txt"),
- "w",
- ) as f:
- f.write(api_id + "\n" + 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 send_tg_code(self, request):
- if not self._check_session(request):
- return web.Response(status=401, body="Authorization required")
- text = await request.text()
- phone = telethon.utils.parse_phone(text)
- if not phone:
- return web.Response(status=400, body="Invalid phone number")
- client = CustomTelegramClient(
- telethon.sessions.MemorySession(),
- self.api_token.ID,
- self.api_token.HASH,
- connection=self.connection,
- proxy=self.proxy,
- connection_retries=None,
- device_model="Hikka",
- )
- self._pending_client = client
- await client.connect()
- try:
- await client.send_code_request(phone)
- except FloodWaitError as e:
- return web.Response(
- status=429,
- body=(
- f"You got FloodWait of {e.seconds} seconds. Wait the specified"
- " amount of time and try again."
- ),
- )
- return web.Response(body="ok")
- async def okteto(self, request):
- if main.get_config_key("okteto_uri"):
- return web.Response(status=418)
- text = await request.text()
- main.save_config_key("okteto_uri", text)
- return web.Response(body="URI_SAVED")
- async def tg_code(self, request):
- 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 = telethon.utils.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 telethon.errors.SessionPasswordNeededError:
- return web.Response(
- status=401,
- body="2FA Password required",
- ) # Requires 2FA login
- except telethon.errors.PhoneCodeExpiredError:
- return web.Response(status=404, body="Code expired")
- except telethon.errors.PhoneCodeInvalidError:
- return web.Response(status=403, body="Invalid code")
- except telethon.errors.FloodWaitError as e:
- return web.Response(
- status=421,
- body=(
- f"You got FloodWait of {e.seconds} seconds. Wait the specified"
- " amount of time and try again."
- ),
- )
- else:
- try:
- await self._pending_client.sign_in(phone, password=password)
- except telethon.errors.PasswordHashInvalidError:
- return web.Response(
- status=403,
- body="Invalid 2FA password",
- ) # Invalid 2FA password
- except telethon.errors.FloodWaitError as e:
- return web.Response(
- status=421,
- body=(
- f"You got FloodWait of {e.seconds} seconds. Wait the specified"
- " amount of time and try again."
- ),
- )
- # 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
- # To prevent Heroku from restarting too soon, we'll do it after setting bot
- if "DYNO" not in os.environ:
- await main.hikka.save_client_session(self._pending_client)
- return web.Response()
- async def finish_login(self, request):
- if not self._check_session(request):
- return web.Response(status=401)
- if not self._pending_client:
- return web.Response(status=400)
- if "DYNO" in os.environ:
- app, config = heroku.get_app()
- config["api_id"] = self.api_token.ID
- config["api_hash"] = self.api_token.HASH
- await main.hikka.save_client_session(
- self._pending_client,
- heroku_config=config,
- heroku_app=app,
- )
- # We don't care what happens next, bc Heroku will restart anyway
- return
- first_session = not bool(main.hikka.clients)
- # Client is ready to pass in to dispatcher
- main.hikka.clients = list(set(main.hikka.clients + [self._pending_client]))
- self._pending_client = None
- self.clients_set.set()
- if not first_session:
- atexit.register(functools.partial(restart, *sys.argv[1:]))
- handler = logging.getLogger().handlers[0]
- handler.setLevel(logging.CRITICAL)
- for client in main.hikka.clients:
- await client.disconnect()
- sys.exit(0)
- return web.Response()
- async def web_auth(self, request):
- if self._check_session(request):
- return web.Response(body=request.cookies.get("session", "unauthorized"))
- token = utils.rand(8)
- markup = InlineKeyboardMarkup()
- markup.add(
- InlineKeyboardButton(
- "🔓 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"<i>{utils.get_lang_flag(res['country_code'])} {res['country_name']} {res['region_name']} {res['city']} {res['zip_code']}</i>"
- ]
- except Exception:
- pass
- cities = (
- ("<b>🏢 Possible cities:</b>\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(
- user[1].tg_id,
- "🌘🔐 <b>Click button below to confirm web application"
- f" ops</b>\n\n<b>Client IP</b>: {ips}\n{cities}\n<i>If you did not"
- " request any codes, simply ignore this message</i>",
- 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"hikka_{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.hikka.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)
|