├── .gitignore ├── callback ├── __init__.py ├── jobs.py ├── misc.py ├── restart.py └── stats.py ├── common.py ├── const ├── CONFIG.py ├── __init__.py └── kb_mks.py ├── main.py ├── readme.md └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | env 3 | __pycache__/ 4 | 5 | *.pyc 6 | *.pem 7 | testing.py 8 | const/CONFIG.py -------------------------------------------------------------------------------- /callback/__init__.py: -------------------------------------------------------------------------------- 1 | from .jobs import Jobs 2 | from .misc import Misc 3 | from .restart import Restart 4 | from .stats import Stats 5 | -------------------------------------------------------------------------------- /callback/jobs.py: -------------------------------------------------------------------------------- 1 | from html import escape 2 | import logging 3 | 4 | from telegram.ext import CallbackContext 5 | from telegram.error import Unauthorized, BadRequest 6 | 7 | from common import get_list_of_py 8 | from const import CONFIG 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Jobs: 14 | @staticmethod 15 | def supervisor(context: CallbackContext): 16 | if "prev_bot_list" not in context.bot_data: 17 | # load list of running bots for the first time 18 | context.bot_data["prev_bot_list"] = { 19 | bot for bot in get_list_of_py(only_alias=True) 20 | } 21 | context.bot_data["current_bot_list"] = { 22 | bot for bot in get_list_of_py(only_alias=True) 23 | } 24 | else: 25 | # shift the "current" to "prev" and fetch "new" as "current" 26 | context.bot_data["prev_bot_list"] = context.bot_data["current_bot_list"] 27 | context.bot_data["current_bot_list"] = { 28 | bot for bot in get_list_of_py(only_alias=True) 29 | } 30 | 31 | failed_bots = ( 32 | context.bot_data["prev_bot_list"] - context.bot_data["current_bot_list"] 33 | ) 34 | if failed_bots: 35 | failed_bots_list = "\n\t".join(escape(bot) for bot in failed_bots) 36 | to_send = ( 37 | f"Attention, Master!" 38 | f"\nSome bots aren't running now and have escaped my hold, (compared to my previous list)" 39 | f"\n" 40 | f"\nAlias" 41 | f"\n" 42 | f"\n\t{failed_bots_list}" 43 | f"\n" 44 | f"\nI don't have the permission to start bots, please login to the VPS at your convenience" 45 | ) 46 | for admin in CONFIG.ADMINS: 47 | try: 48 | context.bot.send_message(chat_id=admin, text=to_send, parse_mode="HTML") 49 | except (Unauthorized, BadRequest): pass 50 | except Exception as e: 51 | logger.exception(f"{e}") 52 | -------------------------------------------------------------------------------- /callback/misc.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | from telegram.ext import CallbackContext, DispatcherHandlerStop, Filters 3 | 4 | from common import get_list_of_py 5 | from const import CONFIG, KeyboardMK 6 | 7 | 8 | class Misc: 9 | @staticmethod 10 | def block_access(update: Update, context: CallbackContext): 11 | # ignore service messages/where there is no effective_message 12 | if update.effective_message is None or Filters.status_update(update): 13 | raise DispatcherHandlerStop 14 | if update.effective_user.id not in CONFIG.ADMINS: 15 | update.effective_message.reply_html( 16 | """ 17 | Hello there, non-admin Human! 18 | I'm the MasterBot. I help admin in managing bots/programs in your server. 19 | 20 | What can I do? 21 | 1. Pull the latest update of a repo to server and restart appropriate bot 22 | 2. Monitor the server statistics 23 | 3. Notify about the failed/stopped bots 24 | """, 25 | reply_markup=KeyboardMK.repo(), 26 | ) 27 | raise DispatcherHandlerStop 28 | 29 | @staticmethod 30 | def start_command(update: Update, context: CallbackContext): 31 | update.effective_message.reply_html( 32 | """ 33 | Hello there, Admin! 34 | I'm the MasterBot. You already know what I do, hit /help for list of commands. 35 | """ 36 | ) 37 | 38 | @staticmethod 39 | def help_command(update: Update, context: CallbackContext): 40 | update.effective_message.reply_html( 41 | """ 42 | HELP 43 | 44 | 1. /get - Returns all the py programs running on server 45 | 2. /restart alias - Stops the program > Fetches the latest update from repo > Starts the program again 46 | 3. /stats - Gets statistics of CPU/Memory usages 47 | 4. /detail_stats - Gets detailed statistics of all processes 48 | """ 49 | ) 50 | 51 | @staticmethod 52 | def get_all(update: Update, context: CallbackContext): 53 | to_send = """ 54 | List of py processes running 55 | cmd filename alias 56 | 57 | """ 58 | for p in get_list_of_py(): 59 | to_send += "" + " ".join(arg for arg in p.cmdline()) + "\n" 60 | update.effective_message.reply_html(to_send) 61 | -------------------------------------------------------------------------------- /callback/restart.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update 3 | from telegram.ext import CallbackContext 4 | 5 | from common import get_full_info, kill_proc_tree, start_program, update_repo 6 | 7 | 8 | class Restart: 9 | @staticmethod 10 | def command( update: Update, context: CallbackContext ): 11 | if context.args: 12 | alias = context.args[0] 13 | 14 | elif update.callback_query is not None: 15 | alias = update.callback_query.data.split("_", maxsplit=1)[-1] 16 | update.effective_message.delete() # keep the chat clean 17 | else: 18 | return update.effective_message.reply_html( 19 | "Format: /restart alias " 20 | "\nUse /get to get all running py programs" 21 | ) 22 | 23 | process = get_full_info(alias) 24 | status_msg = update.effective_message.reply_html( 25 | f"Trying to restart the process {alias}...", quote=False 26 | ) 27 | if process: 28 | path = process.cwd() 29 | args = process.cmdline() 30 | try: 31 | kill_proc_tree(process.pid) 32 | 33 | status_msg = status_msg.edit_text( 34 | f"{status_msg.text_html}" 35 | f"\nBot killed successfully!" 36 | f"\nTrying to pull the latest commit...", 37 | parse_mode="HTML", 38 | ) 39 | result = update_repo(path) 40 | 41 | if result["exit-code"]!= 0: 42 | status_msg = status_msg.edit_text( 43 | f"""{status_msg.text_html} 44 | {result['output']} 45 | ❗️Failed to update the repo, restarting the bot as-is ...""", 46 | parse_mode="HTML") 47 | else: 48 | status_msg = status_msg.edit_text( 49 | f"""{status_msg.text_html} 50 | {result['output']} 51 | Trying to start the bot again...""", 52 | parse_mode="HTML", 53 | ) 54 | start_program(path=path, arg=" ".join(arg for arg in args)) 55 | status_msg.edit_text( 56 | f"{status_msg.text_html}\nStarted the bot", 57 | parse_mode="HTML", 58 | reply_markup=InlineKeyboardMarkup( 59 | [ 60 | [ 61 | InlineKeyboardButton( 62 | f"Restart {alias}", callback_data=f"restart_{alias}" 63 | ) 64 | ] 65 | ] 66 | ), 67 | ) 68 | except psutil.NoSuchProcess: 69 | update.effective_message.reply_html( 70 | "Looks like someone already killed it!" 71 | ) 72 | else: 73 | update.effective_message.reply_html(f"No process found under the alias {alias}", 74 | quote=False) -------------------------------------------------------------------------------- /callback/stats.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from time import time 3 | 4 | import psutil 5 | from telegram import Update 6 | from telegram.ext import CallbackContext 7 | 8 | from common import convert_to_GB, get_list_of_py, str_uptime 9 | from const import KeyboardMK 10 | 11 | 12 | class Stats: 13 | @staticmethod 14 | def command( update: Update, context: CallbackContext ): 15 | stat_msg = f""" 16 | Server Stats 17 | There are {len(list(get_list_of_py()))} python programs running in your server. 18 | 19 | CPU: {psutil.cpu_percent(interval=0.1)}% 20 | RAM: {psutil.virtual_memory().percent}% 21 | DISK: {convert_to_GB(psutil.disk_usage('/').used)}GB of {convert_to_GB(psutil.disk_usage('/').total)}GB used 22 | 23 | Running for {str_uptime(time() - psutil.boot_time())} 24 | Booted on: {datetime.fromtimestamp(psutil.boot_time()).strftime("%Y-%m-%d %H:%M:%S")} 25 | 26 | Stats as of {datetime.fromtimestamp(time()).strftime("%Y-%m-%d %H:%M:%S")} 27 | """ 28 | if update.callback_query: 29 | try: 30 | update.callback_query.edit_message_text( 31 | stat_msg, parse_mode="HTML", reply_markup=KeyboardMK.refresh_stats() 32 | ) 33 | except: # raises "BadRequest:Message not modified" if there's no change in stats 34 | pass 35 | else: 36 | update.effective_message.reply_text( 37 | stat_msg, parse_mode="HTML", reply_markup=KeyboardMK.refresh_stats() 38 | ) 39 | 40 | @staticmethod 41 | def detail_command( update: Update, context: CallbackContext ): 42 | to_send = """ 43 | Detailed stats of py processes 44 | Alias Threads Memory Usage 45 | 46 | """ 47 | for index, p in enumerate(get_list_of_py(), start=1): 48 | # Assume the last arg is Alias 49 | to_send += f"{index}. " + ( 50 | "" 51 | + " ".join( 52 | arg 53 | for arg in ( 54 | p.cmdline()[-1], 55 | str(p.num_threads()), 56 | str(round(p.memory_percent(), 2)) + "%", 57 | ) 58 | ) 59 | + "\n" 60 | ) 61 | update.effective_message.reply_html(to_send) 62 | -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import subprocess 3 | 4 | from humanize import naturaldelta 5 | import psutil 6 | from psutil import Process 7 | import os 8 | import signal 9 | 10 | 11 | def convert_to_GB( input_bytes ): 12 | return round(input_bytes / (1024 * 1024 * 1024), 1) 13 | 14 | 15 | def kill_proc_tree( 16 | pid, sig=signal.SIGTERM, include_parent=True, timeout=None, on_terminate=None 17 | ): 18 | """Kill a process tree (including grandchildren) with signal 19 | "sig" and return a (gone, still_alive) tuple. 20 | "on_terminate", if specified, is a callback function which is 21 | called as soon as a child terminates. 22 | """ 23 | if pid == os.getpid(): 24 | raise RuntimeError("I refuse to kill myself") 25 | parent = psutil.Process(pid) 26 | children = parent.children(recursive=True) 27 | if include_parent: 28 | children.append(parent) 29 | for p in children: 30 | p.send_signal(sig) 31 | gone, alive = psutil.wait_procs(children, timeout=timeout, callback=on_terminate) 32 | return (gone, alive) 33 | 34 | 35 | def get_list_of_py( only_alias=False ) -> [psutil.Process, str]: 36 | process_list: [psutil.Process] = psutil.process_iter() 37 | for p in process_list: 38 | if p.name().startswith("python") or p.name().startswith("telegram-bot-api"): 39 | if only_alias: 40 | try: 41 | yield p.cmdline()[-1] 42 | except IndexError: 43 | continue 44 | else: 45 | yield p 46 | 47 | 48 | def get_full_info( given_alias: str ) -> Process: 49 | for process in get_list_of_py(): 50 | if given_alias == process.cmdline()[-1]: # assume alias is the last arg 51 | return process 52 | 53 | 54 | def start_program( path, arg ): 55 | """ 56 | Activates the virtualenv 'env' inside path and executes the arg 57 | 58 | :param path: path of the program's dir 59 | :param arg: 60 | :return: 61 | """ 62 | cmd = f"source env/bin/activate; nohup {arg} &" 63 | subprocess.Popen(cmd, shell=True, cwd=path, executable="/bin/bash") 64 | 65 | 66 | def str_uptime( secs: float ): 67 | return naturaldelta(secs) 68 | 69 | 70 | def update_repo( path ): 71 | """ 72 | Fetches the latest update of the repo located at path 73 | 74 | :param path: 75 | :return: 76 | """ 77 | cmd = "git pull" 78 | try: 79 | return {"exit-code": 0, 80 | "output": subprocess.check_output( 81 | cmd, shell=True, cwd=path, 82 | executable="/bin/bash", universal_newlines=True) 83 | } 84 | except subprocess.CalledProcessError as e: 85 | return {"exit-code": e.returncode, "output": e.stderr} 86 | -------------------------------------------------------------------------------- /const/CONFIG.py: -------------------------------------------------------------------------------- 1 | class CONFIG: 2 | ADMINS = {1, 2} # UserIDs of the admin 3 | BOTTOKEN = "" 4 | IP_ADDR = "" # optional, used for setting webhook 5 | PORT_NUM = 0 # set 0 for polling the updates instead of webhook 6 | -------------------------------------------------------------------------------- /const/__init__.py: -------------------------------------------------------------------------------- 1 | from .CONFIG import CONFIG 2 | from .kb_mks import KeyboardMK 3 | -------------------------------------------------------------------------------- /const/kb_mks.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | 3 | 4 | class KeyboardMK: 5 | @staticmethod 6 | def refresh_stats() -> InlineKeyboardMarkup: 7 | return InlineKeyboardMarkup( 8 | [[InlineKeyboardButton("🔁 Refresh", callback_data="refresh")]] 9 | ) 10 | 11 | @staticmethod 12 | def repo() -> InlineKeyboardMarkup: 13 | return InlineKeyboardMarkup( 14 | [ 15 | [ 16 | InlineKeyboardButton( 17 | "See me in Action", 18 | url = "https://t.me/ys0seri0us_bots/19" 19 | ) 20 | ], 21 | [ 22 | InlineKeyboardButton( 23 | "🍴 Fork me", 24 | url="https://github.com/GauthamramRavichandran/MasterBot/", 25 | ) 26 | ] 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | import logging 3 | from logging.handlers import RotatingFileHandler 4 | from telegram import Update, BotCommand 5 | from telegram.ext import Updater, CommandHandler, TypeHandler, CallbackQueryHandler 6 | 7 | from callback import Jobs, Misc, Restart, Stats 8 | from const.CONFIG import CONFIG 9 | 10 | logging.basicConfig(handlers=[RotatingFileHandler("./logs.log", maxBytes=10000, backupCount=4)], 11 | level=logging.ERROR, 12 | format="[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s", 13 | datefmt="%Y-%m-%dT%H:%M:%S", ) 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | aps_logger = logging.getLogger('apscheduler') 18 | aps_logger.setLevel(logging.ERROR) 19 | 20 | 21 | def main(): 22 | updater = Updater(CONFIG.BOTTOKEN) 23 | dispatcher = updater.dispatcher 24 | job_q = updater.job_queue 25 | job_q.run_repeating( 26 | callback=Jobs.supervisor, interval=timedelta(minutes=5), first=5 27 | ) 28 | dispatcher.add_handler( 29 | TypeHandler(type=Update, callback=Misc.block_access), group=0 30 | ) 31 | dispatcher.add_handler(CommandHandler("start", Misc.start_command), group=1) 32 | dispatcher.add_handler(CommandHandler("restart", Restart.command), group=1) 33 | dispatcher.add_handler( 34 | CallbackQueryHandler(Restart.command, pattern="^restart"), group=1 35 | ) 36 | dispatcher.add_handler(CommandHandler("get", Misc.get_all), group=1) 37 | dispatcher.add_handler(CommandHandler("help", Misc.help_command), group=1) 38 | dispatcher.add_handler(CommandHandler("stats", Stats.command), group=1) 39 | dispatcher.add_handler( 40 | CommandHandler("detail_stats", Stats.detail_command), group=1 41 | ) 42 | dispatcher.add_handler( 43 | CallbackQueryHandler(Stats.command, pattern="refresh"), group=1 44 | ) 45 | updater.bot.set_my_commands( 46 | [ 47 | BotCommand("start", "start the bot"), 48 | BotCommand("restart", "restart a bot/script using alias"), 49 | BotCommand("get", "get all running py processes"), 50 | BotCommand("stats", "stats of the server"), 51 | BotCommand("detail_stats", "stats of the processes"), 52 | BotCommand("help", "help message"), 53 | ] 54 | ) 55 | if CONFIG.PORT_NUM != 0: 56 | updater.start_webhook( 57 | listen="127.0.0.1", port=CONFIG.PORT_NUM, url_path=CONFIG.BOTTOKEN 58 | ) 59 | updater.bot.set_webhook( 60 | url=f"https://{CONFIG.IP_ADDR}:443/{CONFIG.BOTTOKEN}", 61 | certificate=open("cert.pem", "rb"), 62 | ) 63 | else: 64 | updater.start_polling() 65 | updater.idle() 66 | 67 | 68 | if __name__ == "__main__": 69 | main() 70 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # MasterBot 2 | 3 | Control bots on your server. Easiest way to manage multiple bots. _Works for any .py scripts/bots, not just telegram bots_ 4 | 5 | ### Functions: 6 | 7 | 1. Pull the latest commit and restart the bot 8 | 2. Gets current statistics of the server 9 | 3. Notifies admins when a bot is either killed/stopped 10 | 11 | ### How to deploy? 12 | 1. Clone the repo 13 | `git clone https://github.com/GauthamramRavichandran/MasterBot` 14 | 2. Change directory 15 | 16 | `cd MasterBot` 17 | 3. Create a new virtual environment 18 | 19 | `virtualenv env` 20 | 5. Activate the virtual environment by, 21 | 22 | `source env/bin/activate` 23 | 6. Install the requirements, 24 | 25 | `pip3 install -r requirements.txt` 26 | 27 | 7. Fill in the `/const/CONFIG.py` file 28 | 8. Place the `cert.pem` file if bot wants to use webhook method 29 | 9. Start the master bot, `python main.py` 30 | 31 | ### Assumptions 32 | 1. All the bots should have a separate virtualenv (called env) within its folder 33 | 2. The last argument should be the alias (only alias will be used, not the name of the .py file) 34 | 35 | ### ⚠️ Known Issue 36 | Once the masterbot restarts anyother program, the new program will be under the masterbot process tree. 37 | If masterbot killed for any reason, all the programs started via this bot will be terminated too. 38 | 39 | One way to safe kill this bot is to send SIGTERM signal, which will terminate this bot while preserving its children to continue execution. 40 | 41 | #### How to send SIGTERM signal? 42 | 1. Open `htop` 43 | 2. Locate this bot by searching (F3 key) 44 | 3. F9 to kill > select SIGTERM 45 | 46 | ## DISCLAIMER 47 | 48 | THIS SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. YOU MAY USE THIS SOFTWARE AT YOUR OWN RISK. THE USE IS COMPLETE RESPONSIBILITY OF THE END-USER. THE DEVELOPERS ASSUME NO LIABILITY AND ARE NOT RESPONSIBLE FOR ANY MISUSE OR DAMAGE CAUSED BY THIS PROGRAM. 49 | 50 | 51 | **Please feel free to raise an issue here if you have any queries** 52 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | APScheduler==3.6.3 2 | certifi==2020.12.5 3 | cffi==1.14.4 4 | cryptography==3.3.1 5 | humanize==4.4.0 6 | psutil==5.8.0 7 | pycparser==2.20 8 | python-telegram-bot==13.2 9 | pytz==2021.1 10 | six==1.15.0 11 | tornado==6.1 12 | tzlocal==2.1 13 | --------------------------------------------------------------------------------