updater.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  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 .. import loader, utils, heroku, main
  37. from ..inline.types import InlineCall
  38. try:
  39. import psycopg2
  40. except ImportError:
  41. if "DYNO" in os.environ:
  42. raise
  43. logger = logging.getLogger(__name__)
  44. @loader.tds
  45. class UpdaterMod(loader.Module):
  46. """Updates itself"""
  47. strings = {
  48. "name": "Updater",
  49. "source": "ℹ️ <b>Read the source code from</b> <a href='{}'>here</a>",
  50. "restarting_caption": "🔄 <b>Restarting...</b>",
  51. "downloading": "🕐 <b>Downloading updates...</b>",
  52. "installing": "🕐 <b>Installing updates...</b>",
  53. "success": "⏳ <b>Restart successful! {}</b>\n<i>But still loading modules...</i>\n<i>Restart took {}s</i>",
  54. "origin_cfg_doc": "Git origin URL, for where to update from",
  55. "btn_restart": "🔄 Restart",
  56. "btn_update": "🧭 Update",
  57. "restart_confirm": "🔄 <b>Are you sure you want to restart?</b>",
  58. "secure_boot_confirm": "🔄 <b>Are you sure you want to restart in secure boot mode?</b>",
  59. "update_confirm": (
  60. "🧭 <b>Are you sure you want to update?\n\n"
  61. '<a href="https://github.com/hikariatama/Hikka/commit/{}">{}</a> ⤑ '
  62. '<a href="https://github.com/hikariatama/Hikka/commit/{}">{}</a></b>'
  63. ),
  64. "no_update": "🚸 <b>You are on the latest version, pull updates anyway?</b>",
  65. "cancel": "🚫 Cancel",
  66. "lavhost_restart": "✌️ <b>Your lavHost is restarting...\n&gt;///&lt;</b>",
  67. "lavhost_update": "✌️ <b>Your lavHost is updating...\n&gt;///&lt;</b>",
  68. "heroku_update": "♓️ <b>Deploying new version to Heroku...\nThis might take some time</b>",
  69. "full_success": "✅ <b>Userbot is fully loaded! {}</b>\n<i>Full restart took {}s</i>",
  70. "secure_boot_complete": "🔒 <b>Secure boot completed! {}</b>\n<i>Restart took {}s</i>",
  71. "heroku_psycopg2_unavailable": "♓️🚫 <b>PostgreSQL database is not available.</b>\n\n<i>Do not report this error to support chat, as it has nothing to do with Hikka. Try changing database to Redis</i>",
  72. }
  73. strings_ru = {
  74. "source": "ℹ️ <b>Исходный код можно прочитать</b> <a href='{}'>здесь</a>",
  75. "restarting_caption": "🔄 <b>Перезагрузка...</b>",
  76. "downloading": "🕐 <b>Скачивание обновлений...</b>",
  77. "installing": "🕐 <b>Установка обновлений...</b>",
  78. "success": "⏳ <b>Перезагрузка успешна! {}</b>\n<i>Но модули еще загружаются...</i>\n<i>Перезагрузка заняла {} сек</i>",
  79. "full_success": "✅ <b>Юзербот полностью загружен! {}</b>\n<i>Полная перезагрузка заняла {} сек</i>",
  80. "secure_boot_complete": "🔒 <b>Безопасная загрузка завершена! {}</b>\n<i>Перезагрузка заняла {} сек</i>",
  81. "origin_cfg_doc": "Ссылка, из которой будут загружаться обновления",
  82. "btn_restart": "🔄 Перезагрузиться",
  83. "btn_update": "🧭 Обновиться",
  84. "restart_confirm": "🔄 <b>Ты уверен, что хочешь перезагрузиться?</b>",
  85. "secure_boot_confirm": "🔄 <b>Ты уверен, что хочешь перезагрузиться в режиме безопасной загрузки?</b>",
  86. "update_confirm": (
  87. "🧭 <b>Ты уверен, что хочешь обновиться??\n\n"
  88. '<a href="https://github.com/hikariatama/Hikka/commit/{}">{}</a> ⤑ '
  89. '<a href="https://github.com/hikariatama/Hikka/commit/{}">{}</a></b>'
  90. ),
  91. "no_update": "🚸 <b>У тебя последняя версия. Обновиться принудительно?</b>",
  92. "cancel": "🚫 Отмена",
  93. "_cmd_doc_restart": "Перезагружает юзербот",
  94. "_cmd_doc_download": "Скачивает обновления",
  95. "_cmd_doc_update": "Обновляет юзербот",
  96. "_cmd_doc_source": "Ссылка на исходный код проекта",
  97. "_cls_doc": "Обновляет юзербот",
  98. "lavhost_restart": "✌️ <b>Твой lavHost перезагружается...\n&gt;///&lt;</b>",
  99. "lavhost_update": "✌️ <b>Твой lavHost обновляется...\n&gt;///&lt;</b>",
  100. "heroku_update": "♓️ <b>Обновляю Heroku...\nЭто может занять некоторое время</b>",
  101. "heroku_psycopg2_unavailable": "♓️🚫 <b>PostgreSQL база данных не доступна.</b>\n\n<i>Не обращайтесь к поддержке чата, так как эта проблема не вызвана Hikka. Попробуйте изменить базу данных на Redis</i>",
  102. }
  103. def __init__(self):
  104. self.config = loader.ModuleConfig(
  105. loader.ConfigValue(
  106. "GIT_ORIGIN_URL",
  107. "https://github.com/hikariatama/Hikka",
  108. lambda: self.strings("origin_cfg_doc"),
  109. validator=loader.validators.Link(),
  110. )
  111. )
  112. @loader.owner
  113. async def restartcmd(self, message: Message):
  114. """Restarts the userbot"""
  115. secure_boot = "--secure-boot" in utils.get_args_raw(message)
  116. try:
  117. if (
  118. "--force" in (utils.get_args_raw(message) or "")
  119. or not self.inline.init_complete
  120. or not await self.inline.form(
  121. message=message,
  122. text=self.strings(
  123. "secure_boot_confirm" if secure_boot else "restart_confirm"
  124. ),
  125. reply_markup=[
  126. {
  127. "text": self.strings("btn_restart"),
  128. "callback": self.inline_restart,
  129. "args": (secure_boot,),
  130. },
  131. {"text": self.strings("cancel"), "action": "close"},
  132. ],
  133. )
  134. ):
  135. raise
  136. except Exception:
  137. await self.restart_common(message, secure_boot)
  138. async def inline_restart(self, call: InlineCall, secure_boot: bool = False):
  139. await self.restart_common(call, secure_boot=secure_boot)
  140. async def process_restart_message(self, msg_obj: Union[InlineCall, Message]):
  141. self.set(
  142. "selfupdatemsg",
  143. msg_obj.inline_message_id
  144. if hasattr(msg_obj, "inline_message_id")
  145. else f"{utils.get_chat_id(msg_obj)}:{msg_obj.id}",
  146. )
  147. async def restart_common(
  148. self,
  149. msg_obj: Union[InlineCall, Message],
  150. secure_boot: bool = False,
  151. ):
  152. if (
  153. hasattr(msg_obj, "form")
  154. and isinstance(msg_obj.form, dict)
  155. and "uid" in msg_obj.form
  156. and msg_obj.form["uid"] in self.inline._units
  157. and "message" in self.inline._units[msg_obj.form["uid"]]
  158. ):
  159. message = self.inline._units[msg_obj.form["uid"]]["message"]
  160. else:
  161. message = msg_obj
  162. if secure_boot:
  163. self._db.set(loader.__name__, "secure_boot", True)
  164. msg_obj = await utils.answer(
  165. msg_obj,
  166. self.strings(
  167. "restarting_caption"
  168. if "LAVHOST" not in os.environ
  169. else "lavhost_restart"
  170. ),
  171. )
  172. await self.process_restart_message(msg_obj)
  173. self.set("restart_ts", time.time())
  174. await self._db.remote_force_save()
  175. if "LAVHOST" in os.environ:
  176. os.system("lavhost restart")
  177. return
  178. if "DYNO" in os.environ:
  179. app = heroku.get_app(api_token=main.hikka.api_token)[0]
  180. app.restart()
  181. return
  182. with contextlib.suppress(Exception):
  183. await main.hikka.web.stop()
  184. atexit.register(restart, *sys.argv[1:])
  185. handler = logging.getLogger().handlers[0]
  186. handler.setLevel(logging.CRITICAL)
  187. for client in self.allclients:
  188. # Terminate main loop of all running clients
  189. # Won't work if not all clients are ready
  190. if client is not message.client:
  191. await client.disconnect()
  192. await message.client.disconnect()
  193. sys.exit(0)
  194. async def download_common(self):
  195. try:
  196. repo = Repo(os.path.dirname(utils.get_base_dir()))
  197. origin = repo.remote("origin")
  198. r = origin.pull()
  199. new_commit = repo.head.commit
  200. for info in r:
  201. if info.old_commit:
  202. for d in new_commit.diff(info.old_commit):
  203. if d.b_path == "requirements.txt":
  204. return True
  205. return False
  206. except git.exc.InvalidGitRepositoryError:
  207. repo = Repo.init(os.path.dirname(utils.get_base_dir()))
  208. origin = repo.create_remote("origin", self.config["GIT_ORIGIN_URL"])
  209. origin.fetch()
  210. repo.create_head("master", origin.refs.master)
  211. repo.heads.master.set_tracking_branch(origin.refs.master)
  212. repo.heads.master.checkout(True)
  213. return False
  214. @staticmethod
  215. def req_common():
  216. # Now we have downloaded new code, install requirements
  217. logger.debug("Installing new requirements...")
  218. try:
  219. subprocess.run(
  220. [
  221. sys.executable,
  222. "-m",
  223. "pip",
  224. "install",
  225. "-r",
  226. os.path.join(
  227. os.path.dirname(utils.get_base_dir()),
  228. "requirements.txt",
  229. ),
  230. "--user",
  231. ],
  232. check=True,
  233. )
  234. except subprocess.CalledProcessError:
  235. logger.exception("Req install failed")
  236. @loader.owner
  237. async def updatecmd(self, message: Message):
  238. """Downloads userbot updates"""
  239. try:
  240. current = utils.get_git_hash()
  241. upcoming = next(
  242. git.Repo().iter_commits("origin/master", max_count=1)
  243. ).hexsha
  244. if (
  245. "--force" in (utils.get_args_raw(message) or "")
  246. or not self.inline.init_complete
  247. or not await self.inline.form(
  248. message=message,
  249. text=self.strings("update_confirm").format(
  250. *(
  251. [current, current[:8], upcoming, upcoming[:8]]
  252. if "DYNO" not in os.environ
  253. else ["", "", "", ""]
  254. )
  255. )
  256. if upcoming != current or "DYNO" in os.environ
  257. else self.strings("no_update"),
  258. reply_markup=[
  259. {
  260. "text": self.strings("btn_update"),
  261. "callback": self.inline_update,
  262. },
  263. {"text": self.strings("cancel"), "action": "close"},
  264. ],
  265. )
  266. ):
  267. raise
  268. except Exception:
  269. await self.inline_update(message)
  270. async def inline_update(
  271. self,
  272. msg_obj: Union[InlineCall, Message],
  273. hard: bool = False,
  274. ):
  275. # We don't really care about asyncio at this point, as we are shutting down
  276. if hard:
  277. os.system(f"cd {utils.get_base_dir()} && cd .. && git reset --hard HEAD")
  278. try:
  279. if "LAVHOST" in os.environ:
  280. msg_obj = await utils.answer(msg_obj, self.strings("lavhost_update"))
  281. await self.process_restart_message(msg_obj)
  282. os.system("lavhost update")
  283. return
  284. if "DYNO" in os.environ:
  285. await utils.answer(msg_obj, self.strings("heroku_update"))
  286. await self.process_restart_message(msg_obj)
  287. try:
  288. await self._db.remote_force_save()
  289. except psycopg2.errors.InFailedSqlTransaction:
  290. await utils.answer(
  291. msg_obj, self.strings("heroku_psycopg2_unavailable")
  292. )
  293. return
  294. heroku.publish(api_token=main.hikka.api_token, create_new=False)
  295. return
  296. try:
  297. msg_obj = await utils.answer(msg_obj, self.strings("downloading"))
  298. except Exception:
  299. pass
  300. req_update = await self.download_common()
  301. try:
  302. msg_obj = await utils.answer(msg_obj, self.strings("installing"))
  303. except Exception:
  304. pass
  305. if req_update:
  306. self.req_common()
  307. await self.restart_common(msg_obj)
  308. except GitCommandError:
  309. if not hard:
  310. await self.inline_update(msg_obj, True)
  311. return
  312. logger.critical("Got update loop. Update manually via .terminal")
  313. return
  314. @loader.unrestricted
  315. async def sourcecmd(self, message: Message):
  316. """Links the source code of this project"""
  317. await utils.answer(
  318. message,
  319. self.strings("source").format(self.config["GIT_ORIGIN_URL"]),
  320. )
  321. async def client_ready(self, client, db):
  322. self._db = db
  323. self._client = client
  324. if self.get("selfupdatemsg") is not None:
  325. try:
  326. await self.update_complete(client)
  327. except Exception:
  328. logger.exception("Failed to complete update!")
  329. if self.get("do_not_create", False):
  330. return
  331. folders = await self._client(GetDialogFiltersRequest())
  332. if any(getattr(folder, "title", None) == "hikka" for folder in folders):
  333. return
  334. try:
  335. folder_id = (
  336. max(
  337. folders,
  338. key=lambda x: x.id,
  339. ).id
  340. + 1
  341. )
  342. except ValueError:
  343. folder_id = 2
  344. try:
  345. await self._client(
  346. UpdateDialogFilterRequest(
  347. folder_id,
  348. DialogFilter(
  349. folder_id,
  350. title="hikka",
  351. pinned_peers=(
  352. [
  353. await self._client.get_input_entity(
  354. self._client.loader.inline.bot_id
  355. )
  356. ]
  357. if self._client.loader.inline.init_complete
  358. else []
  359. ),
  360. include_peers=[
  361. await self._client.get_input_entity(dialog.entity)
  362. async for dialog in self._client.iter_dialogs(
  363. None,
  364. ignore_migrated=True,
  365. )
  366. if dialog.name
  367. in {
  368. "hikka-logs",
  369. "hikka-onload",
  370. "hikka-assets",
  371. "hikka-backups",
  372. "hikka-acc-switcher",
  373. "silent-tags",
  374. }
  375. and dialog.is_channel
  376. and (
  377. dialog.entity.participants_count == 1
  378. or dialog.entity.participants_count == 2
  379. and dialog.name in {"hikka-logs", "silent-tags"}
  380. )
  381. or (
  382. self._client.loader.inline.init_complete
  383. and dialog.entity.id
  384. == self._client.loader.inline.bot_id
  385. )
  386. or dialog.entity.id
  387. in [
  388. 1554874075,
  389. 1697279580,
  390. 1679998924,
  391. ] # official hikka chats
  392. ],
  393. emoticon="🐱",
  394. exclude_peers=[],
  395. contacts=False,
  396. non_contacts=False,
  397. groups=False,
  398. broadcasts=False,
  399. bots=False,
  400. exclude_muted=False,
  401. exclude_read=False,
  402. exclude_archived=False,
  403. ),
  404. )
  405. )
  406. except Exception:
  407. logger.critical(
  408. "Can't create Hikka folder. Possible reasons are:\n"
  409. "- User reached the limit of folders in Telegram\n"
  410. "- User got floodwait\n"
  411. "Ignoring error and adding folder addition to ignore list"
  412. )
  413. self.set("do_not_create", True)
  414. async def update_complete(self, client: "TelegramClient"): # type: ignore
  415. logger.debug("Self update successful! Edit message")
  416. start = self.get("restart_ts")
  417. try:
  418. took = round(time.time() - start)
  419. except Exception:
  420. took = "n/a"
  421. msg = self.strings("success").format(utils.ascii_face(), took)
  422. ms = self.get("selfupdatemsg")
  423. if ":" in str(ms):
  424. chat_id, message_id = ms.split(":")
  425. chat_id, message_id = int(chat_id), int(message_id)
  426. await self._client.edit_message(chat_id, message_id, msg)
  427. return
  428. await self.inline.bot.edit_message_text(
  429. inline_message_id=ms,
  430. text=msg,
  431. )
  432. async def full_restart_complete(self, secure_boot: bool = False):
  433. start = self.get("restart_ts")
  434. try:
  435. took = round(time.time() - start)
  436. except Exception:
  437. took = "n/a"
  438. self.set("restart_ts", None)
  439. ms = self.get("selfupdatemsg")
  440. msg = self.strings(
  441. "secure_boot_complete" if secure_boot else "full_success"
  442. ).format(utils.ascii_face(), took)
  443. if ms is None:
  444. return
  445. self.set("selfupdatemsg", None)
  446. if ":" in str(ms):
  447. chat_id, message_id = ms.split(":")
  448. chat_id, message_id = int(chat_id), int(message_id)
  449. await self._client.edit_message(chat_id, message_id, msg)
  450. await asyncio.sleep(60)
  451. await self._client.delete_messages(chat_id, message_id)
  452. return
  453. await self.inline.bot.edit_message_text(
  454. inline_message_id=ms,
  455. text=msg,
  456. )
  457. def restart(*argv):
  458. os.execl(
  459. sys.executable,
  460. sys.executable,
  461. "-m",
  462. os.path.relpath(utils.get_base_dir()),
  463. *argv,
  464. )