main.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. """Main script, where all the fun starts"""
  2. # Friendly Telegram (telegram userbot)
  3. # Copyright (C) 2018-2021 The Authors
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. # This program is distributed in the hope that it will be useful,
  9. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. # GNU Affero General Public License for more details.
  12. # You should have received a copy of the GNU Affero General Public License
  13. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  14. # █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
  15. # █▀█ █ █ █ █▀█ █▀▄ █
  16. # © Copyright 2022
  17. # https://t.me/hikariatama
  18. #
  19. # 🔒 Licensed under the GNU AGPLv3
  20. # 🌐 https://www.gnu.org/licenses/agpl-3.0.html
  21. import argparse
  22. import asyncio
  23. import collections
  24. import importlib
  25. import json
  26. import logging
  27. import os
  28. import random
  29. import socket
  30. import sqlite3
  31. import sys
  32. from math import ceil
  33. from typing import Union
  34. from telethon import TelegramClient, events
  35. from telethon.errors.rpcerrorlist import (
  36. ApiIdInvalidError,
  37. AuthKeyDuplicatedError,
  38. PhoneNumberInvalidError,
  39. )
  40. from telethon.network.connection import (
  41. ConnectionTcpFull,
  42. ConnectionTcpMTProxyRandomizedIntermediate,
  43. )
  44. from telethon.sessions import SQLiteSession, StringSession, MemorySession
  45. from . import database, loader, utils, heroku
  46. from .dispatcher import CommandDispatcher
  47. from .translations import Translator
  48. from .version import __version__
  49. from .entity_cache import install_entity_caching
  50. try:
  51. from .web import core
  52. except ImportError:
  53. web_available = False
  54. logging.exception("Unable to import web")
  55. else:
  56. web_available = True
  57. BASE_DIR = (
  58. os.path.normpath(os.path.join(utils.get_base_dir(), ".."))
  59. if "OKTETO" not in os.environ and "DOCKER" not in os.environ
  60. else "/data"
  61. )
  62. CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
  63. try:
  64. import uvloop
  65. uvloop.install()
  66. except Exception:
  67. pass
  68. if "DYNO" in os.environ:
  69. heroku.init()
  70. def run_config(
  71. db: database.Database,
  72. data_root: str,
  73. phone: Union[str, int] = None,
  74. modules: list = None,
  75. ):
  76. """Load configurator.py"""
  77. from . import configurator
  78. return configurator.run(db, data_root, phone, phone is None, modules)
  79. def get_config_key(key: str) -> Union[str, bool]:
  80. """
  81. Parse and return key from config
  82. :param key: Key name in config
  83. :return: Value of config key or `False`, if it doesn't exist
  84. """
  85. try:
  86. with open(CONFIG_PATH, "r") as f:
  87. config = json.load(f)
  88. return config.get(key, False)
  89. except FileNotFoundError:
  90. return False
  91. def save_config_key(key: str, value: str) -> bool:
  92. """
  93. Save `key` with `value` to config
  94. :param key: Key name in config
  95. :param value: Desired value in config
  96. :return: `True` on success, otherwise `False`
  97. """
  98. try:
  99. # Try to open our newly created json config
  100. with open(CONFIG_PATH, "r") as f:
  101. config = json.load(f)
  102. except FileNotFoundError:
  103. # If it doesn't exist, just default config to none
  104. # It won't cause problems, bc after new save
  105. # we will create new one
  106. config = {}
  107. # Assign config value
  108. config[key] = value
  109. # And save config
  110. with open(CONFIG_PATH, "w") as f:
  111. json.dump(config, f, indent=4)
  112. return True
  113. def gen_port() -> int:
  114. """
  115. Generates random free port in case of VDS, and
  116. 8080 in case of Okteto and Heroku
  117. In case of Docker, also return 8080, as it's already
  118. exposed by default
  119. :returns: Integer value of generated port
  120. """
  121. if any(trigger in os.environ for trigger in {"OKTETO", "DOCKER", "DYNO"}):
  122. return 8080
  123. # But for own server we generate new free port, and assign to it
  124. port = get_config_key("port")
  125. if port:
  126. return port
  127. # If we didn't get port from config, generate new one
  128. # First, try to randomly get port
  129. port = random.randint(1024, 65536)
  130. # Then ensure it's free
  131. while not socket.socket(
  132. socket.AF_INET,
  133. socket.SOCK_STREAM,
  134. ).connect_ex(("localhost", port)):
  135. # Until we find the free port, generate new one
  136. port = random.randint(1024, 65536)
  137. return port
  138. def parse_arguments() -> dict:
  139. """
  140. Parses the arguments
  141. :returns: Dictionary with arguments
  142. """
  143. parser = argparse.ArgumentParser()
  144. parser.add_argument(
  145. "--port", dest="port", action="store", default=gen_port(), type=int
  146. )
  147. parser.add_argument("--phone", "-p", action="append")
  148. parser.add_argument("--token", "-t", action="append", dest="tokens")
  149. parser.add_argument("--heroku", action="store_true")
  150. parser.add_argument("--no-nickname", "-nn", dest="no_nickname", action="store_true")
  151. parser.add_argument("--hosting", "-lh", dest="hosting", action="store_true")
  152. parser.add_argument("--web-only", dest="web_only", action="store_true")
  153. parser.add_argument("--no-web", dest="disable_web", action="store_true")
  154. parser.add_argument(
  155. "--data-root",
  156. dest="data_root",
  157. default="",
  158. help="Root path to store session files in",
  159. )
  160. parser.add_argument(
  161. "--no-auth",
  162. dest="no_auth",
  163. action="store_true",
  164. help="Disable authentication and API token input, exitting if needed",
  165. )
  166. parser.add_argument(
  167. "--proxy-host",
  168. dest="proxy_host",
  169. action="store",
  170. help="MTProto proxy host, without port",
  171. )
  172. parser.add_argument(
  173. "--proxy-port",
  174. dest="proxy_port",
  175. action="store",
  176. type=int,
  177. help="MTProto proxy port",
  178. )
  179. parser.add_argument(
  180. "--proxy-secret",
  181. dest="proxy_secret",
  182. action="store",
  183. help="MTProto proxy secret",
  184. )
  185. parser.add_argument(
  186. "--docker-deps-internal",
  187. dest="docker_deps_internal",
  188. action="store_true",
  189. help="This is for internal use only. If you use it, things will go wrong.",
  190. )
  191. parser.add_argument(
  192. "--root",
  193. dest="disable_root_check",
  194. action="store_true",
  195. help="Disable `force_insecure` warning",
  196. )
  197. arguments = parser.parse_args()
  198. logging.debug(arguments)
  199. if sys.platform == "win32":
  200. # Subprocess support; not needed in 3.8 but not harmful
  201. asyncio.set_event_loop(asyncio.ProactorEventLoop())
  202. return arguments
  203. class SuperList(list):
  204. """
  205. Makes able: await self.allclients.send_message("foo", "bar")
  206. """
  207. def __getattribute__(self, attr):
  208. if hasattr(list, attr):
  209. return list.__getattribute__(self, attr)
  210. for obj in self: # TODO: find other way
  211. attribute = getattr(obj, attr)
  212. if callable(attribute):
  213. if asyncio.iscoroutinefunction(attribute):
  214. async def foobar(*args, **kwargs):
  215. return [await getattr(_, attr)(*args, **kwargs) for _ in self]
  216. return foobar
  217. return lambda *args, **kwargs: [
  218. getattr(_, attr)(*args, **kwargs) for _ in self
  219. ]
  220. return [getattr(x, attr) for x in self]
  221. class InteractiveAuthRequired(Exception):
  222. """Is being rased by Telethon, if phone is required"""
  223. def raise_auth():
  224. """Raises `InteractiveAuthRequired`"""
  225. raise InteractiveAuthRequired()
  226. class Hikka:
  227. """Main userbot instance, which can handle multiple clients"""
  228. omit_log = False
  229. def __init__(self):
  230. self.arguments = parse_arguments()
  231. self.loop = asyncio.get_event_loop()
  232. self.clients = SuperList()
  233. self.ready = asyncio.Event()
  234. self._read_sessions()
  235. self._get_api_token()
  236. self._get_proxy()
  237. def _get_proxy(self):
  238. """
  239. Get proxy tuple from --proxy-host, --proxy-port and --proxy-secret
  240. and connection to use (depends on proxy - provided or not)
  241. """
  242. if (
  243. self.arguments.proxy_host is not None
  244. and self.arguments.proxy_port is not None
  245. and self.arguments.proxy_secret is not None
  246. ):
  247. logging.debug(
  248. f"Using proxy: {self.arguments.proxy_host}:{self.arguments.proxy_port}"
  249. )
  250. self.proxy, self.conn = (
  251. (
  252. self.arguments.proxy_host,
  253. self.arguments.proxy_port,
  254. self.arguments.proxy_secret,
  255. ),
  256. ConnectionTcpMTProxyRandomizedIntermediate,
  257. )
  258. return
  259. self.proxy, self.conn = None, ConnectionTcpFull
  260. def _read_sessions(self):
  261. """Gets sessions from environment and data directory"""
  262. self.sessions = [
  263. SQLiteSession(
  264. os.path.join(
  265. self.arguments.data_root or BASE_DIR,
  266. session.rsplit(".session", maxsplit=1)[0],
  267. )
  268. )
  269. for session in filter(
  270. lambda f: f.startswith("hikka-") and f.endswith(".session"),
  271. os.listdir(self.arguments.data_root or BASE_DIR),
  272. )
  273. ]
  274. if os.environ.get("hikka_session"):
  275. self.sessions += [StringSession(os.environ.get("hikka_session"))]
  276. def _get_api_token(self):
  277. """Get API Token from disk or environment"""
  278. api_token_type = collections.namedtuple("api_token", ("ID", "HASH"))
  279. # Try to retrieve credintials from file, or from env vars
  280. try:
  281. with open(
  282. os.path.join(
  283. self.arguments.data_root or BASE_DIR,
  284. "api_token.txt",
  285. )
  286. ) as f:
  287. api_token = api_token_type(*[line.strip() for line in f.readlines()])
  288. except FileNotFoundError:
  289. try:
  290. from . import api_token
  291. except ImportError:
  292. try:
  293. api_token = api_token_type(
  294. os.environ["api_id"],
  295. os.environ["api_hash"],
  296. )
  297. except KeyError:
  298. api_token = None
  299. self.api_token = api_token
  300. def _init_web(self):
  301. """Initialize web"""
  302. if not web_available or getattr(self.arguments, "disable_web", False):
  303. self.web = None
  304. return
  305. self.web = core.Web(
  306. data_root=self.arguments.data_root,
  307. api_token=self.api_token,
  308. proxy=self.proxy,
  309. connection=self.conn,
  310. )
  311. def _get_token(self):
  312. """Reads or waits for user to enter API credentials"""
  313. while self.api_token is None:
  314. if self.arguments.no_auth:
  315. return
  316. if self.web:
  317. self.loop.run_until_complete(
  318. self.web.start(
  319. self.arguments.port,
  320. proxy_pass=True,
  321. )
  322. )
  323. self.loop.run_until_complete(self._web_banner())
  324. self.loop.run_until_complete(self.web.wait_for_api_token_setup())
  325. self.api_token = self.web.api_token
  326. else:
  327. run_config({}, self.arguments.data_root)
  328. importlib.invalidate_caches()
  329. self._get_api_token()
  330. async def save_client_session(
  331. self,
  332. client: TelegramClient,
  333. heroku_config: "ConfigVars" = None, # type: ignore
  334. heroku_app: "App" = None, # type: ignore
  335. ):
  336. if hasattr(client, "_tg_id"):
  337. telegram_id = client._tg_id
  338. else:
  339. telegram_id = (await client.get_me()).id
  340. client._tg_id = telegram_id
  341. client.tg_id = telegram_id
  342. if "DYNO" in os.environ:
  343. session = StringSession()
  344. else:
  345. session = SQLiteSession(
  346. os.path.join(
  347. self.arguments.data_root or BASE_DIR,
  348. f"hikka-{telegram_id}",
  349. )
  350. )
  351. session.set_dc(
  352. client.session.dc_id,
  353. client.session.server_address,
  354. client.session.port,
  355. )
  356. session.auth_key = client.session.auth_key
  357. if "DYNO" not in os.environ:
  358. session.save()
  359. else:
  360. heroku_config["hikka_session"] = session.save()
  361. heroku_app.update_config(heroku_config.to_dict())
  362. # Heroku will restart the app after updating config
  363. client.session = session
  364. # Set db attribute to this client in order to save
  365. # custom bot nickname from web
  366. client.hikka_db = database.Database(client)
  367. await client.hikka_db.init()
  368. async def _web_banner(self):
  369. """Shows web banner"""
  370. logging.info("✅ Web mode ready for configuration")
  371. logging.info(f"🌐 Please visit {self.web.url}")
  372. async def wait_for_web_auth(self, token: str):
  373. """Waits for web auth confirmation in Telegram"""
  374. timeout = 5 * 60
  375. polling_interval = 1
  376. for _ in range(ceil(timeout * polling_interval)):
  377. await asyncio.sleep(polling_interval)
  378. for client in self.clients:
  379. if client.loader.inline.pop_web_auth_token(token):
  380. return True
  381. def _initial_setup(self) -> bool:
  382. """Responsible for first start"""
  383. if self.arguments.no_auth:
  384. return False
  385. if not self.web:
  386. try:
  387. phone = input("Phone: ")
  388. client = TelegramClient(
  389. MemorySession(),
  390. self.api_token.ID,
  391. self.api_token.HASH,
  392. connection=self.conn,
  393. proxy=self.proxy,
  394. connection_retries=None,
  395. device_model="Hikka",
  396. )
  397. client.start(phone)
  398. asyncio.ensure_future(self.save_client_session(client))
  399. self.clients += [client]
  400. except (EOFError, OSError):
  401. raise
  402. return True
  403. if not self.web.running.is_set():
  404. self.loop.run_until_complete(
  405. self.web.start(
  406. self.arguments.port,
  407. proxy_pass=True,
  408. )
  409. )
  410. asyncio.ensure_future(self._web_banner())
  411. self.loop.run_until_complete(self.web.wait_for_clients_setup())
  412. return True
  413. def _init_clients(self) -> bool:
  414. """
  415. Reads session from disk and inits them
  416. :returns: `True` if at least one client started successfully
  417. """
  418. for session in self.sessions.copy():
  419. try:
  420. client = TelegramClient(
  421. session,
  422. self.api_token.ID,
  423. self.api_token.HASH,
  424. connection=self.conn,
  425. proxy=self.proxy,
  426. connection_retries=None,
  427. device_model="Hikka",
  428. )
  429. client.start(phone=raise_auth if self.web else lambda: input("Phone: "))
  430. client.phone = "never gonna give you up"
  431. install_entity_caching(client)
  432. self.clients += [client]
  433. except sqlite3.OperationalError:
  434. logging.error(
  435. "Check that this is the only instance running. "
  436. f"If that doesn't help, delete the file named '{session}'"
  437. )
  438. continue
  439. except (TypeError, AuthKeyDuplicatedError):
  440. os.remove(os.path.join(BASE_DIR, f"{session}.session"))
  441. self.sessions.remove(session)
  442. except (ValueError, ApiIdInvalidError):
  443. # Bad API hash/ID
  444. run_config({}, self.arguments.data_root)
  445. return False
  446. except PhoneNumberInvalidError:
  447. logging.error(
  448. "Phone number is incorrect. Use international format (+XX...) "
  449. "and don't put spaces in it."
  450. )
  451. self.sessions.remove(session)
  452. except InteractiveAuthRequired:
  453. logging.error(
  454. f"Session {session} was terminated and re-auth is required"
  455. )
  456. self.sessions.remove(session)
  457. return bool(self.sessions)
  458. def _init_loop(self):
  459. """Initializes main event loop and starts handler for each client"""
  460. loops = [self.amain_wrapper(client) for client in self.clients]
  461. self.loop.run_until_complete(asyncio.gather(*loops))
  462. async def amain_wrapper(self, client):
  463. """Wrapper around amain"""
  464. async with client:
  465. first = True
  466. client._tg_id = (await client.get_me()).id
  467. client.tg_id = client._tg_id
  468. while await self.amain(first, client):
  469. first = False
  470. async def _badge(self, client):
  471. """Call the badge in shell"""
  472. try:
  473. import git
  474. repo = git.Repo()
  475. build = repo.heads[0].commit.hexsha
  476. diff = repo.git.log(["HEAD..origin/master", "--oneline"])
  477. upd = r"Update required" if diff else r"Up-to-date"
  478. _platform = utils.get_named_platform()
  479. logo1 = f"""
  480. █ █ █ █▄▀ █▄▀ ▄▀█
  481. █▀█ █ █ █ █ █ █▀█
  482. • Build: {build[:7]}
  483. • Version: {'.'.join(list(map(str, list(__version__))))}
  484. • {upd}
  485. • Platform: {_platform}
  486. """
  487. if not self.omit_log:
  488. print(logo1)
  489. web_url = (
  490. f"🌐 Web url: {self.web.url}\n"
  491. if self.web and hasattr(self.web, "url")
  492. else ""
  493. )
  494. logging.info(
  495. f"🌘 Hikka {'.'.join(list(map(str, list(__version__))))} started\n"
  496. f"🔏 GitHub commit SHA: {build[:7]} ({upd})\n"
  497. f"{web_url}"
  498. f"{_platform}"
  499. )
  500. self.omit_log = True
  501. logging.info(f"- Started for {client._tg_id} -")
  502. except Exception:
  503. logging.exception("Badge error")
  504. async def _add_dispatcher(self, client, modules, db):
  505. """Inits and adds dispatcher instance to client"""
  506. dispatcher = CommandDispatcher(modules, db, self.arguments.no_nickname)
  507. client.dispatcher = dispatcher
  508. await dispatcher.init(client)
  509. modules.check_security = dispatcher.check_security
  510. client.add_event_handler(
  511. dispatcher.handle_incoming,
  512. events.NewMessage,
  513. )
  514. client.add_event_handler(
  515. dispatcher.handle_incoming,
  516. events.ChatAction,
  517. )
  518. client.add_event_handler(
  519. dispatcher.handle_command,
  520. events.NewMessage(forwards=False),
  521. )
  522. client.add_event_handler(
  523. dispatcher.handle_command,
  524. events.MessageEdited(),
  525. )
  526. async def amain(self, first, client):
  527. """Entrypoint for async init, run once for each user"""
  528. web_only = self.arguments.web_only
  529. client.parse_mode = "HTML"
  530. await client.start()
  531. db = database.Database(client)
  532. await db.init()
  533. logging.debug("Got DB")
  534. logging.debug("Loading logging config...")
  535. to_load = ["loader.py"] if self.arguments.docker_deps_internal else None
  536. translator = Translator(client, db)
  537. await translator.init()
  538. modules = loader.Modules(client, db, self.clients, translator)
  539. client.loader = modules
  540. if self.arguments.docker_deps_internal:
  541. # Loader has installed all dependencies
  542. return # We are done
  543. if self.web:
  544. await self.web.add_loader(client, modules, db)
  545. await self.web.start_if_ready(
  546. len(self.clients),
  547. self.arguments.port,
  548. )
  549. if not web_only:
  550. await self._add_dispatcher(client, modules, db)
  551. modules.register_all(to_load)
  552. modules.send_config()
  553. await modules.send_ready()
  554. if first:
  555. await self._badge(client)
  556. await client.run_until_disconnected()
  557. def main(self):
  558. """Main entrypoint"""
  559. if self.arguments.heroku:
  560. if isinstance(self.arguments.heroku, str):
  561. key = self.arguments.heroku
  562. else:
  563. print(
  564. "\033[0;35m- Heroku installation -\033[0m\n*If you are from Russia,"
  565. " enable VPN and use it throughout the process\n\n1. Register"
  566. " account at https://heroku.com\n2. Go to"
  567. " https://dashboard.heroku.com/account\n3. Next to API Key click"
  568. " `Reveal` and copy it\n4. Enter it below:"
  569. )
  570. key = input("♓️ \033[1;37mHeroku API key:\033[0m ").strip()
  571. print(
  572. "⏱ Installing to Heroku...\n"
  573. "This process might take several minutes, be patient."
  574. )
  575. app = heroku.publish(key, api_token=self.api_token)
  576. print(f"Installed to heroku successfully!\n🎉 App URL: {app.web_url}")
  577. # At this point our work is done
  578. # everything else will be run on Heroku, including
  579. # authentication
  580. return
  581. self._init_web()
  582. save_config_key("port", self.arguments.port)
  583. self._get_token()
  584. if (
  585. not self.clients # Search for already inited clients
  586. and not self.sessions # Search for already added sessions
  587. or not self._init_clients() # Attempt to read sessions from env
  588. ) and not self._initial_setup(): # Otherwise attempt to run setup
  589. return
  590. self.loop.set_exception_handler(
  591. lambda _, x: logging.error(
  592. f"Exception on event loop! {x['message']}",
  593. exc_info=x.get("exception", None),
  594. )
  595. )
  596. self._init_loop()
  597. hikka = Hikka()