├── .gitignore ├── COMMAND_TEMPLATE.yml ├── Makefile ├── README.md ├── pdm.lock ├── pyproject.toml └── sysadmin_telebot ├── __init__.py ├── arguments.py ├── file_utils.py ├── log_utils.py ├── main.py ├── process_utils.py ├── telegram_utils.py └── thread_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | *.build 7 | *.dist 8 | 9 | # Environments 10 | *.swp 11 | .env 12 | .venv 13 | env/ 14 | venv/ 15 | ENV/ 16 | env.bak/ 17 | venv.bak/ 18 | .pytest_cache/ 19 | .idea/ 20 | .idea/** 21 | .DS_Store 22 | 23 | # local files 24 | local* 25 | local/** 26 | error* 27 | ERROR* 28 | *token* 29 | *.egg-info 30 | 31 | # pdm 32 | .pdm.toml 33 | __pypackages__ 34 | -------------------------------------------------------------------------------- /COMMAND_TEMPLATE.yml: -------------------------------------------------------------------------------- 1 | --- 2 | owner: "" 3 | 4 | constants: 5 | init_example: 6 | execute: "" 7 | prefix: "" 8 | suffix: "" 9 | sendto: 10 | - "" 11 | 12 | repeat_example: 13 | every: 5 14 | execute: "" 15 | prefix: "" 16 | suffix: "" 17 | sendto: 18 | - "" 19 | sleep: 0 20 | 21 | commands: 22 | example: 23 | reactto: "sysinfo" 24 | execute: "lshw" 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pdm install --prod 3 | 4 | init-dev: 5 | pdm install 6 | 7 | test: 8 | pdm run python ./sysadmin_telebot/main.py --log-level debug 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sysadmin Telegram Bot 2 | 3 | ### Introduction 4 | 5 | Because of some strange circumstances, I'm the de facto administrator for a Linux server in my workplace. 6 | 7 | I got sick of having to ssh into the server and manually check the system's status, so I decided to make this bot. Off the clock though, because I enjoyed the idea and wanted to have a project of my own. 8 | 9 | Now I can check on the server's status very quickly just by having this bot running on the server, prepared with well chosen informational commands that I can either trigger or have sent to me on an interval. 10 | 11 | ### How to set up 12 | 13 | 1. `cp ./sysadmin_telebot/COMMAND_TEMPLATE.yml ./sysadmin_telebot/local_config.yml` 14 | 15 | - Can also be any other name, so long as the formatting remains the same. Specify custom files with the `-c` argument. 16 | 17 | 2. In the format provided, add any shell commands into the `constants` section that you want to run on init or repeatedly on an interval. 18 | 19 | - every (int): How frequently, in seconds, to repeat the given command. If not provided, command is run at bot init and never again. Optional 20 | - prefix (str): Prepended to execute output. Optional 21 | - execute (str): Shell command that the bot will execute and send the result of to the user or chat specified in sendto. Required 22 | - suffix (str): appended to execute output. Optional 23 | - sendto (str, int): id of a user or chat to send the message to. Required 24 | 25 | 3. In the format provided, add any shell commands that you want users to be able to trigger into the `commands` section. 26 | - reactto (str): Name of command that the bot will react to in telegram. /reactto. Required 27 | - execute (str): Shell command that the bot will execute and send the result of to the user who triggered the command. Required 28 | 29 | ##### Configuring 30 | 31 | ```yml 32 | --- 33 | owner: "[your Telegram ID]" 34 | 35 | constants: 36 | init_example: # any name 37 | execute: "[command]" 38 | prefix: "This will appear on the line before your command output" # optional 39 | suffix: "This will appear on the line after your command output" # optional 40 | sendto: 41 | - "Any telegram user ID" 42 | - "Any number of these" 43 | - "Can also be a group ID if the bot is in the group" 44 | 45 | repeat_example: # any name 46 | every: [integer number of seconds] 47 | execute: "Command to run every number of seconds as specified" 48 | prefix: "This will appear on the line before your command output" # optional 49 | suffix: "This will appear on the line after your command output" # optional 50 | sendto: 51 | - "Any telegram user ID" 52 | - "Any number of these" 53 | - "Can also be a group ID if the bot is in the group" 54 | sleep: "" 55 | 56 | commands: 57 | example: # any name 58 | reactto: "sysinfo" # command user inputs 59 | execute: "lshw" # command run in shell 60 | ``` 61 | 62 | ### How to run 63 | 64 | 1. `pdm install` 65 | 2. `pdm run python ./sysadmin-telebot/main.py` 66 | 67 | Preferably, execute the bot inside of a screen or tmux session so that it can be hidden and re-captured by the executing user. 68 | 69 | ### Convenient testing tool 70 | 71 | You can go to `https://api.telegram.org/botYOUR_BOT_TOKEN/getUpdates` to see all the recent messages your bot has sent and received, as well as group additions/removals and other interactions. This way, 72 | if you lose access to your bot for some reason, you can still see what it's been doing, if it's still running. 73 | 74 | ### Important notes 75 | 76 | It is up to the person setting up the bot to create and use responsible commands. You can cause damage via the bot if you add dangerous commands to the bot's config. 77 | 78 | For this reason, I have not created any ability to add commands to the bot after init, nor to provide commands to the bot via telegram messages. 79 | 80 | ### Known issues 81 | 82 | If you provide an accessible command which has a long or indefinite runtime, the thread that command is executed in will stall, and that command will never complete. I'm fairly certain this does not cause the bot to stall across the board, but it will consume CPU time and memory in a form of memory leak. To my knowledge, this cannot be resolved; the user is responsible for providing non-destructive commands for the bot to be able to execute. 83 | 84 | If you stop the bot in the middle of a command execution, the process that was being executed will be orphaned until its natural completion and destruction, or indefinitely. To my knowledge, this cannot be resolved. 85 | 86 | ### Future additions 87 | 88 | - Create a "danger mode" option, which allows the user to execute any command via messaging the bot on telegram. 89 | 90 | - Activated via argv when starting the program 91 | - Triggers a blocking warning before the bot initializes, preventing the bot from starting until the user confirms their choice. 92 | 93 | - Allow importing bash scripts instead of writing directly into config 94 | 95 | - Add extensive debug logging 96 | 97 | - Allow scripts running on a time interval to "timeout" once they occur 98 | 99 | - For example: `dmesg | grep segfault` will only send a message if there is content in the command... 100 | - But you might not want to see the same message on every repeat, once you see it the first time! 101 | - So `timeout` will delay that command from executing again for a set period of time, likely by sleeping that thread. 102 | 103 | - Allow scripts running on a time interval to execute at a specific time, as opposed 104 | to on a given interval set by bot start 105 | - If this could be cron-like, that would be awesome 106 | 107 | ### Annotated Config 108 | 109 | TODO 110 | 111 | ```yml 112 | constants: # All commands which are run at init or on specified intervals, without user input, REQUIRED KEY 113 | init: # no every argument means to execute once at beginning of runtime 114 | execute: "hostname --long" # execute will be run as a shell command, and the output returned 115 | prefix: "Hi! Waking up on host " # will be attached before command output 116 | suffix: "\nHave a nice day!" # will be attached after command output 117 | sendto: "1" # a user or chat to send to, which the bot has permissions for 118 | 119 | another: # you can have more than one of these 120 | execute: "ls -la" # can be any shell command, including piped programs. 121 | prefix: "Current dir files: \n" # any special character is legal, probably? 122 | sendto: "1" 123 | 124 | conntest: # the names of keys are irrelevant 125 | every: 60 # measured in seconds 126 | execute: "ping -c 1 google.com" # if a given command takes a long time, outside the interval, 127 | sendto: "1" # it will only re-execute once instead of queueing many executions 128 | 129 | sysdata: # key names can be used for your own purposes 130 | every: 40 # 131 | execute: "uname -r" 132 | sendto: "1" 133 | 134 | commands: # all commands which can be triggered by users by the `reactto` command name, REQUIRED KEY 135 | info: # keynames here are still irrelevant 136 | reactto: "sysinfo" # reactto means the name of the command that must be executed to get the result 137 | execute: "lshw" # same as execute above, shell command to execute 138 | 139 | blocks: 140 | reactto: "blocks" 141 | execute: "lsblk" 142 | 143 | cpu: 144 | reactto: "processor" 145 | execute: "lscpu" 146 | ``` 147 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "APScheduler" 3 | version = "3.6.3" 4 | summary = "In-process task scheduler with Cron-like capabilities" 5 | dependencies = [ 6 | "pytz", 7 | "setuptools>=0.7", 8 | "six>=1.4.0", 9 | "tzlocal>=1.2", 10 | ] 11 | 12 | [[package]] 13 | name = "cachetools" 14 | version = "4.2.2" 15 | requires_python = "~=3.5" 16 | summary = "Extensible memoizing collections and decorators" 17 | 18 | [[package]] 19 | name = "certifi" 20 | version = "2021.10.8" 21 | summary = "Python package for providing Mozilla's CA Bundle." 22 | 23 | [[package]] 24 | name = "python-dotenv" 25 | version = "0.19.2" 26 | requires_python = ">=3.5" 27 | summary = "Read key-value pairs from a .env file and set them as environment variables" 28 | 29 | [[package]] 30 | name = "python-telegram-bot" 31 | version = "13.8.1" 32 | requires_python = ">=3.6" 33 | summary = "We have made you a wrapper you can't refuse" 34 | dependencies = [ 35 | "APScheduler==3.6.3", 36 | "cachetools==4.2.2", 37 | "certifi", 38 | "pytz>=2018.6", 39 | "tornado>=6.1", 40 | ] 41 | 42 | [[package]] 43 | name = "pytz" 44 | version = "2021.3" 45 | summary = "World timezone definitions, modern and historical" 46 | 47 | [[package]] 48 | name = "pytz-deprecation-shim" 49 | version = "0.1.0.post0" 50 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" 51 | summary = "Shims to make deprecation of pytz easier" 52 | dependencies = [ 53 | "tzdata; python_version >= \"3.6\"", 54 | ] 55 | 56 | [[package]] 57 | name = "pyyaml" 58 | version = "6.0" 59 | requires_python = ">=3.6" 60 | summary = "YAML parser and emitter for Python" 61 | 62 | [[package]] 63 | name = "setuptools" 64 | version = "59.3.0" 65 | requires_python = ">=3.6" 66 | summary = "Easily download, build, install, upgrade, and uninstall Python packages" 67 | 68 | [[package]] 69 | name = "six" 70 | version = "1.16.0" 71 | requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 72 | summary = "Python 2 and 3 compatibility utilities" 73 | 74 | [[package]] 75 | name = "tornado" 76 | version = "6.1" 77 | requires_python = ">= 3.5" 78 | summary = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." 79 | 80 | [[package]] 81 | name = "tzdata" 82 | version = "2021.5" 83 | requires_python = ">=2" 84 | summary = "Provider of IANA time zone data" 85 | 86 | [[package]] 87 | name = "tzlocal" 88 | version = "4.1" 89 | requires_python = ">=3.6" 90 | summary = "tzinfo object for the local timezone" 91 | dependencies = [ 92 | "pytz-deprecation-shim", 93 | "tzdata; platform_system == \"Windows\"", 94 | ] 95 | 96 | [metadata] 97 | lock_version = "3.1" 98 | content_hash = "sha256:3d6b28d874e9f2724b06746294a5fd8da961c42b497a5420e5b99119722eb46c" 99 | 100 | [metadata.files] 101 | "apscheduler 3.6.3" = [ 102 | {file = "APScheduler-3.6.3-py2.py3-none-any.whl", hash = "sha256:e8b1ecdb4c7cb2818913f766d5898183c7cb8936680710a4d3a966e02262e526"}, 103 | {file = "APScheduler-3.6.3.tar.gz", hash = "sha256:3bb5229eed6fbbdafc13ce962712ae66e175aa214c69bed35a06bffcf0c5e244"}, 104 | ] 105 | "cachetools 4.2.2" = [ 106 | {file = "cachetools-4.2.2-py3-none-any.whl", hash = "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001"}, 107 | {file = "cachetools-4.2.2.tar.gz", hash = "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"}, 108 | ] 109 | "certifi 2021.10.8" = [ 110 | {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, 111 | {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, 112 | ] 113 | "python-dotenv 0.19.2" = [ 114 | {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"}, 115 | {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"}, 116 | ] 117 | "python-telegram-bot 13.8.1" = [ 118 | {file = "python_telegram_bot-13.8.1-py3-none-any.whl", hash = "sha256:44603dad5182f366c576d8c4a1a8f1e45d294621d712ff34ef1ae1b1b14de05d"}, 119 | {file = "python-telegram-bot-13.8.1.tar.gz", hash = "sha256:b06691e55c35943267ee636d9aa702b3579155d2f32e0e2d6d7f11f0b5e727f0"}, 120 | ] 121 | "pytz 2021.3" = [ 122 | {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, 123 | {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, 124 | ] 125 | "pytz-deprecation-shim 0.1.0.post0" = [ 126 | {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"}, 127 | {file = "pytz_deprecation_shim-0.1.0.post0.tar.gz", hash = "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"}, 128 | ] 129 | "pyyaml 6.0" = [ 130 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 131 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 132 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 133 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 134 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 135 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 136 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 137 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 138 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 139 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 140 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 141 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 142 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 143 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 144 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 145 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 146 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 147 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 148 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 149 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 150 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 151 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 152 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 153 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 154 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 155 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 156 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 157 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 158 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 159 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 160 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 161 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 162 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 163 | ] 164 | "setuptools 59.3.0" = [ 165 | {file = "setuptools-59.3.0-py3-none-any.whl", hash = "sha256:10635f160e9e71546b883e337646567156a02808ecc0e7fafc12021deabf205e"}, 166 | {file = "setuptools-59.3.0.tar.gz", hash = "sha256:da27016d4b3b89cc9b672d93597520e44afa3f87a9aac32d3f084f37fa667ce7"}, 167 | ] 168 | "six 1.16.0" = [ 169 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 170 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 171 | ] 172 | "tornado 6.1" = [ 173 | {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"}, 174 | {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"}, 175 | {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"}, 176 | {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"}, 177 | {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"}, 178 | {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"}, 179 | {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"}, 180 | {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"}, 181 | {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"}, 182 | {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"}, 183 | {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"}, 184 | {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"}, 185 | {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"}, 186 | {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"}, 187 | {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"}, 188 | {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"}, 189 | {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"}, 190 | {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"}, 191 | {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"}, 192 | {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"}, 193 | {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"}, 194 | {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"}, 195 | {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"}, 196 | {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"}, 197 | {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"}, 198 | {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"}, 199 | {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"}, 200 | {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"}, 201 | {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"}, 202 | {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"}, 203 | {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"}, 204 | {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"}, 205 | {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"}, 206 | {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"}, 207 | {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"}, 208 | {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"}, 209 | {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"}, 210 | {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"}, 211 | {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"}, 212 | {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"}, 213 | {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, 214 | ] 215 | "tzdata 2021.5" = [ 216 | {file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"}, 217 | {file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"}, 218 | ] 219 | "tzlocal 4.1" = [ 220 | {file = "tzlocal-4.1-py3-none-any.whl", hash = "sha256:28ba8d9fcb6c9a782d6e0078b4f6627af1ea26aeaa32b4eab5324abc7df4149f"}, 221 | {file = "tzlocal-4.1.tar.gz", hash = "sha256:0f28015ac68a5c067210400a9197fc5d36ba9bc3f8eaf1da3cbd59acdfed9e09"}, 222 | ] 223 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "sysadmin_telebot" 3 | version = "0.1.0" 4 | description = "A Telegram bot for managing a server remotely" 5 | authors = [ 6 | {name = "Gregory Danielson", email = "gregdan3@protonmail.com"}, 7 | ] 8 | dependencies = [ 9 | "python-telegram-bot~=13.8", 10 | "python-dotenv~=0.19", 11 | "pyyaml~=6.0", 12 | ] 13 | requires-python = ">=3.9" 14 | license = {text = "GPL-3.0-or-later"} 15 | 16 | [project.urls] 17 | homepage = "" 18 | 19 | [build-system] 20 | requires = ["pdm-pep517"] 21 | build-backend = "pdm.pep517.api" 22 | 23 | [tool] 24 | [tool.pdm] 25 | -------------------------------------------------------------------------------- /sysadmin_telebot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregdan3/server-administration-bot/5a60b448d3d9864ebfef7de22e6903fa77aee0fb/sysadmin_telebot/__init__.py -------------------------------------------------------------------------------- /sysadmin_telebot/arguments.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | def BaseArgParser(): 5 | """Provides an argparse.ArgumentParser with some arguments pre-prepared. 6 | The object is provided, as opposed to the already parsed args, so that 7 | another user/script may configure the parser even further if necessary.""" 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument( 10 | "-c", 11 | "--config", 12 | dest="config", 13 | metavar="CONFIG_FILE", 14 | type=str, 15 | default="local_config.yml", 16 | help="Location of config file for all bot commands.", 17 | ) 18 | parser.add_argument( 19 | "--danger-mode", 20 | dest="danger_mode", 21 | default=False, 22 | action="store_true", 23 | help="Enable Danger Mode: bot can execute arbitrary commands.", 24 | ) 25 | parser.add_argument( 26 | "-l", 27 | "--log-level", 28 | dest="log_level", 29 | metavar="LEVEL", 30 | type=str.upper, 31 | choices=["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 32 | default="WARNING", 33 | help="Logging level to stdout.", 34 | ) 35 | parser.add_argument( 36 | "--log-file", 37 | dest="log_file", 38 | metavar="LOG_FILE", 39 | type=str, 40 | default="", 41 | help="Log file to write to. No file logging by default.", 42 | ) 43 | parser.add_argument( 44 | "--log-file-level", 45 | dest="log_file_level", 46 | metavar="LEVEL", 47 | type=str.upper, 48 | choices=["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 49 | default="WARNING", 50 | help="Logging level to file.", 51 | ) 52 | return parser # allow user to configure later 53 | -------------------------------------------------------------------------------- /sysadmin_telebot/file_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import yaml 5 | from dotenv import load_dotenv 6 | 7 | __all__ = ["load_yml_file", "TG_BOT_KEY"] 8 | _log = logging.getLogger(__name__) 9 | 10 | load_dotenv() 11 | TG_BOT_KEY = os.environ.get("TG_BOT_KEY") 12 | if not TG_BOT_KEY: 13 | _log.critical("No telegram bot key provided! Shutting down...") 14 | exit(1) 15 | 16 | 17 | def load_yml_file(filename): 18 | with open(filename, "r") as f: 19 | command = yaml.full_load(f) 20 | return command 21 | -------------------------------------------------------------------------------- /sysadmin_telebot/log_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | __all__ = ["init_logger"] 4 | _log = logging.getLogger(__name__) 5 | LOG_FORMAT = ( 6 | "[%(asctime)s] [%(filename)22s:%(lineno)-4s] [%(levelname)8s] %(message)s" 7 | ) 8 | 9 | 10 | def init_logger(log_level, log_file, log_file_level): 11 | logging.basicConfig(level=log_level, format=LOG_FORMAT) 12 | if log_file: 13 | file_hander = logging.FileHandler(log_file) 14 | file_hander.setLevel(log_file_level) 15 | file_hander.setFormatter(logging.Formatter(LOG_FORMAT)) 16 | logging.getLogger().addHandler(file_hander) 17 | -------------------------------------------------------------------------------- /sysadmin_telebot/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | import sys 4 | from functools import partial 5 | 6 | from telegram.bot import Bot 7 | from telegram.ext import CommandHandler, Filters, MessageHandler, Updater 8 | 9 | from sysadmin_telebot.arguments import BaseArgParser 10 | from sysadmin_telebot.file_utils import TG_BOT_KEY, load_yml_file 11 | from sysadmin_telebot.log_utils import init_logger 12 | from sysadmin_telebot.process_utils import get_command_out 13 | from sysadmin_telebot.telegram_utils import ( 14 | bot_command, 15 | handle_message_queue, 16 | help_command, 17 | thread_command, 18 | unknown_command, 19 | ) 20 | from sysadmin_telebot.thread_utils import create_message_thread, repeat_in_thread 21 | 22 | __all__ = [] 23 | _log = logging.getLogger(__name__) 24 | 25 | 26 | def prep_commands(bot, commands: dict): 27 | for _, command in commands.items(): 28 | reactto = command["reactto"] 29 | execute = command["execute"] 30 | _log.info("Adding command %s that executes %s", reactto, execute) 31 | # TODO: what if execute is very long 32 | bot.dispatcher.add_handler( 33 | CommandHandler( 34 | reactto, partial(bot_command, execute=partial(get_command_out, execute)) 35 | ) 36 | ) 37 | 38 | _log.info("Creating help command.") 39 | bot.dispatcher.add_handler( 40 | CommandHandler("help", partial(help_command, commands=commands)) 41 | ) 42 | 43 | _log.info("Creating out-command for unknown command usages") 44 | bot.dispatcher.add_handler(MessageHandler(Filters.command, unknown_command)) 45 | 46 | 47 | def prep_constants(constants: dict): 48 | for _, command in constants.items(): 49 | seconds = command.get("every", 0) 50 | sleep = command.get("sleep", 0) 51 | prefix = command.get("prefix", "") 52 | execute = command["execute"] 53 | suffix = command.get("suffix", "") 54 | sendto = command["sendto"] 55 | 56 | if seconds == 0: # init constants to run immediately 57 | _log.info("Executing %s, sending to %s", execute, sendto) 58 | thread_command(prefix, partial(get_command_out, execute), suffix, sendto) 59 | 60 | else: # constants to run regularly 61 | _log.info("Executing %s every %s, sending to %s", execute, seconds, sendto) 62 | repeat_in_thread( 63 | seconds, 64 | partial( 65 | thread_command, 66 | prefix, 67 | partial(get_command_out, execute), 68 | suffix, 69 | sendto, 70 | sleep, 71 | ), 72 | ) 73 | 74 | 75 | def main(argv): 76 | init_logger(argv.log_level, argv.log_file, argv.log_file_level) 77 | 78 | bot = Bot(TG_BOT_KEY) # handles Constants 79 | updater = Updater(TG_BOT_KEY, use_context=True) # handles Commands 80 | 81 | create_message_thread(handle_message_queue, bot) 82 | # every message from a thread instead goes to a Queue 83 | # and a single thread reads from that queue 84 | 85 | all_commands = load_yml_file(argv.config) 86 | 87 | constants = all_commands.get("constants", {}) 88 | commands = all_commands.get("commands", {}) 89 | 90 | if not constants: 91 | _log.warning("No constants provided; none will be loaded!") 92 | if not commands: 93 | _log.warning("No commands provided; none will be loaded!") 94 | 95 | prep_constants(constants) 96 | prep_commands(updater, commands) 97 | 98 | try: 99 | _log.info("Bot is ready! Starting polling.") 100 | updater.start_polling() 101 | updater.idle() 102 | except KeyboardInterrupt: 103 | _log.info("Shutting down updater thread.") 104 | updater.stop() 105 | 106 | 107 | if __name__ == "__main__": 108 | parser = BaseArgParser() 109 | argv = parser.parse_args() 110 | if argv.danger_mode: 111 | _log.warning( 112 | "YOU ARE STARTING THE BOT IN DANGER MODE! Are you SURE you want to continue?" 113 | ) 114 | _log.warning( 115 | "Users with access to the bot will be able to execute arbitrary commands on your system!" 116 | ) 117 | if input("Y/N: ").lower() not in {"y", "yes"}: 118 | _log.warning("Prompt not accepted. Shutting down.") 119 | sys.exit() 120 | 121 | main(argv) 122 | -------------------------------------------------------------------------------- /sysadmin_telebot/process_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | 4 | __all__ = ["get_command_out"] 5 | _log = logging.getLogger(__name__) 6 | 7 | 8 | def execute_command(*args, **kwargs): 9 | """shell=True in subprocess.Popen is considered unsafe 10 | it is up to the user to provide sensible commands to execute. 11 | 12 | KNOWN PROBLEM: 13 | A command which never outputs/sends an end of file 14 | will cause proc.communicate() to hang indefinitely.""" 15 | _log.debug("From execute_command: %s", args) 16 | proc = subprocess.Popen( 17 | args, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE 18 | ) 19 | return proc.communicate() 20 | 21 | 22 | def clean_output(stdout, stderr): 23 | """TODO: separate handling for stderr?""" 24 | return stdout.decode("UTF-8"), stderr.decode("UTF-8") 25 | 26 | 27 | def get_command_out(*args, **kwargs): 28 | stdout, stderr = execute_command(*args, **kwargs) 29 | _log.debug("From get_command_out:\n\t%s\n\t%s", stdout, stderr) 30 | return clean_output(stdout, stderr) 31 | -------------------------------------------------------------------------------- /sysadmin_telebot/telegram_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import queue 3 | import time 4 | 5 | import telegram 6 | from telegram import ParseMode 7 | 8 | _log = logging.getLogger(__name__) 9 | MESSAGES = queue.Queue() 10 | 11 | 12 | def format_backticks(s): 13 | return "```\n" + str(s) + "\n```" # TODO: gross formatting 14 | 15 | 16 | def help_command(update, context, commands={}): 17 | update.message.reply_text( 18 | format_backticks( 19 | {command["reactto"]: command["execute"] for _, command in commands.items()} 20 | ), 21 | parse_mode=ParseMode.MARKDOWN, 22 | ) 23 | 24 | 25 | def unknown_command(update, context): 26 | update.message.reply_text("Sorry, I didn't understand that command.") 27 | 28 | 29 | def bot_command(update, context, execute): 30 | """Used by telegram bot command registration""" 31 | stdout, stderr = execute() 32 | if not stdout: 33 | update.message.reply_text("No command output.") 34 | return 35 | message = format_backticks(stdout) 36 | update.message.reply_text(message, parse_mode=ParseMode.MARKDOWN) 37 | 38 | 39 | def thread_command(prefix, execute, suffix, sendto, sleep=0): 40 | """Used by a timed command thread to send messages to queue""" 41 | stdout, stderr = execute() 42 | if not stdout: 43 | return 44 | message = prefix + format_backticks(stdout) + suffix 45 | MESSAGES.put((sendto, message)) 46 | if sleep > 0 and message: 47 | time.sleep(sleep) 48 | 49 | 50 | def handle_message_queue(bot): 51 | """Consumes from message queue for sends""" 52 | _log.info("Consuming from message queue") 53 | while True: 54 | if MESSAGES.empty(): 55 | time.sleep(1) 56 | continue 57 | try: 58 | chat_ids, msg = MESSAGES.get() 59 | for chat_id in chat_ids: 60 | bot.sendMessage( 61 | chat_id=chat_id, text=msg, parse_mode=ParseMode.MARKDOWN 62 | ) 63 | MESSAGES.task_done() 64 | except telegram.error.RetryAfter: 65 | # TODO: time that Telegram tells us 66 | time.sleep(5) 67 | except telegram.error.TimedOut: 68 | time.sleep(5) 69 | # TODO: malformed command? 70 | -------------------------------------------------------------------------------- /sysadmin_telebot/thread_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | import traceback 5 | from functools import partial 6 | 7 | __all__ = ["create_message_thread", "repeat_in_thread"] 8 | _log = logging.getLogger(__name__) 9 | 10 | 11 | def create_message_thread(message_handler, bot): 12 | _log.debug("Opening message thread with handler %s, bot %s", message_handler, bot) 13 | message_thread = threading.Thread(target=partial(message_handler, bot)) 14 | message_thread.start() 15 | 16 | 17 | def every(delay, *args, **kwargs): 18 | """args is list of Callable 19 | kwargs is arguments to every Callable 20 | kwargs is redundant to use of functools.partial""" 21 | next_time = time.time() + delay 22 | while True: 23 | time.sleep(max(0, next_time - time.time())) 24 | try: 25 | for arg in args: 26 | arg(**kwargs) 27 | except Exception: 28 | traceback.print_exc() 29 | # intentionally eat exception 30 | # bot will not fail, error does not propogate 31 | # TODO: send errors to bot? 32 | # skip tasks if we are behind schedule: 33 | next_time += (time.time() - next_time) // delay * delay + delay 34 | 35 | 36 | def repeat_in_thread(seconds, *args, **kwargs): 37 | threading.Thread(target=lambda: every(seconds, *args, **kwargs)).start() 38 | --------------------------------------------------------------------------------