├── .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 |
--------------------------------------------------------------------------------