main.py 21 KB

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