updater.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. # Friendly Telegram (telegram userbot)
  2. # Copyright (C) 2018-2021 The Authors
  3. # This program is free software: you can redistribute it and/or modify
  4. # it under the terms of the GNU Affero General Public License as published by
  5. # the Free Software Foundation, either version 3 of the License, or
  6. # (at your option) any later version.
  7. # This program is distributed in the hope that it will be useful,
  8. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. # GNU Affero General Public License for more details.
  11. # You should have received a copy of the GNU Affero General Public License
  12. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. # █ █ ▀ █▄▀ ▄▀█ █▀█ ▀
  14. # █▀█ █ █ █ █▀█ █▀▄ █
  15. # © Copyright 2022
  16. # https://t.me/hikariatama
  17. #
  18. # 🔒 Licensed under the GNU AGPLv3
  19. # 🌐 https://www.gnu.org/licenses/agpl-3.0.html
  20. import asyncio
  21. import atexit
  22. import contextlib
  23. import logging
  24. import os
  25. import subprocess
  26. import sys
  27. from typing import Union
  28. import time
  29. import git
  30. from git import GitCommandError, Repo
  31. from telethon.tl.functions.messages import (
  32. GetDialogFiltersRequest,
  33. UpdateDialogFilterRequest,
  34. )
  35. from telethon.tl.types import DialogFilter, Message
  36. from telethon.extensions.html import CUSTOM_EMOJIS
  37. from .. import loader, utils, heroku, main, version
  38. from ..inline.types import InlineCall
  39. logger = logging.getLogger(__name__)
  40. @loader.tds
  41. class UpdaterMod(loader.Module):
  42. """Updates itself"""
  43. strings = {
  44. "name": "Updater",
  45. "source": (
  46. "<emoji document_id=5456255401194429832>📖</emoji> <b>Read the source code"
  47. " from</b> <a href='{}'>here</a>"
  48. ),
  49. "restarting_caption": (
  50. "<emoji document_id=6318970114548958978>🕗</emoji> <b>Your {} is"
  51. " restarting...</b>"
  52. ),
  53. "downloading": (
  54. "<emoji document_id=6318970114548958978>🕗</emoji> <b>Downloading"
  55. " updates...</b>"
  56. ),
  57. "installing": (
  58. "<emoji document_id=6318970114548958978>🕗</emoji> <b>Installing"
  59. " updates...</b>"
  60. ),
  61. "success": (
  62. "<emoji document_id=6321050180095313397>⏱</emoji> <b>Restart successful!"
  63. " {}</b>\n<i>But still loading modules...</i>\n<i>Restart took {}s</i>"
  64. ),
  65. "origin_cfg_doc": "Git origin URL, for where to update from",
  66. "btn_restart": "🔄 Restart",
  67. "btn_update": "🧭 Update",
  68. "restart_confirm": "❓ <b>Are you sure you want to restart?</b>",
  69. "secure_boot_confirm": (
  70. "❓ <b>Are you sure you want to restart in secure boot mode?</b>"
  71. ),
  72. "update_confirm": (
  73. "❓ <b>Are you sure you"
  74. " want to update?\n\n<a"
  75. ' href="https://github.com/hikariatama/Hikka/commit/{}">{}</a> ⤑ <a'
  76. ' href="https://github.com/hikariatama/Hikka/commit/{}">{}</a></b>'
  77. ),
  78. "no_update": "🚸 <b>You are on the latest version, pull updates anyway?</b>",
  79. "cancel": "🚫 Cancel",
  80. "lavhost_restart": (
  81. "<emoji document_id=5469986291380657759>✌️</emoji> <b>Your {} is"
  82. " restarting...</b>"
  83. ),
  84. "lavhost_update": (
  85. "<emoji document_id=5469986291380657759>✌️</emoji> <b>Your {} is"
  86. " updating...</b>"
  87. ),
  88. "heroku_update": (
  89. "♓️ <b>Deploying new version to Heroku...\nThis might take some time</b>"
  90. ),
  91. "heroku_update_done_nothing_to_push": (
  92. "😔 <b>Update complete. Nothing to push...</b>"
  93. ),
  94. "full_success": (
  95. "<emoji document_id=6323332130579416910>👍</emoji> <b>Userbot is fully"
  96. " loaded! {}</b>\n<i>Full restart took {}s</i>"
  97. ),
  98. "secure_boot_complete": (
  99. "🔒 <b>Secure boot completed! {}</b>\n<i>Restart took {}s</i>"
  100. ),
  101. }
  102. strings_ru = {
  103. "source": (
  104. "<emoji document_id=5456255401194429832>📖</emoji> <b>Исходный код можно"
  105. " прочитать</b> <a href='{}'>здесь</a>"
  106. ),
  107. "restarting_caption": (
  108. "<emoji document_id=6318970114548958978>🕗</emoji> <b>Твоя {}"
  109. " перезагружается...</b>"
  110. ),
  111. "downloading": (
  112. "<emoji document_id=6318970114548958978>🕗</emoji> <b>Скачивание"
  113. " обновлений...</b>"
  114. ),
  115. "installing": (
  116. "<emoji document_id=6318970114548958978>🕗</emoji> <b>Установка"
  117. " обновлений...</b>"
  118. ),
  119. "success": (
  120. "<emoji document_id=6321050180095313397>⏱</emoji> <b>Перезагрузка"
  121. " успешна! {}</b>\n<i>Но модули еще загружаются...</i>\n<i>Перезагрузка"
  122. " заняла {} сек</i>"
  123. ),
  124. "full_success": (
  125. "<emoji document_id=6323332130579416910>👍</emoji> <b>Юзербот полностью"
  126. " загружен! {}</b>\n<i>Полная перезагрузка заняла {} сек</i>"
  127. ),
  128. "secure_boot_complete": (
  129. "🔒 <b>Безопасная загрузка завершена! {}</b>\n<i>Перезагрузка заняла {}"
  130. " сек</i>"
  131. ),
  132. "origin_cfg_doc": "Ссылка, из которой будут загружаться обновления",
  133. "btn_restart": "🔄 Перезагрузиться",
  134. "btn_update": "🧭 Обновиться",
  135. "restart_confirm": "❓ <b>Ты уверен, что хочешь перезагрузиться?</b>",
  136. "secure_boot_confirm": (
  137. "❓ <b>Ты уверен, что"
  138. " хочешь перезагрузиться в режиме безопасной загрузки?</b>"
  139. ),
  140. "update_confirm": (
  141. "❓ <b>Ты уверен, что"
  142. " хочешь обновиться??\n\n<a"
  143. ' href="https://github.com/hikariatama/Hikka/commit/{}">{}</a> ⤑ <a'
  144. ' href="https://github.com/hikariatama/Hikka/commit/{}">{}</a></b>'
  145. ),
  146. "no_update": "🚸 <b>У тебя последняя версия. Обновиться принудительно?</b>",
  147. "cancel": "🚫 Отмена",
  148. "_cls_doc": "Обновляет юзербот",
  149. "lavhost_restart": (
  150. "<emoji document_id=5469986291380657759>✌️</emoji> <b>Твой {}"
  151. " перезагружается...</b>"
  152. ),
  153. "lavhost_update": (
  154. "<emoji document_id=5469986291380657759>✌️</emoji> <b>Твой {}"
  155. " обновляется...</b>"
  156. ),
  157. "heroku_update": (
  158. "♓️ <b>Обновляю Heroku...\nЭто может занять некоторое время</b>"
  159. ),
  160. "heroku_update_done_nothing_to_push": (
  161. "😔 <b>Обновление завершено. Ничего не изменилось, нечего обновлять...</b>"
  162. ),
  163. }
  164. def __init__(self):
  165. self.config = loader.ModuleConfig(
  166. loader.ConfigValue(
  167. "GIT_ORIGIN_URL",
  168. "https://github.com/hikariatama/Hikka",
  169. lambda: self.strings("origin_cfg_doc"),
  170. validator=loader.validators.Link(),
  171. )
  172. )
  173. @loader.owner
  174. @loader.command(ru_doc="Перезагружает юзербот")
  175. async def restart(self, message: Message):
  176. """Restarts the userbot"""
  177. secure_boot = "--secure-boot" in utils.get_args_raw(message)
  178. try:
  179. if (
  180. "--force" in (utils.get_args_raw(message) or "")
  181. or "-f" in (utils.get_args_raw(message) or "")
  182. or not self.inline.init_complete
  183. or not await self.inline.form(
  184. message=message,
  185. text=self.strings(
  186. "secure_boot_confirm" if secure_boot else "restart_confirm"
  187. ),
  188. reply_markup=[
  189. {
  190. "text": self.strings("btn_restart"),
  191. "callback": self.inline_restart,
  192. "args": (secure_boot,),
  193. },
  194. {"text": self.strings("cancel"), "action": "close"},
  195. ],
  196. )
  197. ):
  198. raise
  199. except Exception:
  200. await self.restart_common(message, secure_boot)
  201. async def inline_restart(self, call: InlineCall, secure_boot: bool = False):
  202. await self.restart_common(call, secure_boot=secure_boot)
  203. async def process_restart_message(self, msg_obj: Union[InlineCall, Message]):
  204. self.set(
  205. "selfupdatemsg",
  206. msg_obj.inline_message_id
  207. if hasattr(msg_obj, "inline_message_id")
  208. else f"{utils.get_chat_id(msg_obj)}:{msg_obj.id}",
  209. )
  210. async def restart_common(
  211. self,
  212. msg_obj: Union[InlineCall, Message],
  213. secure_boot: bool = False,
  214. ):
  215. if (
  216. hasattr(msg_obj, "form")
  217. and isinstance(msg_obj.form, dict)
  218. and "uid" in msg_obj.form
  219. and msg_obj.form["uid"] in self.inline._units
  220. and "message" in self.inline._units[msg_obj.form["uid"]]
  221. ):
  222. message = self.inline._units[msg_obj.form["uid"]]["message"]
  223. else:
  224. message = msg_obj
  225. if secure_boot:
  226. self._db.set(loader.__name__, "secure_boot", True)
  227. msg_obj = await utils.answer(
  228. msg_obj,
  229. self.strings("restarting_caption").format(
  230. utils.get_platform_emoji()
  231. if self._client.hikka_me.premium
  232. and CUSTOM_EMOJIS
  233. and isinstance(msg_obj, Message)
  234. else "Hikka"
  235. )
  236. if "LAVHOST" not in os.environ
  237. else self.strings("lavhost_restart").format(
  238. '</b><emoji document_id="5192756799647785066">✌️</emoji><emoji'
  239. ' document_id="5193117564015747203">✌️</emoji><emoji'
  240. ' document_id="5195050806105087456">✌️</emoji><emoji'
  241. ' document_id="5195457642587233944">✌️</emoji><b>'
  242. if self._client.hikka_me.premium
  243. and CUSTOM_EMOJIS
  244. and isinstance(msg_obj, Message)
  245. else "lavHost"
  246. ),
  247. )
  248. await self.process_restart_message(msg_obj)
  249. self.set("restart_ts", time.time())
  250. await self._db.remote_force_save()
  251. if "LAVHOST" in os.environ:
  252. os.system("lavhost restart")
  253. return
  254. if "DYNO" in os.environ:
  255. app = heroku.get_app(api_token=main.hikka.api_token)[0]
  256. app.restart()
  257. return
  258. with contextlib.suppress(Exception):
  259. await main.hikka.web.stop()
  260. atexit.register(restart, *sys.argv[1:])
  261. handler = logging.getLogger().handlers[0]
  262. handler.setLevel(logging.CRITICAL)
  263. for client in self.allclients:
  264. # Terminate main loop of all running clients
  265. # Won't work if not all clients are ready
  266. if client is not message.client:
  267. await client.disconnect()
  268. await message.client.disconnect()
  269. sys.exit(0)
  270. async def download_common(self):
  271. try:
  272. repo = Repo(os.path.dirname(utils.get_base_dir()))
  273. origin = repo.remote("origin")
  274. r = origin.pull()
  275. new_commit = repo.head.commit
  276. for info in r:
  277. if info.old_commit:
  278. for d in new_commit.diff(info.old_commit):
  279. if d.b_path == "requirements.txt":
  280. return True
  281. return False
  282. except git.exc.InvalidGitRepositoryError:
  283. repo = Repo.init(os.path.dirname(utils.get_base_dir()))
  284. origin = repo.create_remote("origin", self.config["GIT_ORIGIN_URL"])
  285. origin.fetch()
  286. repo.create_head("master", origin.refs.master)
  287. repo.heads.master.set_tracking_branch(origin.refs.master)
  288. repo.heads.master.checkout(True)
  289. return False
  290. @staticmethod
  291. def req_common():
  292. # Now we have downloaded new code, install requirements
  293. logger.debug("Installing new requirements...")
  294. try:
  295. subprocess.run(
  296. [
  297. sys.executable,
  298. "-m",
  299. "pip",
  300. "install",
  301. "-r",
  302. os.path.join(
  303. os.path.dirname(utils.get_base_dir()),
  304. "requirements.txt",
  305. ),
  306. "--user",
  307. ],
  308. check=True,
  309. )
  310. except subprocess.CalledProcessError:
  311. logger.exception("Req install failed")
  312. @loader.owner
  313. @loader.command(ru_doc="Скачивает обновления юзербота")
  314. async def update(self, message: Message):
  315. """Downloads userbot updates"""
  316. try:
  317. current = utils.get_git_hash()
  318. upcoming = next(
  319. git.Repo().iter_commits(f"origin/{version.branch}", max_count=1)
  320. ).hexsha
  321. if (
  322. "--force" in (utils.get_args_raw(message) or "")
  323. or "-f" in (utils.get_args_raw(message) or "")
  324. or not self.inline.init_complete
  325. or not await self.inline.form(
  326. message=message,
  327. text=self.strings("update_confirm").format(
  328. *(
  329. [current, current[:8], upcoming, upcoming[:8]]
  330. if "DYNO" not in os.environ
  331. else ["", "", "", ""]
  332. )
  333. )
  334. if upcoming != current or "DYNO" in os.environ
  335. else self.strings("no_update"),
  336. reply_markup=[
  337. {
  338. "text": self.strings("btn_update"),
  339. "callback": self.inline_update,
  340. },
  341. {"text": self.strings("cancel"), "action": "close"},
  342. ],
  343. )
  344. ):
  345. raise
  346. except Exception:
  347. await self.inline_update(message)
  348. async def inline_update(
  349. self,
  350. msg_obj: Union[InlineCall, Message],
  351. hard: bool = False,
  352. ):
  353. # We don't really care about asyncio at this point, as we are shutting down
  354. if hard:
  355. os.system(f"cd {utils.get_base_dir()} && cd .. && git reset --hard HEAD")
  356. try:
  357. if "LAVHOST" in os.environ:
  358. msg_obj = await utils.answer(
  359. msg_obj,
  360. self.strings("lavhost_update").format(
  361. '</b><emoji document_id=5192756799647785066>✌️</emoji><emoji'
  362. ' document_id=5193117564015747203>✌️</emoji><emoji'
  363. ' document_id=5195050806105087456>✌️</emoji><emoji'
  364. ' document_id=5195457642587233944>✌️</emoji><b>'
  365. if self._client.hikka_me.premium
  366. and CUSTOM_EMOJIS
  367. and isinstance(msg_obj, Message)
  368. else "lavHost"
  369. ),
  370. )
  371. await self.process_restart_message(msg_obj)
  372. os.system("lavhost update")
  373. return
  374. if "DYNO" in os.environ:
  375. msg_obj = await utils.answer(msg_obj, self.strings("heroku_update"))
  376. await self.process_restart_message(msg_obj)
  377. try:
  378. nosave = "--no-save" in utils.get_args_raw(msg_obj)
  379. except Exception:
  380. nosave = False
  381. if not nosave:
  382. await self._db.remote_force_save()
  383. app, _ = heroku.get_app(
  384. api_token=main.hikka.api_token,
  385. create_new=False,
  386. )
  387. repo = heroku.get_repo()
  388. url = app.git_url.replace(
  389. "https://",
  390. f"https://api:{os.environ.get('heroku_api_token')}@",
  391. )
  392. if "heroku" in repo.remotes:
  393. remote = repo.remote("heroku")
  394. remote.set_url(url)
  395. else:
  396. remote = repo.create_remote("heroku", url)
  397. await utils.run_sync(remote.push, refspec="HEAD:refs/heads/master")
  398. await utils.answer(
  399. msg_obj,
  400. self.strings("heroku_update_done_nothing_to_push"),
  401. )
  402. return
  403. with contextlib.suppress(Exception):
  404. msg_obj = await utils.answer(msg_obj, self.strings("downloading"))
  405. req_update = await self.download_common()
  406. with contextlib.suppress(Exception):
  407. msg_obj = await utils.answer(msg_obj, self.strings("installing"))
  408. if req_update:
  409. self.req_common()
  410. await self.restart_common(msg_obj)
  411. except GitCommandError:
  412. if not hard:
  413. await self.inline_update(msg_obj, True)
  414. return
  415. logger.critical("Got update loop. Update manually via .terminal")
  416. return
  417. @loader.unrestricted
  418. @loader.command(ru_doc="Показать ссылку на исходный код проекта")
  419. async def source(self, message: Message):
  420. """Links the source code of this project"""
  421. await utils.answer(
  422. message,
  423. self.strings("source").format(self.config["GIT_ORIGIN_URL"]),
  424. )
  425. async def client_ready(self):
  426. if self.get("selfupdatemsg") is not None:
  427. try:
  428. await self.update_complete()
  429. except Exception:
  430. logger.exception("Failed to complete update!")
  431. if self.get("do_not_create", False):
  432. return
  433. try:
  434. await self._add_folder()
  435. except Exception:
  436. logger.exception("Failed to add folder!")
  437. finally:
  438. self.set("do_not_create", True)
  439. async def _add_folder(self):
  440. folders = await self._client(GetDialogFiltersRequest())
  441. if any(getattr(folder, "title", None) == "hikka" for folder in folders):
  442. return
  443. try:
  444. folder_id = (
  445. max(
  446. folders,
  447. key=lambda x: x.id,
  448. ).id
  449. + 1
  450. )
  451. except ValueError:
  452. folder_id = 2
  453. try:
  454. await self._client(
  455. UpdateDialogFilterRequest(
  456. folder_id,
  457. DialogFilter(
  458. folder_id,
  459. title="hikka",
  460. pinned_peers=(
  461. [
  462. await self._client.get_input_entity(
  463. self._client.loader.inline.bot_id
  464. )
  465. ]
  466. if self._client.loader.inline.init_complete
  467. else []
  468. ),
  469. include_peers=[
  470. await self._client.get_input_entity(dialog.entity)
  471. async for dialog in self._client.iter_dialogs(
  472. None,
  473. ignore_migrated=True,
  474. )
  475. if dialog.name
  476. in {
  477. "hikka-logs",
  478. "hikka-onload",
  479. "hikka-assets",
  480. "hikka-backups",
  481. "hikka-acc-switcher",
  482. "silent-tags",
  483. }
  484. and dialog.is_channel
  485. and (
  486. dialog.entity.participants_count == 1
  487. or dialog.entity.participants_count == 2
  488. and dialog.name in {"hikka-logs", "silent-tags"}
  489. )
  490. or (
  491. self._client.loader.inline.init_complete
  492. and dialog.entity.id
  493. == self._client.loader.inline.bot_id
  494. )
  495. or dialog.entity.id
  496. in [
  497. 1554874075,
  498. 1697279580,
  499. 1679998924,
  500. ] # official hikka chats
  501. ],
  502. emoticon="🐱",
  503. exclude_peers=[],
  504. contacts=False,
  505. non_contacts=False,
  506. groups=False,
  507. broadcasts=False,
  508. bots=False,
  509. exclude_muted=False,
  510. exclude_read=False,
  511. exclude_archived=False,
  512. ),
  513. )
  514. )
  515. except Exception:
  516. logger.critical(
  517. "Can't create Hikka folder. Possible reasons are:\n"
  518. "- User reached the limit of folders in Telegram\n"
  519. "- User got floodwait\n"
  520. "Ignoring error and adding folder addition to ignore list"
  521. )
  522. async def update_complete(self):
  523. logger.debug("Self update successful! Edit message")
  524. start = self.get("restart_ts")
  525. try:
  526. took = round(time.time() - start)
  527. except Exception:
  528. took = "n/a"
  529. msg = self.strings("success").format(utils.ascii_face(), took)
  530. ms = self.get("selfupdatemsg")
  531. if ":" in str(ms):
  532. chat_id, message_id = ms.split(":")
  533. chat_id, message_id = int(chat_id), int(message_id)
  534. await self._client.edit_message(chat_id, message_id, msg)
  535. return
  536. await self.inline.bot.edit_message_text(
  537. inline_message_id=ms,
  538. text=self.inline.sanitise_text(msg),
  539. )
  540. async def full_restart_complete(self, secure_boot: bool = False):
  541. start = self.get("restart_ts")
  542. try:
  543. took = round(time.time() - start)
  544. except Exception:
  545. took = "n/a"
  546. self.set("restart_ts", None)
  547. ms = self.get("selfupdatemsg")
  548. msg = self.strings(
  549. "secure_boot_complete" if secure_boot else "full_success"
  550. ).format(utils.ascii_face(), took)
  551. if ms is None:
  552. return
  553. self.set("selfupdatemsg", None)
  554. if ":" in str(ms):
  555. chat_id, message_id = ms.split(":")
  556. chat_id, message_id = int(chat_id), int(message_id)
  557. await self._client.edit_message(chat_id, message_id, msg)
  558. await asyncio.sleep(60)
  559. await self._client.delete_messages(chat_id, message_id)
  560. return
  561. await self.inline.bot.edit_message_text(
  562. inline_message_id=ms,
  563. text=self.inline.sanitise_text(msg),
  564. )
  565. def restart(*argv):
  566. os.execl(
  567. sys.executable,
  568. sys.executable,
  569. "-m",
  570. os.path.relpath(utils.get_base_dir()),
  571. *argv,
  572. )