├── syno_bot ├── modules │ ├── helper │ │ ├── __init__.py │ │ ├── file_size.py │ │ ├── bot_decorator.py │ │ ├── conversation.py │ │ ├── string_processor.py │ │ └── user_status.py │ ├── __init__.py │ ├── sys_info.py │ └── download_station.py ├── __main__.py └── __init__.py ├── .gitignore ├── .dockerignore ├── .travis.yml ├── Makefile ├── entrypoint.sh ├── Dockerfile └── README.md /syno_bot/modules/helper/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | credentials.py 3 | build/ 4 | __pycache__/ 5 | dist/ 6 | synology_telegram.egg-info/ 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | credentials.py 3 | build/ 4 | __pycache__/ 5 | dist/ 6 | synology_telegram.egg-info/ 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: python 4 | 5 | before_install: 6 | - sudo apt-get -qq update 7 | - sudo apt-get -y install make 8 | - python --version 9 | 10 | script: 11 | - make build 12 | -------------------------------------------------------------------------------- /syno_bot/modules/helper/file_size.py: -------------------------------------------------------------------------------- 1 | def human_readable_size(size, decimal_places=2): 2 | for unit in ['B','KB','MB','GB','TB']: 3 | if size < 1024.0: 4 | break 5 | size /= 1024.0 6 | return f"{size:.{decimal_places}f} {unit}" 7 | -------------------------------------------------------------------------------- /syno_bot/modules/helper/bot_decorator.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from telegram import ChatAction 3 | 4 | def send_typing_action(func): 5 | """Sends typing action while processing func command.""" 6 | 7 | @wraps(func) 8 | def command_func(update, context, *args, **kwargs): 9 | context.bot.send_chat_action(chat_id=update.effective_message.chat_id, 10 | action=ChatAction.TYPING) 11 | return func(update, context, *args, **kwargs) 12 | 13 | return command_func 14 | -------------------------------------------------------------------------------- /syno_bot/modules/helper/conversation.py: -------------------------------------------------------------------------------- 1 | from syno_bot import dispatcher 2 | from telegram.ext import ConversationHandler 3 | 4 | def cancel_other_conversations(update, context): 5 | """Cancel other conversations so that it doesn't pick up response from new conversation""" 6 | all_hand = dispatcher.handlers 7 | for dict_group in all_hand: 8 | for handler in all_hand[dict_group]: 9 | if isinstance(handler, ConversationHandler): 10 | handler.update_state(ConversationHandler.END, handler._get_key(update)) 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | 3 | newtag ?= latest 4 | 5 | image = ivaniskandar/synology-telegram-bot 6 | 7 | build: 8 | docker build --no-cache=true -t "${image}:latest" . 9 | docker tag "${image}:latest" "${image}:${newtag}" 10 | 11 | buildtest: 12 | docker build -t "${image}:testing" . 13 | 14 | buildtestimage: 15 | docker build -t "${image}:testing" . 16 | docker save "${image}:testing" | gzip > testing.tar.gz 17 | 18 | push: 19 | docker push ${image}:${newtag} 20 | docker push ${image}:latest 21 | 22 | clean: 23 | -docker kill $(docker ps -q) 24 | -docker rm $(docker ps -a -q) 25 | -docker rmi $(docker images -a --filter=dangling=true -q) 26 | -------------------------------------------------------------------------------- /syno_bot/modules/helper/string_processor.py: -------------------------------------------------------------------------------- 1 | def escape_reserved_character(input): 2 | return (input.replace("-", "\\-") 3 | .replace("_", "\_") 4 | .replace(".", "\.") 5 | .replace("(", "\(") 6 | .replace(")", "\)") 7 | .replace("[", "\[") 8 | .replace("]", "\]") 9 | .replace("#", "\#") 10 | .replace("{", "\{") 11 | .replace("}", "\}") 12 | .replace("=", "\=") 13 | .replace("|", "\|") 14 | .replace("!", "\!") 15 | .replace(">", "\>") 16 | .replace("<", "\<")) 17 | -------------------------------------------------------------------------------- /syno_bot/modules/__init__.py: -------------------------------------------------------------------------------- 1 | from syno_bot import LOGGER 2 | 3 | def __list_all_modules(): 4 | from os.path import dirname, basename, isfile 5 | import glob 6 | # This generates a list of modules in this folder for the * in __main__ to work. 7 | mod_paths = glob.glob(dirname(__file__) + "/*.py") 8 | all_modules = [basename(f)[:-3] for f in mod_paths if isfile(f) 9 | and f.endswith(".py") 10 | and not f.endswith('__init__.py')] 11 | 12 | return all_modules 13 | 14 | ALL_MODULES = sorted(__list_all_modules()) 15 | LOGGER.info("Available modules: %s", str(ALL_MODULES)) 16 | 17 | # Commonly used constants 18 | ACTION_REPLY, ACTION_EDIT = range(2) 19 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Checkers 4 | if [[ -z "$BOT_TOKEN" ]]; then 5 | echo "BOT_TOKEN is not set" 6 | exit 1 7 | fi 8 | if [[ -z "$BOT_OWNER_ID" ]]; then 9 | echo "BOT_OWNER_ID is not set" 10 | exit 1 11 | fi 12 | if [[ -z "$NAS_IP" ]]; then 13 | echo "NAS_IP is not set" 14 | exit 1 15 | fi 16 | if [[ -z "$NAS_PORT" ]]; then 17 | echo "NAS_PORT is not set" 18 | exit 1 19 | fi 20 | if [[ -z "$DSM_ACCOUNT" ]]; then 21 | echo "DSM_ACCOUNT is not set" 22 | exit 1 23 | fi 24 | if [[ -z "$DSM_PASSWORD" ]]; then 25 | echo "DSM_PASSWORD is not set" 26 | exit 1 27 | fi 28 | 29 | echo "Finished preparations, starting..." 30 | /usr/bin/dumb-init pypy3 -m syno_bot 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jamiehewland/alpine-pypy:3.6-7.3.0-alpine3.11 2 | 3 | # Prepare build dependencies 4 | RUN apk add --no-cache --virtual .build-deps \ 5 | gcc \ 6 | git \ 7 | libc-dev \ 8 | libffi-dev \ 9 | openssl-dev \ 10 | python3-dev 11 | 12 | # Install Python dependencies 13 | RUN pip3 install --no-cache-dir --upgrade \ 14 | python-telegram-bot \ 15 | decorator \ 16 | git+https://github.com/nicholaschum/synology-api@Improving-code 17 | 18 | # Delete build dependencies 19 | RUN apk del .build-deps 20 | 21 | # Install runtime dependencies 22 | RUN apk add --no-cache --update \ 23 | dumb-init \ 24 | tzdata 25 | 26 | ENV BOT_TOKEN="" 27 | ENV BOT_OWNER_ID="" 28 | ENV NAS_IP="" 29 | ENV NAS_PORT="" 30 | ENV DSM_ACCOUNT="" 31 | ENV DSM_PASSWORD="" 32 | ENV TZ="GMT" 33 | 34 | COPY entrypoint.sh /entrypoint.sh 35 | COPY syno_bot /syno_bot 36 | ENTRYPOINT ["/entrypoint.sh"] 37 | -------------------------------------------------------------------------------- /syno_bot/modules/helper/user_status.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from syno_bot import BOT_OWNER_ID 3 | 4 | def user_owner(func): 5 | @wraps(func) 6 | def is_owner(update, context, *args, **kwargs): 7 | user = update.effective_user 8 | if str(user.id) == BOT_OWNER_ID: 9 | return func(update, context, *args, **kwargs) 10 | elif not user: 11 | pass 12 | else: 13 | update.message.reply_text("I have no obligation to serve you.") 14 | 15 | return is_owner 16 | 17 | 18 | def user_pm(func): 19 | @wraps(func) 20 | def is_pm(update, context, *args, **kwargs): 21 | chat = update.effective_chat 22 | if chat.type == "private": 23 | return func(update, context, *args, **kwargs) 24 | elif not chat: 25 | pass 26 | else: 27 | update.message.reply_text("I'm afraid to tell you that it's a PM only command.") 28 | 29 | return is_pm 30 | -------------------------------------------------------------------------------- /syno_bot/__main__.py: -------------------------------------------------------------------------------- 1 | from syno_bot import dispatcher, updater, LOGGER, START_MESSAGE 2 | from syno_bot.modules.helper.user_status import user_owner 3 | from syno_bot.modules.helper.string_processor import escape_reserved_character 4 | from telegram import ParseMode 5 | from telegram.ext import CommandHandler, MessageHandler, Filters 6 | 7 | @user_owner 8 | def start(update, context): 9 | update.message.reply_text(text=escape_reserved_character(START_MESSAGE), 10 | parse_mode=ParseMode.MARKDOWN_V2) 11 | 12 | 13 | @user_owner 14 | def cancel(update, context): 15 | # Default cancel response 16 | update.message.reply_text("What to cancel? I'm not doing anything right now.") 17 | 18 | 19 | @user_owner 20 | def unknown(update, context): 21 | update.message.reply_text("I'm sorry, I don't understand what were you trying to tell me.") 22 | 23 | 24 | def error(update, context): 25 | try: 26 | raise context.error 27 | except Exception as e: 28 | LOGGER.critical(e, exc_info=True) 29 | 30 | def main(): 31 | dispatcher.add_handler(CommandHandler("start", start)) 32 | dispatcher.add_handler(CommandHandler("cancel", cancel)) 33 | dispatcher.add_handler(MessageHandler(Filters.command, unknown)) 34 | 35 | # log all errors 36 | dispatcher.add_error_handler(error) 37 | 38 | # Start the Bot 39 | updater.start_polling() 40 | LOGGER.info("Bot started successfully. Have a nice day.") 41 | 42 | # Run the bot until you press Ctrl-C or the process receives SIGINT, 43 | # SIGTERM or SIGABRT. This should be used most of the time, since 44 | # start_polling() is non-blocking and will stop the bot gracefully. 45 | updater.idle() 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ivaniskandar/synology-telegram-bot.svg?branch=master)](https://travis-ci.org/ivaniskandar/synology-telegram-bot) 2 | 3 | # Telegram Bot for Synology DiskStation Manager (DSM) 4 | 5 | A Docker image based on Alpine Linux that runs [Telegram](https://telegram.org) bot to manage a Synology DiskStation machine. 6 | 7 | ## Features 8 | 9 | Currently support these functions: 10 | 11 | ### Download Station 12 | * `/mydownloads` - manage your downloads 13 | * `/adddownload` - create a new download task 14 | * `/resumedownloads` - resume all inactive download tasks 15 | * `/pausedownloads` - pause all active download tasks 16 | * `/cleanupdownloads` - clear completed download tasks 17 | 18 | ### System Info 19 | * `/resourcemonitor` - show NAS resource infos 20 | * `/nasnetwork` - show NAS network status 21 | * `/nashealth` - show NAS health status 22 | * `/bothealth` - show bot health status 23 | 24 | ## Environment Variables 25 | 26 | Make sure to set the container environment variables: 27 | 28 | * `BOT_TOKEN` - Your bot's token. Make one with [@BotFather](https://telegram.me/BotFather) 29 | * `BOT_OWNER_ID` - The bot will only respond to user with this ID, get it from [@userinfobot](https://telegram.me/userinfobot) 30 | * `NAS_IP` - Your DiskStation's IP address. Make sure it uses a static IP 31 | * `NAS_PORT` - Your DiskStation's port number. DSM default is 5000 32 | * `DSM_ACCOUNT` - Your DSM account name 33 | * `DSM_PASSWORD` - Your DSM password 34 | * `TZ` - System time zone. See available time zones [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). 35 | 36 | ## Resources 37 | 38 | Code available on [GitHub](https://github.com/ivaniskandar/synology-telegram-bot) 39 | 40 | Image available on [Docker Hub](https://hub.docker.com/repository/docker/ivaniskandar/synology-telegram-bot) 41 | -------------------------------------------------------------------------------- /syno_bot/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | import os 4 | import ssl 5 | import sys 6 | 7 | from telegram.ext import Updater 8 | 9 | # Enable logging 10 | logging.basicConfig( 11 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 12 | level=logging.INFO) 13 | 14 | LOGGER = logging.getLogger(__name__) 15 | 16 | # Prevent SSL invocation complaints 17 | ssl._create_default_https_context = ssl._create_unverified_context 18 | 19 | try: 20 | from .credentials import (SYNOLOGY_NAS_BOT_TOKEN, SYNOLOGY_NAS_BOT_OWNER, 21 | SYNOLOGY_NAS_BOT_IP, SYNOLOGY_NAS_BOT_PORT, 22 | SYNOLOGY_NAS_BOT_ACCOUNT, SYNOLOGY_NAS_BOT_PASSWORD) 23 | LOGGER.info("Importing credentials from file") 24 | BOT_TOKEN = SYNOLOGY_NAS_BOT_TOKEN 25 | BOT_OWNER_ID = SYNOLOGY_NAS_BOT_OWNER 26 | NAS_IP = SYNOLOGY_NAS_BOT_IP 27 | NAS_PORT = SYNOLOGY_NAS_BOT_PORT 28 | DSM_ACCOUNT = SYNOLOGY_NAS_BOT_ACCOUNT 29 | DSM_PASSWORD = SYNOLOGY_NAS_BOT_PASSWORD 30 | except ModuleNotFoundError: 31 | LOGGER.info("Importing credentials from env") 32 | BOT_TOKEN = os.environ.get("BOT_TOKEN") 33 | BOT_OWNER_ID = os.environ.get("BOT_OWNER_ID") 34 | NAS_IP = os.environ.get("NAS_IP") 35 | NAS_PORT = os.environ.get("NAS_PORT") 36 | DSM_ACCOUNT = os.environ.get("DSM_ACCOUNT") 37 | DSM_PASSWORD = os.environ.get("DSM_PASSWORD") 38 | 39 | LOGGER.info("Starting bot for {0}@{1}:{2}".format(DSM_ACCOUNT, NAS_IP, NAS_PORT)) 40 | 41 | updater = Updater(BOT_TOKEN, use_context=True) 42 | 43 | dispatcher = updater.dispatcher 44 | 45 | START_MESSAGE = """Hi! I can hear (or read?) you clearly and I'm ready to do my job. 46 | 47 | You can control me by sending these commands: 48 | 49 | """ 50 | 51 | # Needed values are set, time load load the modules 52 | from syno_bot.modules import ALL_MODULES 53 | for module_name in ALL_MODULES: 54 | imported_module = importlib.import_module("syno_bot.modules." + module_name) 55 | if hasattr(imported_module, "__help__") and imported_module.__help__: 56 | START_MESSAGE += imported_module.__help__ 57 | -------------------------------------------------------------------------------- /syno_bot/modules/sys_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | 4 | from synology_api.sys_info import SysInfo, DSM, Storage 5 | from syno_bot import dispatcher, NAS_IP, NAS_PORT, DSM_ACCOUNT, DSM_PASSWORD 6 | from syno_bot.modules import ACTION_EDIT, ACTION_REPLY 7 | from syno_bot.modules.helper.bot_decorator import send_typing_action 8 | from syno_bot.modules.helper.conversation import cancel_other_conversations 9 | from syno_bot.modules.helper.file_size import human_readable_size 10 | from syno_bot.modules.helper.user_status import user_owner 11 | from syno_bot.modules.helper.string_processor import escape_reserved_character 12 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ParseMode 13 | from telegram.ext import CallbackQueryHandler, CommandHandler, ConversationHandler 14 | 15 | sys_info = SysInfo.login(DSM_ACCOUNT, DSM_PASSWORD, NAS_IP, NAS_PORT) 16 | dsm = DSM.login(DSM_ACCOUNT, DSM_PASSWORD, NAS_IP, NAS_PORT) 17 | strg = Storage.login(DSM_ACCOUNT, DSM_PASSWORD, NAS_IP, NAS_PORT) 18 | 19 | RELOADABLE_STATE = 1 20 | 21 | @user_owner 22 | @send_typing_action 23 | def __nas_network_status(update, context): 24 | cancel_other_conversations(update, context) 25 | __show_nas_network_status(update.message) 26 | return RELOADABLE_STATE 27 | 28 | 29 | @user_owner 30 | @send_typing_action 31 | def __resource_monitor(update, context): 32 | cancel_other_conversations(update, context) 33 | __show_resource_monitor(update.message) 34 | return RELOADABLE_STATE 35 | 36 | 37 | @user_owner 38 | @send_typing_action 39 | def __nas_health_status(update, context): 40 | cancel_other_conversations(update, context) 41 | __show_nas_health_status(update.message) 42 | return RELOADABLE_STATE 43 | 44 | 45 | @user_owner 46 | @send_typing_action 47 | def __bot_health_status(update, context): 48 | cancel_other_conversations(update, context) 49 | update.message.reply_text("The fact that I can reply to you means I'm good! Thanks for asking.") 50 | 51 | 52 | @user_owner 53 | def __reload_reloadable(update, context): 54 | query = update.callback_query 55 | data = query.data 56 | query.answer() 57 | if data == "resource_monitor": 58 | __show_resource_monitor(query, ACTION_EDIT) 59 | elif data == "nas_health": 60 | __show_nas_health_status(query, ACTION_EDIT) 61 | 62 | 63 | def __show_nas_network_status(message, action=ACTION_REPLY): 64 | network_status = sys_info.network_status() 65 | vpn_status = sys_info.network_vpn_pptp() # we can add the openVPN checks 66 | server_name = network_status["data"]["server_name"] 67 | dns_primary = network_status["data"]["dns_primary"] 68 | dns_secondary = network_status["data"]["dns_secondary"] 69 | ip = network_status["data"]["gateway_info"]["ip"] 70 | vpn = vpn_status["data"] 71 | 72 | reply_text = "*Server Name:*` {0}`\n".format(server_name) 73 | # DNS Primary is a must, or else there's no Telegram Bot 74 | reply_text += "*DNS Primary:*` {0}`\n".format(dns_primary) 75 | if len(dns_secondary) > 0: 76 | reply_text += "*DNS Secondary:*` {0}`\n".format(dns_secondary) 77 | # We should be able to get at least one ip 78 | reply_text += "*IP Address:*` {0}`\n".format(ip) 79 | if len(vpn) > 0: 80 | reply_text += "*VPN:*` Yes`\n" 81 | else: 82 | reply_text += "*VPN:*` No`\n" 83 | 84 | keyboard = [[InlineKeyboardButton("Reload", callback_data="nas_network")]] 85 | reply_markup = InlineKeyboardMarkup(keyboard) 86 | if action == ACTION_EDIT: 87 | message.edit_message_text(text=escape_reserved_character(reply_text), 88 | parse_mode=ParseMode.MARKDOWN_V2, 89 | reply_markup=reply_markup) 90 | else: 91 | message.reply_text(text=escape_reserved_character(reply_text), 92 | parse_mode=ParseMode.MARKDOWN_V2, 93 | reply_markup=reply_markup) 94 | 95 | 96 | def __show_resource_monitor(message, action=ACTION_REPLY): 97 | util = sys_info.utilisation()["data"] 98 | 99 | user_load = util["cpu"]["user_load"] 100 | system_load = util["cpu"]["system_load"] 101 | other_load = util["cpu"]["other_load"] 102 | reply_text = "*CPU Utilization*\n" 103 | reply_text += "`User : {}%`\n".format(user_load) 104 | reply_text += "`System : {}%`\n".format(user_load) 105 | reply_text += "`I/O Wait : {}%`\n\n".format(user_load) 106 | 107 | real_usage = util["memory"]["real_usage"] 108 | swap_usage = util["memory"]["swap_usage"] 109 | reply_text += "*Memory Utilization*\n" 110 | reply_text += "`Physical : {}%`\n".format(real_usage) 111 | reply_text += "`Swap : {}%`\n\n".format(swap_usage) 112 | 113 | total_disk_utilization = util["disk"]["total"]["utilization"] 114 | disks = util["disk"]["disk"] 115 | reply_text += "*Disk Utilization*\n" 116 | disks_util = {} 117 | for disk in disks: 118 | display_name = disk["display_name"] 119 | utilization = disk["utilization"] 120 | disks_util[display_name] = utilization 121 | disks_util.update({"Total": total_disk_utilization}) 122 | longest_display_name_length = 0 123 | for key in disks_util: 124 | length = len(key) 125 | if length > longest_display_name_length: 126 | longest_display_name_length = length 127 | longest_display_name_length += 1 128 | for name, util in disks_util.items(): 129 | name_padded = name 130 | for i in range(longest_display_name_length - len(name)): 131 | name_padded += " " 132 | reply_text += "`{0}: {1}%`\n".format(name_padded, util) 133 | 134 | update_time = time.strftime("%d %B %Y %H:%M:%S", time.localtime(time.time())) 135 | reply_text += "\nLast update: {}".format(update_time) 136 | 137 | keyboard = [[InlineKeyboardButton("Reload", callback_data="resource_monitor")]] 138 | reply_markup = InlineKeyboardMarkup(keyboard) 139 | if action == ACTION_EDIT: 140 | message.edit_message_text(text=escape_reserved_character(reply_text), 141 | parse_mode=ParseMode.MARKDOWN_V2, 142 | reply_markup=reply_markup) 143 | else: 144 | message.reply_text(text=escape_reserved_character(reply_text), 145 | parse_mode=ParseMode.MARKDOWN_V2, 146 | reply_markup=reply_markup) 147 | 148 | 149 | def __show_nas_health_status(message, action=ACTION_REPLY): 150 | dsm_info = dsm.get_info()["data"] 151 | storage = strg.storage()["data"] 152 | update_available = sys_info.sys_upgrade_check()["data"]["update"]["available"] 153 | 154 | version = dsm_info["version_string"] 155 | temperature = dsm_info["temperature"] 156 | temperature_warn = dsm_info["temperature_warn"] 157 | temperature_status = "Not Good" if temperature_warn else "Good" 158 | update_status = "Update available" if update_available else "Latest" 159 | reply_text = "*Status*\n" 160 | reply_text += "`DSM Version : {0} ({1})`\n".format(version[4:], update_status) 161 | reply_text += "`System Temperature : {0} °C ({1})`\n".format(temperature, temperature_status) 162 | 163 | reply_text += "\n*Storage Volume*\n" 164 | volumes = storage["volumes"] 165 | for volume in volumes: 166 | id = volume["id"].capitalize().replace("_", " ") 167 | status = volume["status"].capitalize() 168 | total = int(volume["size"]["total"]) 169 | used = int(volume["size"]["used"]) 170 | available = total - used 171 | reply_text += "`{0} ({1})`\n".format(id, status) 172 | reply_text += "`├─Total : {}`\n".format(human_readable_size(total)) 173 | reply_text += "`├─Used : {}`\n".format(human_readable_size(used)) 174 | reply_text += "`└─Available : {}`\n".format(human_readable_size(available)) 175 | 176 | reply_text += "\n*Uptime*\n" 177 | uptime_seconds = int(dsm_info["uptime"]) 178 | uptime_day = uptime_seconds // (24 * 3600) 179 | uptime_seconds = uptime_seconds % (24 * 3600) 180 | uptime_hour = uptime_seconds // 3600 181 | uptime_seconds %= 3600 182 | uptime_minutes = uptime_seconds // 60 183 | uptime_seconds %= 60 184 | seconds = uptime_seconds 185 | 186 | processed_date_reply = str() 187 | if uptime_day == 1: 188 | processed_date_reply = "{0} day ".format(uptime_day) 189 | elif uptime_day > 0: 190 | processed_date_reply = "{0} days ".format(uptime_day) 191 | 192 | reply_text += ("`{0}{1}:{2}:{3}`".format(processed_date_reply, 193 | "{0:0=2d}".format(int(uptime_hour)), 194 | "{0:0=2d}".format(int(uptime_minutes)), 195 | "{0:0=2d}".format(int(seconds)))) 196 | 197 | update_time = time.strftime("%d %B %Y %H:%M:%S", time.localtime(time.time())) 198 | reply_text += "\n\nLast update: {}".format(update_time) 199 | 200 | keyboard = [[InlineKeyboardButton("Reload", callback_data="nas_health")]] 201 | reply_markup = InlineKeyboardMarkup(keyboard) 202 | if action == ACTION_EDIT: 203 | message.edit_message_text(text=escape_reserved_character(reply_text), 204 | parse_mode=ParseMode.MARKDOWN_V2, 205 | reply_markup=reply_markup) 206 | else: 207 | message.reply_text(text=escape_reserved_character(reply_text), 208 | parse_mode=ParseMode.MARKDOWN_V2, 209 | reply_markup=reply_markup) 210 | 211 | 212 | nas_network_handler = ConversationHandler( 213 | entry_points=[CommandHandler("nasnetwork", __nas_network_status)], 214 | states={ 215 | RELOADABLE_STATE: [CallbackQueryHandler(__reload_reloadable)] 216 | }, 217 | fallbacks=[], 218 | allow_reentry=True 219 | ) 220 | resource_monitor_handler = ConversationHandler( 221 | entry_points=[CommandHandler("resourcemonitor", __resource_monitor)], 222 | states={ 223 | RELOADABLE_STATE: [CallbackQueryHandler(__reload_reloadable)] 224 | }, 225 | fallbacks=[], 226 | allow_reentry=True 227 | ) 228 | nas_health_handler = ConversationHandler( 229 | entry_points=[CommandHandler("nashealth", __nas_health_status)], 230 | states={ 231 | RELOADABLE_STATE: [CallbackQueryHandler(__reload_reloadable)] 232 | }, 233 | fallbacks=[], 234 | allow_reentry=True 235 | ) 236 | bot_health_handler = CommandHandler("bothealth", __bot_health_status) 237 | 238 | dispatcher.add_handler(nas_network_handler) 239 | dispatcher.add_handler(resource_monitor_handler) 240 | dispatcher.add_handler(nas_health_handler) 241 | dispatcher.add_handler(bot_health_handler) 242 | 243 | __help__ = """*System Info* 244 | /resourcemonitor - show NAS resource infos 245 | /nasnetwork - show NAS network status 246 | /nashealth - show NAS health status 247 | /bothealth - show bot health status 248 | 249 | """ 250 | -------------------------------------------------------------------------------- /syno_bot/modules/download_station.py: -------------------------------------------------------------------------------- 1 | from math import ceil 2 | from synology_api.downloadstation import DownloadStation 3 | from syno_bot import dispatcher, NAS_IP, NAS_PORT, DSM_ACCOUNT, DSM_PASSWORD 4 | from syno_bot.modules import ACTION_EDIT, ACTION_REPLY 5 | from syno_bot.modules.helper.bot_decorator import send_typing_action 6 | from syno_bot.modules.helper.conversation import cancel_other_conversations 7 | from syno_bot.modules.helper.file_size import human_readable_size 8 | from syno_bot.modules.helper.user_status import user_owner 9 | from syno_bot.modules.helper.string_processor import escape_reserved_character 10 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup, MessageEntity, ParseMode 11 | from telegram.ext import (CallbackQueryHandler, CommandHandler, ConversationHandler, Filters, 12 | MessageHandler) 13 | from telegram.ext.dispatcher import run_async 14 | from telegram.utils.helpers import escape_markdown 15 | 16 | import telegram 17 | import time 18 | 19 | instance = DownloadStation.login(DSM_ACCOUNT, DSM_PASSWORD, NAS_IP, NAS_PORT) 20 | 21 | DOCUMENT_OR_LINK = range(1) 22 | 23 | MAIN_PAGE, DETAIL_PAGE, REMOVE_CONFIRMATION_PAGE = range(3) 24 | 25 | FIRST, PREVIOUS, NEXT, LAST = range(4) 26 | PAGE_LIMIT = 5 27 | TASK_PAGE_EMPTY_STATE = "You have no download task. Use /adddownload to create a new one." 28 | TASK_PAGE_HEADER_TEMPLATE = "Page *{0}* of *{1}* - You have *{2}* download task" 29 | TASK_LIST_TEMPLATE = """*Task #{0}:*` {1}` 30 | *Status:*` {2} ({3:.2f}%)` 31 | 32 | """ 33 | TASK_PAGE_FOOTER = "Choose a task from the list below to see its details:" 34 | PAGE_CALLBACK_DATA = "page_" 35 | DETAILS_CALLBACK_DATA = "details_" 36 | 37 | DETAIL_RESUME = "resume_" 38 | DETAIL_PAUSE = "pause_" 39 | DETAIL_REMOVE = "remove_" 40 | DETAIL_BACK = "back" 41 | 42 | CONFIRMATION_PAGE_TEXT_TEMPLATE = "Remove task for `{0}`?" 43 | CONFIRMATION_YES = "y_" 44 | CONFIRMATION_NO = "n_" 45 | 46 | RELOAD = "RELOAD_" 47 | 48 | def __get_task_list_size(): 49 | return instance.tasks_list()["data"]["total"] 50 | 51 | 52 | def __get_task_title(task_id): 53 | return instance.tasks_info(task_id)["data"]["tasks"][0]["title"] 54 | 55 | 56 | @send_typing_action 57 | def __add_download_link(update, context): 58 | message = update.message 59 | if message.document: 60 | link = message.document.get_file().file_path 61 | elif message.audio: 62 | link = message.audio.get_file().file_path 63 | elif message.photo: 64 | i_width = -1 65 | for photo_size in message.photo: 66 | if photo_size.width > i_width: 67 | i_width = photo_size.width 68 | to_download = photo_size 69 | 70 | if not to_download: 71 | message.reply_text("Failed to get item. Please try again.") 72 | return ConversationHandler.END 73 | 74 | link = to_download.get_file().file_path 75 | elif message.video: 76 | link = message.video.get_file().file_path 77 | else: 78 | link = message.text 79 | 80 | if __handle_link(update.message, link): 81 | return ConversationHandler.END 82 | else: 83 | return DOCUMENT_OR_LINK 84 | 85 | 86 | def __handle_link(message, link): 87 | try: 88 | instance.tasks_create(link) 89 | message.reply_text("Item added successfully.") 90 | return True 91 | except: 92 | message.reply_text("Failed to add item. Please try again.") 93 | return False 94 | 95 | 96 | def __cancel(update, context): 97 | update.message.reply_text("Operation cancelled.") 98 | return ConversationHandler.END 99 | 100 | 101 | def __download_list_data(message, page_number=1, action=ACTION_REPLY): 102 | # List 5 tasks per page 103 | total_tasks_size = __get_task_list_size() 104 | if total_tasks_size == 0: 105 | message.reply_text(TASK_PAGE_EMPTY_STATE) 106 | return ConversationHandler.END 107 | 108 | total_page = ceil(total_tasks_size / PAGE_LIMIT) 109 | offset_val = (page_number - 1) * PAGE_LIMIT 110 | task_list = instance.tasks_list(offset=offset_val, limit=PAGE_LIMIT) 111 | reply_text = TASK_PAGE_HEADER_TEMPLATE.format(page_number, total_page, total_tasks_size) 112 | if total_tasks_size > 1: 113 | reply_text += "s" 114 | 115 | reply_text += ".\n\n" 116 | buttons = [] 117 | task_number = offset_val + 1 118 | for current in task_list["data"]["tasks"]: 119 | progress = 0 120 | if current["size"] != 0: 121 | progress = current["additional"]["transfer"]["size_downloaded"] / current["size"] * 100 122 | 123 | reply_text += TASK_LIST_TEMPLATE.format(task_number, 124 | current["title"], 125 | current["status"].capitalize(), 126 | progress) 127 | buttons.append(InlineKeyboardButton(task_number, 128 | callback_data=DETAILS_CALLBACK_DATA + current["id"])) 129 | task_number += 1 130 | 131 | previous_page = page_number - 1 132 | if previous_page < 1: 133 | previous_page = 1 134 | 135 | next_page = page_number + 1 136 | if next_page > total_page: 137 | next_page = total_page 138 | 139 | reply_text += TASK_PAGE_FOOTER 140 | keyboard = [buttons] 141 | if total_page > 1: 142 | keyboard.append( 143 | [InlineKeyboardButton("<<", callback_data=PAGE_CALLBACK_DATA + "1"), 144 | InlineKeyboardButton("<", callback_data=PAGE_CALLBACK_DATA + str(previous_page)), 145 | InlineKeyboardButton(">", callback_data=PAGE_CALLBACK_DATA + str(next_page)), 146 | InlineKeyboardButton(">>", callback_data=PAGE_CALLBACK_DATA + str(total_page))] 147 | ) 148 | 149 | keyboard.append([InlineKeyboardButton("Reload", callback_data=RELOAD + str(page_number))]) 150 | reply_markup = InlineKeyboardMarkup(keyboard) 151 | if action == ACTION_REPLY: 152 | message.reply_text(text=escape_reserved_character(reply_text), 153 | parse_mode=ParseMode.MARKDOWN_V2, 154 | reply_markup=reply_markup) 155 | elif action == ACTION_EDIT: 156 | message.edit_message_text(text=escape_reserved_character(reply_text), 157 | parse_mode=ParseMode.MARKDOWN_V2, 158 | reply_markup=reply_markup) 159 | 160 | 161 | def __list_page_change(update, context): 162 | query = update.callback_query 163 | query.answer() 164 | page_number = int(query.data.split("_")[1]) 165 | __download_list_data(query, page_number=page_number, action=ACTION_EDIT) 166 | 167 | 168 | def __open_details_page(update, context): 169 | query = update.callback_query 170 | task_id = query.data.split(DETAILS_CALLBACK_DATA)[1] 171 | __show_details_page(query, task_id) 172 | return DETAIL_PAGE 173 | 174 | 175 | def __show_details_page(query, task_id): 176 | task_info = instance.tasks_info(task_id)["data"]["tasks"][0] 177 | task_active = task_info["status"] != "finished" and task_info["status"] != "paused" 178 | task_bt = task_info["type"] == "bt" 179 | 180 | title = task_info["title"] 181 | status = task_info["status"].capitalize() 182 | progress = 0 183 | if task_info["size"] != 0: 184 | progress = task_info["additional"]["transfer"]["size_downloaded"] / task_info["size"] * 100 185 | ul_speed = human_readable_size(task_info["additional"]["transfer"]["speed_upload"]) + "/s" 186 | dl_speed = human_readable_size(task_info["additional"]["transfer"]["speed_download"]) + "/s" 187 | uled_size = human_readable_size(task_info["additional"]["transfer"]["size_uploaded"]) 188 | dled_size = human_readable_size(task_info["additional"]["transfer"]["size_downloaded"]) 189 | total_size = human_readable_size(task_info["size"]) 190 | source = task_info["additional"]["detail"]["uri"] 191 | created_time = time.strftime("%d %B %Y %H:%M", 192 | time.localtime(task_info["additional"]["detail"]["create_time"])) 193 | update_time = time.strftime("%d %B %Y %H:%M:%S", time.localtime(time.time())) 194 | 195 | reply_text = "*Name:* `{}`\n".format(title) 196 | reply_text += "*Status:*` {} ({:.2f}%)`\n".format(status, progress) 197 | if task_active: 198 | if task_bt: 199 | reply_text += "*Transfer Speed (UL|DL):*` {0}|{1}`\n".format(ul_speed, dl_speed) 200 | else: 201 | reply_text += "*Transfer Speed (DL):*` {}`\n".format(dl_speed) 202 | 203 | if task_bt: 204 | reply_text += "*Size (UL|DL|Total):*` {0}|{1}|{2}`\n".format(uled_size, 205 | dled_size, 206 | total_size) 207 | else: 208 | reply_text += "*Size (DL|Total):*` {0}|{1}`\n".format(dled_size, total_size) 209 | 210 | reply_text += "*Created time:*` {}`\n".format(created_time) 211 | reply_text += "*Source:*` {}`\n\n".format(source) 212 | reply_text += "Last update: {}".format(update_time) 213 | 214 | keyboard = [] 215 | if task_info["status"] != "finished": 216 | if task_info["status"] == "paused": 217 | callback_data = DETAIL_RESUME + task_id + "&" + DETAILS_CALLBACK_DATA + task_id 218 | keyboard.append([InlineKeyboardButton("Resume task",callback_data=callback_data)]) 219 | elif task_info["status"] != "error": 220 | callback_data = DETAIL_PAUSE + task_id + "&" + DETAILS_CALLBACK_DATA + task_id 221 | keyboard.append([InlineKeyboardButton("Pause task", callback_data=callback_data)]) 222 | 223 | keyboard.append([InlineKeyboardButton("Remove task", callback_data=DETAIL_REMOVE + task_id)]) 224 | keyboard.append([InlineKeyboardButton("Reload", callback_data=RELOAD + task_id)]) 225 | keyboard.append([InlineKeyboardButton("« Back to list", callback_data=DETAIL_BACK)]) 226 | reply_markup = InlineKeyboardMarkup(keyboard) 227 | query.answer() 228 | query.edit_message_text(text=escape_reserved_character(reply_text), 229 | parse_mode=ParseMode.MARKDOWN_V2, 230 | reply_markup=reply_markup) 231 | 232 | 233 | def __details_page_handler(update, context): 234 | query = update.callback_query 235 | data = query.data 236 | if data.startswith(DETAIL_RESUME): 237 | task_id = (data.split(DETAIL_RESUME)[1]).split("&")[0] 238 | instance.resume_task(task_id) 239 | time.sleep(1) 240 | __show_details_page(query, task_id) 241 | return DETAIL_PAGE 242 | elif data.startswith(DETAIL_PAUSE): 243 | task_id = (data.split(DETAIL_PAUSE)[1]).split("&")[0] 244 | instance.pause_task(task_id) 245 | time.sleep(1) 246 | __show_details_page(query, task_id) 247 | return DETAIL_PAGE 248 | elif data.startswith(RELOAD): 249 | task_id = data.split(RELOAD)[1] 250 | __show_details_page(query, task_id) 251 | elif data.startswith(DETAIL_REMOVE): 252 | # Send confirmation before removing 253 | task_id = data.split(DETAIL_REMOVE)[1] 254 | __show_remove_confirmation(query, task_id) 255 | return REMOVE_CONFIRMATION_PAGE 256 | elif data == DETAIL_BACK: 257 | query.answer() 258 | __download_list_data(query, page_number=1, action=ACTION_EDIT) 259 | return MAIN_PAGE 260 | 261 | 262 | def __show_remove_confirmation(query, task_id): 263 | keyboard = [ 264 | [InlineKeyboardButton("Yes", callback_data=CONFIRMATION_YES + task_id)], 265 | [InlineKeyboardButton("No", callback_data=CONFIRMATION_NO + task_id)] 266 | ] 267 | reply_markup = InlineKeyboardMarkup(keyboard) 268 | query.answer() 269 | reply_text = CONFIRMATION_PAGE_TEXT_TEMPLATE.format(__get_task_title(task_id)) 270 | query.edit_message_text(text=escape_reserved_character(reply_text), 271 | parse_mode=ParseMode.MARKDOWN_V2, 272 | reply_markup = reply_markup) 273 | 274 | 275 | def __remove_task_confirmation_page_handler(update, context): 276 | query = update.callback_query 277 | data = query.data 278 | if data.startswith(CONFIRMATION_YES): 279 | task_id = data.split(CONFIRMATION_YES)[1] 280 | instance.delete_task(task_id) 281 | time.sleep(1) 282 | query.answer() 283 | __download_list_data(query, page_number=1, action=ACTION_EDIT) 284 | return MAIN_PAGE 285 | elif data.startswith(CONFIRMATION_NO): 286 | task_id = data.split(CONFIRMATION_NO)[1] 287 | __show_details_page(query, task_id) 288 | return DETAIL_PAGE 289 | 290 | 291 | @user_owner 292 | @send_typing_action 293 | def __resume_downloads(update, context): 294 | task_list = instance.tasks_list() 295 | resumed_count = 0 296 | for current in task_list["data"]["tasks"]: 297 | result = instance.resume_task(current["id"]) 298 | if result["success"] == True: 299 | resumed_count += 1 300 | 301 | update.message.reply_text("Successfully resumed {} download(s).".format(resumed_count)) 302 | 303 | 304 | @user_owner 305 | @send_typing_action 306 | def __pause_downloads(update, context): 307 | task_list = instance.tasks_list() 308 | paused_count = 0 309 | for current in task_list["data"]["tasks"]: 310 | result = instance.pause_task(current["id"]) 311 | if result["success"] == True: 312 | paused_count += 1 313 | 314 | update.message.reply_text("Successfully paused {} download(s).".format(paused_count)) 315 | 316 | 317 | @user_owner 318 | @send_typing_action 319 | def __cleanup_downloads(update, context): 320 | cancel_other_conversations(update, context) 321 | keyboard = [ 322 | [InlineKeyboardButton("Yes", callback_data=CONFIRMATION_YES)], 323 | [InlineKeyboardButton("No", callback_data=CONFIRMATION_NO)] 324 | ] 325 | reply_markup = InlineKeyboardMarkup(keyboard) 326 | update.message.reply_text(text="Are you sure?", 327 | reply_markup = reply_markup) 328 | return REMOVE_CONFIRMATION_PAGE 329 | 330 | 331 | @send_typing_action 332 | def __cleanup_confirmation_page_handler(update, context): 333 | query = update.callback_query 334 | data = query.data 335 | if data == CONFIRMATION_YES: 336 | task_list = instance.tasks_list() 337 | removed_count = 0 338 | for current in task_list["data"]["tasks"]: 339 | if current["status"] == "finished": 340 | result = instance.delete_task(current["id"]) 341 | if result["success"] == True: 342 | removed_count += 1 343 | 344 | reply_text = "Successfully removed {} download(s).".format(removed_count) 345 | elif data == CONFIRMATION_NO: 346 | reply_text = "Operation cancelled." 347 | 348 | query.answer() 349 | query.edit_message_text(reply_text) 350 | return ConversationHandler.END 351 | 352 | 353 | @user_owner 354 | @send_typing_action 355 | def __list_downloads(update, context): 356 | cancel_other_conversations(update, context) 357 | __download_list_data(update.message) 358 | return MAIN_PAGE 359 | 360 | 361 | @user_owner 362 | def __add_download_entry(update, context): 363 | cancel_other_conversations(update, context) 364 | update.message.reply_text("Send me something for me to download. " + 365 | "Keep in mind that I can process only one thing at a time.") 366 | return DOCUMENT_OR_LINK 367 | 368 | 369 | resume_handler = CommandHandler("resumedownloads", __resume_downloads) 370 | pause_handler = CommandHandler("pausedownloads", __pause_downloads) 371 | cancel_handler = CommandHandler("cancel", __cancel) 372 | 373 | cleanup_handler = ConversationHandler( 374 | entry_points=[CommandHandler("cleanupdownloads", __cleanup_downloads)], 375 | states={ 376 | REMOVE_CONFIRMATION_PAGE: [CallbackQueryHandler(__cleanup_confirmation_page_handler), 377 | cancel_handler] 378 | }, 379 | fallbacks=[cancel_handler], 380 | allow_reentry=True 381 | ) 382 | list_downloads_handler = ConversationHandler( 383 | entry_points=[CommandHandler("mydownloads", __list_downloads)], 384 | states={ 385 | MAIN_PAGE: [CallbackQueryHandler(__list_page_change, pattern=PAGE_CALLBACK_DATA), 386 | CallbackQueryHandler(__open_details_page, pattern=DETAILS_CALLBACK_DATA), 387 | CallbackQueryHandler(__list_page_change, pattern=RELOAD)], 388 | DETAIL_PAGE: [CallbackQueryHandler(__details_page_handler)], 389 | REMOVE_CONFIRMATION_PAGE: [CallbackQueryHandler(__remove_task_confirmation_page_handler), 390 | cancel_handler] 391 | }, 392 | fallbacks=[cancel_handler], 393 | allow_reentry=True 394 | ) 395 | add_download_handler = ConversationHandler( 396 | entry_points=[CommandHandler("adddownload", __add_download_entry)], 397 | states={ 398 | DOCUMENT_OR_LINK: [MessageHandler(Filters.audio | Filters.video | Filters.photo | 399 | Filters.document | Filters.text & 400 | (Filters.entity(MessageEntity.URL) | 401 | Filters.entity(MessageEntity.TEXT_LINK)), 402 | __add_download_link)] 403 | }, 404 | fallbacks=[cancel_handler], 405 | allow_reentry=True 406 | ) 407 | 408 | 409 | dispatcher.add_handler(resume_handler) 410 | dispatcher.add_handler(pause_handler) 411 | dispatcher.add_handler(cleanup_handler) 412 | dispatcher.add_handler(list_downloads_handler) 413 | dispatcher.add_handler(add_download_handler) 414 | 415 | __help__ = """*Download Station* 416 | /mydownloads - manage your downloads 417 | /adddownload - create a new download task 418 | /resumedownloads - resume all inactive download tasks 419 | /pausedownloads - pause all active download tasks 420 | /cleanupdownloads - clear completed download tasks 421 | 422 | """ 423 | --------------------------------------------------------------------------------