From 6a35dbbf30eab20ab4f0a7abdd50f40d37d74bb6 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 10 Jan 2023 22:26:08 +0300 Subject: [PATCH 01/14] Start restructuring - Invalidate most of the readme - Update the filetree to match the upcoming updates - Minor updates to the files to preserve the bot functioning --- .gitignore | 2 +- README.md | 22 +++++++++++++------ Rimokon/__init__.py | 0 main.py => Rimokon/__main__.py | 6 ++--- .../config.py.example | 0 Rimokon/plugins/__init__.py | 0 util.py => Rimokon/util.py | 0 7 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 Rimokon/__init__.py rename main.py => Rimokon/__main__.py (97%) mode change 100755 => 100644 rename config.py.example => Rimokon/config.py.example (100%) create mode 100644 Rimokon/plugins/__init__.py rename util.py => Rimokon/util.py (100%) diff --git a/.gitignore b/.gitignore index 42a7951..f1562c4 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,4 @@ dmypy.json .pyre/ # Project-level -/config.py +Rimokon/config.py diff --git a/README.md b/README.md index 7da4aa6..6d563ce 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Telegram bot for simple remote control of the device it is running on. +## Redesign in progress + +The current readme is mainly outdated. + + + ## Requirements / limitations - To run the bot, on the device you need Python 3 and pip3 and have the libraries @@ -38,12 +44,12 @@ Supported commands (leading slash can be omitted, lower/upper case do not matter - `/help` - List available commands (you can find details there) -- `/key [] [...]` (**Xorg only**) - Generate keypress event for a key, +- `/key [<ARGS>] <KEYS> [<KEYS>...]` (**Xorg only**) - Generate keypress event for a key, a shortcut, or a sequence of them. All the arguments are separated by space and forwarded - to `xdotool key`, thus, `` must be valid `xdotool` keysequences, and it is possible - to specify additional arguments `` (refer to `xdotool key --help` for details) + to `xdotool key`, thus, `<KEYS>` must be valid `xdotool` keysequences, and it is possible + to specify additional arguments `<ARGS>` (refer to `xdotool key --help` for details) -- `/type ` (**Xorg only**) - Type the given text on the keyboard through +- `/type <STRING>` (**Xorg only**) - Type the given text on the keyboard through keyboard events. - `/screen` (**Windows, macOS or Xorg**) - Take a screenshot and send it as a Telegram @@ -51,17 +57,19 @@ Supported commands (leading slash can be omitted, lower/upper case do not matter - `/screenf` (-||-) - Just like `/screen`, but sends the screenshot as a document. -- `/run ` - run the command without shell but with +- `/run <COMMAND & ARGS SHELL-STYLE>` - run the command without shell but with shell-style arguments splitting (quoting and escaping is supported) -- `/rawrun ` - run the command without shell +- `/rawrun <COMMAND & ARGS WHITESPACE-SEPARATED>` - run the command without shell and split it by whitespaces, ignoring quotes and backslashes -- `/shell ` - run the command in shell. +- `/shell <SHELL COMMAND>` - run the command in shell. Note: `/exec` and `/rawexec` are synonyms for `/run` and `/rawrun` respectively (for backward compatibility). + + ## Security Because this bot allows arbitrary code execution on the device it is running on, of course, diff --git a/Rimokon/__init__.py b/Rimokon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/Rimokon/__main__.py old mode 100755 new mode 100644 similarity index 97% rename from main.py rename to Rimokon/__main__.py index 782af6e..28bb5cd --- a/main.py +++ b/Rimokon/__main__.py @@ -13,10 +13,10 @@ import telebot from requests.exceptions import RequestException -from util import escape, try_decode_otherwise_repr as try_decode, cmd_get_action, cmd_get_rest -from config import bot_token, admins_ids, emergency_shutdown_command, emergency_shutdown_public +from .util import escape, try_decode_otherwise_repr as try_decode, cmd_get_action, cmd_get_rest +from .config import bot_token, admins_ids, emergency_shutdown_command, emergency_shutdown_public try: - from config import quick_access_cmds + from .config import quick_access_cmds except ImportError: quick_access_cmds = [] diff --git a/config.py.example b/Rimokon/config.py.example similarity index 100% rename from config.py.example rename to Rimokon/config.py.example diff --git a/Rimokon/plugins/__init__.py b/Rimokon/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/util.py b/Rimokon/util.py similarity index 100% rename from util.py rename to Rimokon/util.py From 61390c95f396d46e2aa2c16985299e2077ca662c Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Wed, 11 Jan 2023 00:15:03 +0300 Subject: [PATCH 02/14] Turned screenshot creation into a plugin The core part of the bot does not handle it in a proper plugin manner (yet), but it's going to be fixed in the next commit(s) --- Rimokon/__main__.py | 19 ++++++------------- Rimokon/plugins/screenshot.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 Rimokon/plugins/screenshot.py diff --git a/Rimokon/__main__.py b/Rimokon/__main__.py index 28bb5cd..b5691ca 100644 --- a/Rimokon/__main__.py +++ b/Rimokon/__main__.py @@ -7,8 +7,6 @@ from time import sleep from sys import stderr from traceback import format_exc -from PIL import ImageGrab -from io import BytesIO import telebot from requests.exceptions import RequestException @@ -134,22 +132,17 @@ def key(message): xdotool_key_args = cmd_get_rest(message.text).split() run_command_and_notify(message, ['xdotool', 'key'] + xdotool_key_args, expect_quick=True) +# FIXME: everything will be imported from config +from .plugins.screenshot import screen as p_screen, screenf as p_screenf + @bot.message_handler(func=lambda message: cmd_get_action(message.text) in ['screen', 'screenf']) @admins_only_handler def screen(message): - try: - screenshot = ImageGrab.grab() - except OSError as e: - bot.reply_to(message, f"Error: your machine does not support this feature:\n{e}") - return - img = BytesIO() - img.name = 'i.png' - screenshot.save(img, 'PNG') - img.seek(0) + # FIXME: temporary code to test a plugin prototype if cmd_get_action(message.text).endswith('f'): - bot.send_document(message.chat.id, img, reply_to_message_id=message.message_id) + p_screenf(bot, message, cmd_get_rest(message.text)) else: - bot.send_photo(message.chat.id, img, reply_to_message_id=message.message_id) + p_screen(bot, message, cmd_get_rest(message.text)) @bot.message_handler(func=lambda message: cmd_get_action(message.text) in ['run', 'rawrun', 'exec', 'rawexec']) @admins_only_handler diff --git a/Rimokon/plugins/screenshot.py b/Rimokon/plugins/screenshot.py new file mode 100644 index 0000000..7294df8 --- /dev/null +++ b/Rimokon/plugins/screenshot.py @@ -0,0 +1,31 @@ +from io import BytesIO +from typing import Optional + +from PIL import ImageGrab +import telebot + + +__all__ = ['screen', 'screenf'] + + +def take_screenshot_or_complain_to(bot: telebot.TeleBot, message: telebot.types.Message) -> Optional[BytesIO]: + try: + screenshot = ImageGrab.grab() + except OSError as e: + bot.reply_to(message, f"Error: your machine doesn't seem to support this:\n{e}") + return + img = BytesIO() + img.name = 'i.png' # Telegram doesn't like untitled files + screenshot.save(img, 'PNG') + img.seek(0) + return img + +def screen(bot: telebot.TeleBot, message: telebot.types.Message, _: str) -> None: + img = take_screenshot_or_complain_to(bot, message) + if img: + bot.send_photo(message.chat.id, img, reply_to_message_id=message.message_id) + +def screenf(bot: telebot.TeleBot, message: telebot.types.Message, _: str) -> None: + img = take_screenshot_or_complain_to(bot, message) + if img: + bot.send_document(message.chat.id, img, reply_to_message_id=message.message_id) From 6d31c4587af315fdb17e1b07c765e1049723756a Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Wed, 11 Jan 2023 12:16:55 +0300 Subject: [PATCH 03/14] Implemented action lookup function, added terminology.txt Implemented the action lookup function that will be used to find the action function corresponding to received commands. Added terminology.txt (temp file) that states the differences between plugin, action, and command. --- Rimokon/__main__.py | 46 +++++++++++++++++++++++---------------------- Rimokon/util.py | 7 ++----- terminology.txt | 5 +++++ 3 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 terminology.txt diff --git a/Rimokon/__main__.py b/Rimokon/__main__.py index b5691ca..8bf9cbd 100644 --- a/Rimokon/__main__.py +++ b/Rimokon/__main__.py @@ -11,7 +11,7 @@ import telebot from requests.exceptions import RequestException -from .util import escape, try_decode_otherwise_repr as try_decode, cmd_get_action, cmd_get_rest +from .util import escape, try_decode_otherwise_repr as try_decode, cmd_get_action_name, cmd_get_rest from .config import bot_token, admins_ids, emergency_shutdown_command, emergency_shutdown_public try: from .config import quick_access_cmds @@ -80,7 +80,7 @@ def handler(message, *args, **kwargs): return handler -@bot.message_handler(func=lambda message: cmd_get_action(message.text) == 'start') +@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'start') def start(message: telebot.types.Message): keyboard = telebot.types.ReplyKeyboardMarkup(resize_keyboard=True) # `quick_access_cmds` should be an array of arrays of strings, the latter arrays represent lines @@ -95,7 +95,7 @@ def start(message: telebot.types.Message): reply_markup=keyboard ) -@bot.message_handler(func=lambda message: cmd_get_action(message.text) == 'help') +@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'help') @admins_only_handler def help_(message: telebot.types.Message): bot.reply_to(message, @@ -120,34 +120,22 @@ def help_(message: telebot.types.Message): parse_mode="MarkdownV2" ) -@bot.message_handler(func=lambda message: cmd_get_action(message.text) == 'type') +@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'type') @admins_only_handler def type_(message): text_to_type = cmd_get_rest(message.text) run_command_and_notify(message, ['xdotool', 'type', text_to_type], expect_quick=True) -@bot.message_handler(func=lambda message: cmd_get_action(message.text) == 'key') +@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'key') @admins_only_handler def key(message): xdotool_key_args = cmd_get_rest(message.text).split() run_command_and_notify(message, ['xdotool', 'key'] + xdotool_key_args, expect_quick=True) -# FIXME: everything will be imported from config -from .plugins.screenshot import screen as p_screen, screenf as p_screenf - -@bot.message_handler(func=lambda message: cmd_get_action(message.text) in ['screen', 'screenf']) -@admins_only_handler -def screen(message): - # FIXME: temporary code to test a plugin prototype - if cmd_get_action(message.text).endswith('f'): - p_screenf(bot, message, cmd_get_rest(message.text)) - else: - p_screen(bot, message, cmd_get_rest(message.text)) - -@bot.message_handler(func=lambda message: cmd_get_action(message.text) in ['run', 'rawrun', 'exec', 'rawexec']) +@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) in ['run', 'rawrun', 'exec', 'rawexec']) @admins_only_handler def run_raw_run(message): - action = cmd_get_action(message.text).replace('exec', 'run') + action = cmd_get_action_name(message.text).replace('exec', 'run') to_run = cmd_get_rest(message.text) if action == 'run': try: @@ -161,7 +149,7 @@ def run_raw_run(message): assert False, "Neither /run, nor /rawrun. How is that possible?" run_command_and_notify(message, to_run) -@bot.message_handler(func=lambda message: cmd_get_action(message.text) == 'shell') +@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'shell') @admins_only_handler def shell(message): to_run = cmd_get_rest(message.text) @@ -187,8 +175,22 @@ def shutdown(_): bot.register_message_handler(shutdown, func=lambda message: message.text.strip() == emergency_shutdown_command.strip()) -@bot.message_handler(func=lambda message: True) -def unknown(message): +# FIXME: everything will be imported from config +from .plugins.screenshot import screen as p_screen, screenf as p_screenf +actions = { + 'screen': p_screen, + 'screenf': p_screenf +} + +@bot.message_handler(func=lambda message: True) # TODO: accept other content types +@admins_only_handler +def run_command(message): + wanted_action_name = cmd_get_action_name(message.text) + command_rest = cmd_get_rest(message.text) + for action_name, action_func in actions.items(): + if wanted_action_name == action_name: + Thread(target=action_func, args=(bot, message, command_rest)).start() + return bot.reply_to(message, "Unknown command") diff --git a/Rimokon/util.py b/Rimokon/util.py index c482d10..457a6c2 100644 --- a/Rimokon/util.py +++ b/Rimokon/util.py @@ -19,15 +19,12 @@ def try_decode_otherwise_repr(s: bytes) -> str: return repr(s) -def cmd_get_action(s: Optional[str]) -> Optional[str]: +def cmd_get_action_name(s: Optional[str]) -> Optional[str]: """ Extract action from the command string with optional leading slash """ if s: - cmd = s.split()[0] - if cmd[0] == '/': - cmd = cmd[1:] - return cmd.lower() + return s.split()[0].lstrip('/').lower() def cmd_get_rest(s: str) -> str: """ diff --git a/terminology.txt b/terminology.txt new file mode 100644 index 0000000..4d8f8b8 --- /dev/null +++ b/terminology.txt @@ -0,0 +1,5 @@ +Temp, to be moved to readme/wikis/... + +Plugin - a python package (in a single- or multi-file form) put in the Rimokon/plugins directory; +Action - a functionality provided by a plugin consisting of a name (action name) and a function (action function / actuator). Command that contains action name triggers the corresponding action function. +Command - text of a recieved message. It consists of the first word, which is interpreted as an action name, and the rest of the command. From a2ed91a274d3fac8feff3742358a9917fa8c7000 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Fri, 13 Jan 2023 13:35:12 +0300 Subject: [PATCH 04/14] Implemented plugin helpers and run-rawrun-shell plugin Implemented the run-rawrun-shell functionality as a plugin, represented key/type actions through that plugin implementation and made use of them in the main file, dropping their previous implementations; Added plugin helpers, which currently contain decorators for notifying of action execution; Removed dangling stuff from Rimokon/__main__.py and Rimokon/__util__.py. --- Rimokon/__main__.py | 99 ++++------------------------- Rimokon/plugins/plugin_helpers.py | 45 +++++++++++++ Rimokon/plugins/run_rawrun_shell.py | 68 ++++++++++++++++++++ Rimokon/util.py | 20 +----- 4 files changed, 128 insertions(+), 104 deletions(-) create mode 100644 Rimokon/plugins/plugin_helpers.py create mode 100644 Rimokon/plugins/run_rawrun_shell.py diff --git a/Rimokon/__main__.py b/Rimokon/__main__.py index 8bf9cbd..20c00f2 100644 --- a/Rimokon/__main__.py +++ b/Rimokon/__main__.py @@ -1,9 +1,6 @@ #!/usr/bin/env python3 from functools import wraps -from typing import Union, List -import subprocess from threading import Thread #, Timer -import shlex from time import sleep from sys import stderr from traceback import format_exc @@ -11,7 +8,7 @@ import telebot from requests.exceptions import RequestException -from .util import escape, try_decode_otherwise_repr as try_decode, cmd_get_action_name, cmd_get_rest +from .util import cmd_get_action_name, cmd_get_rest from .config import bot_token, admins_ids, emergency_shutdown_command, emergency_shutdown_public try: from .config import quick_access_cmds @@ -22,51 +19,6 @@ bot = telebot.TeleBot(bot_token) -def run_command_and_notify(message: telebot.types.Message, args: Union[str, List[str]], *, - expect_quick: bool = False, shell: bool = False): - """ - Creates and starts a new thread, that runs the given command (either in the shell mode or not) and - reports the results to the user. - - @param message: Telegram bot message to reply to - @param args: String or list of arguments to be executed. - @param expect_quick: (optional, default `False`) Whether to expect that the command will - finish quickly. If so, bot will only notify the user after it completes, otherwise it - will send a temporary message to identify that the command is accepted and running, - but not yet finished. - @param shell: (optional, default `False`) Whether to run in shell - """ - def f(): - try: - p = subprocess.Popen(args, shell=shell, stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - - if not expect_quick: - sent_message = bot.reply_to(message, "Executing...") - - out, err = map(try_decode, p.communicate()) - reply_text = "Done\\. Output:\n" # '.' must be escaped in MarkdownV2 - if out: - reply_text += "stdout:\n```\n" + escape(out, ['\\', '`']) + "\n```\n" - if err: - reply_text += "stderr:\n```\n" + escape(err, ['\\', '`']) + "\n```\n" - reply_text += f"Exit code: {p.returncode}" - - try: - bot.reply_to(message, reply_text, parse_mode="MarkdownV2") - except telebot.apihelper.ApiTelegramException as e: - bot.reply_to(message, f"The command has completed with code {p.returncode}, but I failed " - f"to send the response:\n{e}") - if not expect_quick: - # Sending a new message and deleting the old one instead of editing because running a command may - # take a long time and we want to notify user when it's over - bot.delete_message(message.chat.id, sent_message.message_id) - except Exception as e: - bot.reply_to(message, f"Something went wrong while processing your request:\n{e}") - - Thread(target=f, daemon=True).start() # Do all of it in the background - - # Decorator that prevents the actions when executed by a non-admin user. # Must be specified UNDER the `@bot.*_handler` decorator (must be applied before it) def admins_only_handler(original_handler): @@ -98,6 +50,8 @@ def start(message: telebot.types.Message): @bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'help') @admins_only_handler def help_(message: telebot.types.Message): + bot.reply_to(message, 'The version you are running is in the work-in-progress state') + ''' bot.reply_to(message, "Hello\\. I currently have the following commands:\n\n" "*\\(\\*\\)* /type _STRING_ \\- Type _STRING_ on keyboard\n\n" @@ -119,41 +73,7 @@ def help_(message: telebot.types.Message): "Note: leading slashes can be omitted in all of the above commands, case does not matter\\. ", parse_mode="MarkdownV2" ) - -@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'type') -@admins_only_handler -def type_(message): - text_to_type = cmd_get_rest(message.text) - run_command_and_notify(message, ['xdotool', 'type', text_to_type], expect_quick=True) - -@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'key') -@admins_only_handler -def key(message): - xdotool_key_args = cmd_get_rest(message.text).split() - run_command_and_notify(message, ['xdotool', 'key'] + xdotool_key_args, expect_quick=True) - -@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) in ['run', 'rawrun', 'exec', 'rawexec']) -@admins_only_handler -def run_raw_run(message): - action = cmd_get_action_name(message.text).replace('exec', 'run') - to_run = cmd_get_rest(message.text) - if action == 'run': - try: - to_run = shlex.split(to_run) - except ValueError as e: - bot.reply_to(message, f"Failed to parse arguments:\n{e}") - return - elif action == 'rawrun': - to_run = to_run.split() - else: - assert False, "Neither /run, nor /rawrun. How is that possible?" - run_command_and_notify(message, to_run) - -@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'shell') -@admins_only_handler -def shell(message): - to_run = cmd_get_rest(message.text) - run_command_and_notify(message, to_run, shell=True) + ''' def shutdown(_): print("Stopping due to emergency shutdown command received", flush=True) @@ -177,9 +97,18 @@ def shutdown(_): # FIXME: everything will be imported from config from .plugins.screenshot import screen as p_screen, screenf as p_screenf +from .plugins.run_rawrun_shell import run, rawrun, shell, run_parsed_command actions = { + 'run': run, + 'rawrun': rawrun, + 'shell': shell, 'screen': p_screen, - 'screenf': p_screenf + 'screenf': p_screenf, + + # Simple alias (will be represented as string a string alias): + 'key': lambda bot, msg, rest: rawrun(bot, msg, 'xdotool key ' + rest, notify=False), + # Complex alias: + 'type': lambda bot, msg, rest: run_parsed_command(bot, msg, ['xdotool', 'type', rest], notify=False) } @bot.message_handler(func=lambda message: True) # TODO: accept other content types diff --git a/Rimokon/plugins/plugin_helpers.py b/Rimokon/plugins/plugin_helpers.py new file mode 100644 index 0000000..32c29af --- /dev/null +++ b/Rimokon/plugins/plugin_helpers.py @@ -0,0 +1,45 @@ +from functools import wraps +from typing import Callable, Any + +from telebot import TeleBot +from telebot.types import Message + + +def _run_notified(f, bot, message, args, kwargs): + sent_message = bot.reply_to(message, "Executing...") + try: + return f(bot, message, *args, **kwargs) + finally: + bot.delete_message(message.chat.id, sent_message.message_id) + +def notify_of_execution(f: Callable[[TeleBot, Message, ...], Any]) -> Callable[[TeleBot, Message, ...], Any]: + """ + Decorator for an action function to notify user when the execution of the action is in progress. + + Modifies an action function (which accepts `telebot.TeleBot` and `telebot.Message` as its first + positional arguments) to send a message with text "Executing..." before starting the action function + and deletes that message after its completion. + """ + @wraps(f) + def decorated(bot: TeleBot, message: Message, *args, **kwargs): + return _run_notified(f, bot, message, args, kwargs) + return decorated + +def notify_of_execution_conditionally(f: Callable[[TeleBot, Message, ...], Any]) -> \ + Callable[[TeleBot, Message, ...], Any]: + """ + Decorator for an action function to conditionally notify user when the execution of the + action is in progress. + + Modifes an action function (which accepts `telebot.TeleBot` and `telebot.Message` as its + first position arguments) such that an additional kw-only argument `notify` is added to + the function. If it is `True` (default), the behavior is the same with `notify_of_execution`; + if it is `False`, the behavior is as if the function was unmodified + """ + @wraps(f) + def decorated(bot: TeleBot, message: Message, *args, notify: bool = True, **kwargs): + if notify: + return _run_notified(f, bot, message, args, kwargs) + else: + return f(bot, message, *args, **kwargs) + return decorated diff --git a/Rimokon/plugins/run_rawrun_shell.py b/Rimokon/plugins/run_rawrun_shell.py new file mode 100644 index 0000000..f3eff6f --- /dev/null +++ b/Rimokon/plugins/run_rawrun_shell.py @@ -0,0 +1,68 @@ +from typing import Union, List +from subprocess import Popen, PIPE +from shlex import split as shlex_split + +from telebot import TeleBot +from telebot.types import Message +from telebot.apihelper import ApiTelegramException + +from .plugin_helpers import notify_of_execution_conditionally + + +__all__ = ['shell', 'run', 'rawrun', 'run_parsed_command'] + + +def try_decode_otherwise_repr(s: bytes) -> str: + try: + return s.decode() + except UnicodeDecodeError: + return repr(s) + + +def _escape(where: str, what: List[str]): + # If there is a backslash in `what`, it must come first + if '\\' in what[1:]: + what = ['\\'] + [c for c in what if c != '\\'] + + for c in what: + assert len(c) == 1 + where = where.replace(c, '\\' + c) + return where + + +@notify_of_execution_conditionally +def run_parsed_command(bot: TeleBot, message: Message, command: Union[str, List[str]], + *, shell: bool = False) -> None: + """ + Accepts a command (in the prepared form), runs it via `subprocess.Popen`, forms and sends + the response message. + """ + p = Popen(command, shell=shell, stdin=PIPE, stdout=PIPE, stderr=PIPE) + + out, err = map(try_decode_otherwise_repr, p.communicate()) + reply_text = "Done\\. Output:\n" # '.' must be escaped in MarkdownV2 + if out: + reply_text += "stdout:\n```\n" + _escape(out, ['\\', '`']) + "\n```\n" + if err: + reply_text += "stderr:\n```\n" + _escape(err, ['\\', '`']) + "\n```\n" + reply_text += f"Exit code: {p.returncode}" + + try: + bot.reply_to(message, reply_text, parse_mode="MarkdownV2") + except ApiTelegramException as e: + bot.reply_to(message, f"The command has completed with code {p.returncode}, but I failed " + f"to send the response:\n{e}") + +def shell(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: + run_parsed_command(bot, message, command_rest, shell=True, notify=notify) + +def run(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: + try: + command = shlex_split(command_rest) + except ValueError as e: + bot.reply_to(message, f"Failed to parse arguments:\n{e}") + else: + run_parsed_command(bot, message, command, notify=notify) + +def rawrun(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: + run_parsed_command(bot, message, command_rest.split(), notify=notify) diff --git a/Rimokon/util.py b/Rimokon/util.py index 457a6c2..72b297f 100644 --- a/Rimokon/util.py +++ b/Rimokon/util.py @@ -1,22 +1,4 @@ -from typing import List, Optional - - -def escape(where: str, what: List[str]): - # If there is a backslash in `what`, it must come first - if '\\' in what: - what = ['\\'] + [c for c in what if c != '\\'] - - for c in what: - assert len(c) == 1 - where = where.replace(c, '\\' + c) - return where - - -def try_decode_otherwise_repr(s: bytes) -> str: - try: - return s.decode() - except UnicodeDecodeError: - return repr(s) +# TODO: Move these to __main__.py def cmd_get_action_name(s: Optional[str]) -> Optional[str]: From 877f3bd656c93eef7408008c123aaac0ce757952 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Mon, 16 Jan 2023 18:30:37 +0300 Subject: [PATCH 05/14] Rewrote util.py (for the upcoming import_config.py) Fixed a missing import; fixed bugs in `cmd_get_*`, extended `cmd_get_rest` to preserve whitespace when requested. --- Rimokon/util.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Rimokon/util.py b/Rimokon/util.py index 72b297f..d206ed2 100644 --- a/Rimokon/util.py +++ b/Rimokon/util.py @@ -1,16 +1,24 @@ -# TODO: Move these to __main__.py +from typing import Optional def cmd_get_action_name(s: Optional[str]) -> Optional[str]: """ - Extract action from the command string with optional leading slash + Extract action name from a command string with, optionally, leading spaces followed by leading slashes. """ if s: - return s.split()[0].lstrip('/').lower() + return s.lstrip().lstrip('/').split()[0].lower() -def cmd_get_rest(s: str) -> str: +def cmd_get_rest(s: str, cut_first_whitespace: bool = True) -> str: """ - Cuts the first word of the string and the first whitespace symbol - after it, returns the rest + Cut the first word of the string and (optionally, default) the first whitespace symbol after it. + Return the rest. """ - return s[len(s.split()[0])+1:] + s = s.lstrip() + try: + i = s.index(' ') + except ValueError: + return '' + + if cut_first_whitespace: + i += 1 + return s[i:] From d3b1cb4efcae67d0f5bb7616657899fe7b9fd064 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Mon, 16 Jan 2023 18:32:20 +0300 Subject: [PATCH 06/14] Implemented config importing and canonicalization In import_config.py implemented importing config and canonicalizing actions and aliases defined in it. --- Rimokon/import_config.py | 95 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 Rimokon/import_config.py diff --git a/Rimokon/import_config.py b/Rimokon/import_config.py new file mode 100644 index 0000000..773749a --- /dev/null +++ b/Rimokon/import_config.py @@ -0,0 +1,95 @@ +""" +This file imports the user-defined config.py file and processes all the entries to +bring them to the common form and make it easy to use them. +""" + +import logging + +from .util import cmd_get_action_name, cmd_get_rest +from .config import bot_token, admins_ids, \ + quick_access_cmds, \ + emergency_shutdown_command, emergency_shutdown_public, \ + actions, aliases + + +def die(*args): + # TODO: use logger object + logging.critical(*args) + exit(1) + +def canonicalize_key_or_die(k_: str) -> str: + """ + Bring the key to the lowercase stripped and slash-stripped form. + + If the given name is not a valid action name print the error message and terminate the program. + """ + k = k_.strip().lstrip('/').lower() + if any(c.isspace() for c in k): + die('Action name must not contain whitespaces (violated by %s)', repr(k_)) + return k + + +updated_actions = {} + +for k_, v in actions.items(): + logging.debug('Processing action %s', repr(k_)) + k = canonicalize_key_or_die(k_) + if k in updated_actions.keys(): + die('Attempt to redefine action %s (in `config.actions`)', repr(k)) + if not callable(v): + die('The action function specified by key %s is not callable', repr(k_)) + updated_actions[k] = v + + +def complex_alias_from_string(base_action_func, prepended_string): + """ + Construct the action function for a string alias, given its base action function and the + prepended string for the command. + """ + def new_action_func(bot, msg, _rest): + # Using `cmd_get_rest(msg.text, ...)` instead of `_rest` to preserve the whitespace + # symbol from the message text. + new_rest = prepended_string + cmd_get_rest(msg.text, False) + + logging.debug('String alias triggered. Message text: %s; prepended string: %s; ' + 'resulting (new) rest: %s', + repr(msg.text), repr(prepended_string), repr(new_rest)) + + return base_action_func(bot, msg, new_rest) + + return new_action_func + + +updated_aliases = {} + +for k_, v_ in aliases.items(): + logging.debug('Processing alias %s', repr(k_)) + k = canonicalize_key_or_die(k_) + if k in updated_aliases.keys(): + die('Attempt to redefine alias %s (in `config.aliases`)', repr(k)) + if k in updated_actions.keys(): + die('Alias %s attempts to overwrite an existing action', repr(k_)) + if isinstance(v_, str): + base_plugin = cmd_get_action_name(v_) + if base_plugin not in updated_actions.keys(): + die('Alias %s relies on the action %s which does not exist. ' + 'Note that aliases for aliases are not supported', repr(k_), repr(base_plugin)) + base_action_func = updated_actions[base_plugin] + + v = complex_alias_from_string(base_action_func, cmd_get_rest(v_)) + elif callable(v_): + v = v_ + else: + die('The alias %s specifies neither a string nor a callable object', repr(k_)) + updated_aliases[k] = v + + +unified_actions = {**updated_actions, **updated_aliases} + +# Make the old intermediate values unimportable +del actions, aliases, updated_actions, updated_aliases + + +# When executed as a script, behave as a config checker +if __name__ == "__main__": + print("Config file imported successfully") From f84572ebd4ca6e6a10a6c10a6a963b8aa91fab3e Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Mon, 16 Jan 2023 18:38:52 +0300 Subject: [PATCH 07/14] Implemented actual config parsing, added explanations The long description at config.py.example are going to be moved to Wikis (#18) --- Rimokon/__main__.py | 27 +++++---------------------- Rimokon/config.py.example | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/Rimokon/__main__.py b/Rimokon/__main__.py index 20c00f2..575dae4 100644 --- a/Rimokon/__main__.py +++ b/Rimokon/__main__.py @@ -9,11 +9,10 @@ from requests.exceptions import RequestException from .util import cmd_get_action_name, cmd_get_rest -from .config import bot_token, admins_ids, emergency_shutdown_command, emergency_shutdown_public -try: - from .config import quick_access_cmds -except ImportError: - quick_access_cmds = [] +from .import_config import bot_token, admins_ids, \ + emergency_shutdown_command, emergency_shutdown_public, \ + quick_access_cmds, \ + unified_actions bot = telebot.TeleBot(bot_token) @@ -95,28 +94,12 @@ def shutdown(_): bot.register_message_handler(shutdown, func=lambda message: message.text.strip() == emergency_shutdown_command.strip()) -# FIXME: everything will be imported from config -from .plugins.screenshot import screen as p_screen, screenf as p_screenf -from .plugins.run_rawrun_shell import run, rawrun, shell, run_parsed_command -actions = { - 'run': run, - 'rawrun': rawrun, - 'shell': shell, - 'screen': p_screen, - 'screenf': p_screenf, - - # Simple alias (will be represented as string a string alias): - 'key': lambda bot, msg, rest: rawrun(bot, msg, 'xdotool key ' + rest, notify=False), - # Complex alias: - 'type': lambda bot, msg, rest: run_parsed_command(bot, msg, ['xdotool', 'type', rest], notify=False) -} - @bot.message_handler(func=lambda message: True) # TODO: accept other content types @admins_only_handler def run_command(message): wanted_action_name = cmd_get_action_name(message.text) command_rest = cmd_get_rest(message.text) - for action_name, action_func in actions.items(): + for action_name, action_func in unified_actions.items(): if wanted_action_name == action_name: Thread(target=action_func, args=(bot, message, command_rest)).start() return diff --git a/Rimokon/config.py.example b/Rimokon/config.py.example index e46df84..576ffdd 100644 --- a/Rimokon/config.py.example +++ b/Rimokon/config.py.example @@ -32,3 +32,42 @@ emergency_shutdown_command = 'YOUR_COMMAND_HERE' # # It is recommended to leave this enabled and keep the emergency shutdown command in secret. emergency_shutdown_public = True + + +# The following parameters `actions` and `aliases` define the actions your Rimokon instance will be able +# to perform. To enable them, import the action functions from plugins and add them to the following +# dictionaries. + +from .plugins.run_rawrun_shell import run, rawrun, shell, run_parsed_command +from .plugins.screenshot import screen, screenf + +# This dictionary specifies a mapping from action name (i.e. the command that the user will use) to the +# action function (it will be given positional arguments: `telebot.TeleBot` object to interact with user, +# `telebot.types.Message` object corresponding to the received message, in case it needs any metadata, +# and an `str` with the rest of the command (i.e. part after the action name)). +actions = { + 'run': run, + 'rawrun': rawrun, + 'shell': shell, + 'screen': screen, + 'screenf': screenf +} + +# Aliases complement the set of actions. Here users can specify their commands that are based on the +# plugins' functionalities. Aliases may be of two types: string (simple) aliases and +# callable (complex) aliases. They are explained in more detail below. These examples rely on the +# `xdotool` utility installed (which is well suited for Xorg, but you may want to use another one, +# such as `ydotool`, which works on both Xorg and Wayland). +aliases = { + # A "simple alias" (or "string alias") just expands the given action name in the beginning of the + # received commands to the value. Notice that it is interpreted just like other action names, + # i.e. letters lower/upper case or trailing slashes do not matter. + # For this alias, for example, a command "/Key 123 key 456" is expanded to "Run xdotool key 123 key 456". + 'key': 'Run xdotool key', + + # A "complex alias" (or "callable alias") takes the same form as an `actions` entry. It works exactly + # the same way and there is no technical difference whether it is defined as an alias or an action. + # There is only logical difference: the `actions` dictionary is intended for enabling plugins, while + # the `aliases` dictionary is for user-defined actions. + 'type': lambda bot, msg, rest: run_parsed_command(bot, msg, ['xdotool', 'type', rest], notify=False) +} From 4380987e5022cc5a1718425808c2bb451b228116 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 17 Jan 2023 11:26:12 +0300 Subject: [PATCH 08/14] Nicer logging - using the logger object One more step on the way to #15 --- Rimokon/config.py.example | 5 +++++ Rimokon/import_config.py | 21 ++++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Rimokon/config.py.example b/Rimokon/config.py.example index 576ffdd..8f9402a 100644 --- a/Rimokon/config.py.example +++ b/Rimokon/config.py.example @@ -34,6 +34,11 @@ emergency_shutdown_command = 'YOUR_COMMAND_HERE' emergency_shutdown_public = True +# Optional: set logging level +#import logging +#logging.getLogger('Rimokon').setLevel(logging.DEBUG) + + # The following parameters `actions` and `aliases` define the actions your Rimokon instance will be able # to perform. To enable them, import the action functions from plugins and add them to the following # dictionaries. diff --git a/Rimokon/import_config.py b/Rimokon/import_config.py index 773749a..a1d9a66 100644 --- a/Rimokon/import_config.py +++ b/Rimokon/import_config.py @@ -12,9 +12,17 @@ actions, aliases +logger = logging.getLogger('Rimokon') +_handler = logging.StreamHandler() +_handler.setFormatter(logging.Formatter( + fmt='%(asctime)s %(levelname)s\t%(message)s', datefmt="%d.%m.%Y %H:%M:%S")) +logger.addHandler(_handler) +del _handler # Make unimportable + + def die(*args): # TODO: use logger object - logging.critical(*args) + logger.critical(*args) exit(1) def canonicalize_key_or_die(k_: str) -> str: @@ -32,7 +40,7 @@ def canonicalize_key_or_die(k_: str) -> str: updated_actions = {} for k_, v in actions.items(): - logging.debug('Processing action %s', repr(k_)) + logger.debug('Processing action %s', repr(k_)) k = canonicalize_key_or_die(k_) if k in updated_actions.keys(): die('Attempt to redefine action %s (in `config.actions`)', repr(k)) @@ -51,7 +59,7 @@ def new_action_func(bot, msg, _rest): # symbol from the message text. new_rest = prepended_string + cmd_get_rest(msg.text, False) - logging.debug('String alias triggered. Message text: %s; prepended string: %s; ' + logger.debug('String alias triggered. Message text: %s; prepended string: %s; ' 'resulting (new) rest: %s', repr(msg.text), repr(prepended_string), repr(new_rest)) @@ -63,7 +71,7 @@ def new_action_func(bot, msg, _rest): updated_aliases = {} for k_, v_ in aliases.items(): - logging.debug('Processing alias %s', repr(k_)) + logger.debug('Processing alias %s', repr(k_)) k = canonicalize_key_or_die(k_) if k in updated_aliases.keys(): die('Attempt to redefine alias %s (in `config.aliases`)', repr(k)) @@ -90,6 +98,5 @@ def new_action_func(bot, msg, _rest): del actions, aliases, updated_actions, updated_aliases -# When executed as a script, behave as a config checker -if __name__ == "__main__": - print("Config file imported successfully") +# When executed as a script, behave as a config checker. Otherwise just notify of success +(print if __name__ == "__main__" else logger.info)("Successfully imported the config file") From dc01bef1936d4693f58648570be6f308f883b706 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 17 Jan 2023 13:44:20 +0300 Subject: [PATCH 09/14] Carefully handle exceptions and notify user of them --- Rimokon/import_config.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Rimokon/import_config.py b/Rimokon/import_config.py index a1d9a66..e35b26c 100644 --- a/Rimokon/import_config.py +++ b/Rimokon/import_config.py @@ -4,6 +4,7 @@ """ import logging +from functools import wraps from .util import cmd_get_action_name, cmd_get_rest from .config import bot_token, admins_ids, \ @@ -37,6 +38,24 @@ def canonicalize_key_or_die(k_: str) -> str: return k +def make_action_noexcept(action_func): + @wraps(action_func) + def noexcept_action_func(bot, message, *args, **kwargs): + try: + return action_func(bot, message, *args, **kwargs) + except Exception as e: + # Logger functions must never ever raise exceptions, right? It should be safe to use them + # outside of the try block. + logger.exception('Exception occurred while handling the command %s', message.text) + try: + bot.reply_to(message, f"Something went wrong while processing your request:\n{e}") + except Exception: + logger.exception('Failed to notify user of the above problem') + else: + logger.info('Successfully notified user of the above problem') + return noexcept_action_func + + updated_actions = {} for k_, v in actions.items(): @@ -46,7 +65,7 @@ def canonicalize_key_or_die(k_: str) -> str: die('Attempt to redefine action %s (in `config.actions`)', repr(k)) if not callable(v): die('The action function specified by key %s is not callable', repr(k_)) - updated_actions[k] = v + updated_actions[k] = make_action_noexcept(v) def complex_alias_from_string(base_action_func, prepended_string): @@ -89,7 +108,7 @@ def new_action_func(bot, msg, _rest): v = v_ else: die('The alias %s specifies neither a string nor a callable object', repr(k_)) - updated_aliases[k] = v + updated_aliases[k] = make_action_noexcept(v) unified_actions = {**updated_actions, **updated_aliases} From 87e8a61dda318f1dce95d7d8cde937a8bde17af5 Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 17 Jan 2023 17:27:31 +0300 Subject: [PATCH 10/14] Actualized README.md + removed the temporary terminology.txt --- README.md | 75 ++++++++++++++++++------------------------------- terminology.txt | 5 ---- 2 files changed, 28 insertions(+), 52 deletions(-) delete mode 100644 terminology.txt diff --git a/README.md b/README.md index 6d563ce..38af7be 100644 --- a/README.md +++ b/README.md @@ -6,69 +6,48 @@ Telegram bot for simple remote control of the device it is running on. The current readme is mainly outdated. - -## Requirements / limitations +## Basic usage -- To run the bot, on the device you need Python 3 and pip3 and have the libraries - installed: - ``` - pip3 install -r requirements.txt - ``` +- Install requirements: + ``` + pip3 install -r requirements.txt + ``` -- For the graphics-related controls (`/type`, `/key`) to work, you have to be running - Xorg (typical for Linux) with the `xdotool` utility installed -- The tool is originally developed for Linux. Shell-related commands for Windows - should be working just fine, but graphics-related controls are not supported on - Windows. I am not planning to add support for it, but if you wish to, you are - welcome! If you implement it, feel free to file a pull request. +- Copy `Rimokon/config.py.example` to `Rimokon/config.py` and modify it according to your use case. + Optionally, install `xdotool`/`ydotool` or a similar utility for graphics interaction. + Use [@BotFather -- For the bot to function you, of course, need to have internet connection. It does - not need to be super stable though, because the bot shall automatically reconnect - in case of a network issue after a timeout. -## Configuration +- Run the bot as a python package: `python3 -m Rimokon` or, from another directory, + `python3 -m Path.To.Rimokon.With.Dot.Delimiters` -You must create and fill in the file `config.py`. Use `config.py.example` as an -example. Refer to comments in it for more details. You will need to register a bot -and get a token [@BotFather](https://t.me/BotFather) and get your telegram user id -[@myidbot](https://t.me/myidbot). -## Usage +## Plugins, actions, and aliases -To start the bot, start `main.py`. +Plugins are python packages stored under `Rimokon/plugins/`, which export some functions of the +signature `f(bot: telebot.TeleBot, message: telebot.types.Message, rest: str) -> Any`. It will +be called with three positional arguments: bot object to perform actions, the message that +triggered the action, and the string containing the part of the command after the action name. -Supported commands (leading slash can be omitted, lower/upper case do not matter): -- `/start` - Start the bot, update keyboard on Telegram side +To enable a package, put it to the `Rimokon/plugins/` directory, import it in your +`Rimokon/config.py` and include it to the `actions` dictionary. -- `/help` - List available commands (you can find details there) +Aliases are, roughly speaking, user-defined actions. They may take the form of a string +(e.g. `'key': 'run xdotool key'` causes commands like `key space` be interpreted like +`run xdotool key space`) or an action-like function, in which case they behave just like +the usual actions (e.g. `'echo': lambda bot, msg, rest: bot.reply_to(msg, rest)` will make +the command `echo 123 456 789` produce the response `123 456 789`). -- `/key [<ARGS>] <KEYS> [<KEYS>...]` (**Xorg only**) - Generate keypress event for a key, - a shortcut, or a sequence of them. All the arguments are separated by space and forwarded - to `xdotool key`, thus, `<KEYS>` must be valid `xdotool` keysequences, and it is possible - to specify additional arguments `<ARGS>` (refer to `xdotool key --help` for details) +To enable an alias, define it in your `Rimokon/config.py` in the `aliases` dictionary. -- `/type <STRING>` (**Xorg only**) - Type the given text on the keyboard through - keyboard events. -- `/screen` (**Windows, macOS or Xorg**) - Take a screenshot and send it as a Telegram - photo. +## Adavnced usage, technical plugins details, ... -- `/screenf` (-||-) - Just like `/screen`, but sends the screenshot as a document. +More detailed docs are coming (hopefully, soon) in the GitHub wikis... But not yet 😔. +Meanwhile, feel free to spam in the issues. -- `/run <COMMAND & ARGS SHELL-STYLE>` - run the command without shell but with - shell-style arguments splitting (quoting and escaping is supported) - -- `/rawrun <COMMAND & ARGS WHITESPACE-SEPARATED>` - run the command without shell - and split it by whitespaces, ignoring quotes and backslashes - -- `/shell <SHELL COMMAND>` - run the command in shell. - -Note: `/exec` and `/rawexec` are synonyms for `/run` and `/rawrun` respectively (for -backward compatibility). - - ## Security @@ -77,6 +56,7 @@ you should only allow access to it (`admins_ids` in the config file) to trusted Still, should you have any kind of runtime security threat, for example, if one of the admin accounts gets compromised, it is possible to perform the emergency shutdown. + ### Emergency shutdown To shut the bot down, user must send the command that they define in the config.py file. @@ -93,6 +73,7 @@ to their account to a malicious user. Note that it is perfectly fine to stop the bot with a usual keyboard interrupt. The shutdown command is intended for a case of emergency. + ### After emergency shutdown Even if some commands were sent to the bot _after_ it was stopped, Telegram maintains (for a diff --git a/terminology.txt b/terminology.txt deleted file mode 100644 index 4d8f8b8..0000000 --- a/terminology.txt +++ /dev/null @@ -1,5 +0,0 @@ -Temp, to be moved to readme/wikis/... - -Plugin - a python package (in a single- or multi-file form) put in the Rimokon/plugins directory; -Action - a functionality provided by a plugin consisting of a name (action name) and a function (action function / actuator). Command that contains action name triggers the corresponding action function. -Command - text of a recieved message. It consists of the first word, which is interpreted as an action name, and the rest of the command. From ce2056dfd6b76bb9a460fff77562fc2b8dc0571a Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 17 Jan 2023 19:08:19 +0300 Subject: [PATCH 11/14] Implemented /help message generation for actions Generate help message from the enabled actions and aliases. Modified and moved /start message, fixed #16 --- Rimokon/__main__.py | 54 +++++++++-------------------- Rimokon/import_config.py | 30 ++++++++++++++-- Rimokon/plugins/plugin_helpers.py | 31 +++++++++++++++++ Rimokon/plugins/run_rawrun_shell.py | 6 +++- Rimokon/plugins/screenshot.py | 4 +++ 5 files changed, 84 insertions(+), 41 deletions(-) diff --git a/Rimokon/__main__.py b/Rimokon/__main__.py index 575dae4..9331bd0 100644 --- a/Rimokon/__main__.py +++ b/Rimokon/__main__.py @@ -12,7 +12,12 @@ from .import_config import bot_token, admins_ids, \ emergency_shutdown_command, emergency_shutdown_public, \ quick_access_cmds, \ - unified_actions + unified_actions, help_text + + +hello_text = ("Hello! I am リモコン (pronounced \"rimokon\", japanese for \"remote control\") " + "and I let my admins control the device I am running on. The available actions are " + "listed under /help") bot = telebot.TeleBot(bot_token) @@ -33,46 +38,21 @@ def handler(message, *args, **kwargs): @bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'start') def start(message: telebot.types.Message): - keyboard = telebot.types.ReplyKeyboardMarkup(resize_keyboard=True) - # `quick_access_cmds` should be an array of arrays of strings, the latter arrays represent lines - # of buttons - for line in quick_access_cmds: - keyboard.add(*line) - - bot.reply_to(message, - "Hello! I am リモコン (pronounced \"rimokon\", japanese for \"remote control\") " - "and I let my admins control the device I am running on. Click /help to " - "learn more", - reply_markup=keyboard - ) + if message.chat.id in admins_ids: + keyboard = telebot.types.ReplyKeyboardMarkup(resize_keyboard=True) + # `quick_access_cmds` should be an array of arrays of strings, the latter arrays represent lines + # of buttons + for line in quick_access_cmds: + keyboard.add(*line) + else: + keyboard = None + + bot.reply_to(message, hello_text, reply_markup=keyboard) @bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'help') @admins_only_handler def help_(message: telebot.types.Message): - bot.reply_to(message, 'The version you are running is in the work-in-progress state') - ''' - bot.reply_to(message, - "Hello\\. I currently have the following commands:\n\n" - "*\\(\\*\\)* /type _STRING_ \\- Type _STRING_ on keyboard\n\n" - "*\\(\\*\\)* /key \\[_ARGS_\\] _KEYS_ \\[_KEYS_\\.\\.\\.\\] \\- Generate keypress event for " - "key \\(e\\.g\\. `space`\\), shortcut \\(e\\.g\\. `ctrl+w`\\), or a sequence of them " - "\\(separated with spaces, e\\.g\\. `ctrl+w space`\\)\\. Additional arguments are forwarded " - "to `xdotool key`\n\n" - "*\\(\\*\\*\\)* /screen \\- Capture screen and send the screenshot as a photo\n\n" - "*\\(\\*\\*\\)* /screenf \\- Capture screen and send the screenshot as a document\n\n" - "/run _COMMAND ARGS_ \\- execute _COMMAND_ with command\\-line whitespace\\-sparated " - "arguments _ARGS_\\. Arguments can be quoted and escaped with backslashes\n\n" - "/rawrun _COMMAND ARGS_ \\- similar to /run, but escaping and quoting are not supported, " - "the string is interpreted as raw\n\n" - "/shell _STRING_ \\- execute _STRING_ in a shell\n\n" - "The emergency shutdown command that you have set in the config file will terminate the " - "bot\\.\n\n" - "*\\(\\*\\)* These commands only work with `xdotool` \n\n" - "*\\(\\*\\*\\)* These commands are guaranteed to work on Windows, macOS or Linux with X11\n\n" - "Note: leading slashes can be omitted in all of the above commands, case does not matter\\. ", - parse_mode="MarkdownV2" - ) - ''' + bot.reply_to(message, help_text) def shutdown(_): print("Stopping due to emergency shutdown command received", flush=True) diff --git a/Rimokon/import_config.py b/Rimokon/import_config.py index e35b26c..fb10587 100644 --- a/Rimokon/import_config.py +++ b/Rimokon/import_config.py @@ -7,6 +7,7 @@ from functools import wraps from .util import cmd_get_action_name, cmd_get_rest +from .plugins.plugin_helpers import help_description from .config import bot_token, admins_ids, \ quick_access_cmds, \ emergency_shutdown_command, emergency_shutdown_public, \ @@ -68,11 +69,14 @@ def noexcept_action_func(bot, message, *args, **kwargs): updated_actions[k] = make_action_noexcept(v) -def complex_alias_from_string(base_action_func, prepended_string): +def complex_alias_from_string(base_action_func, alias_command): """ Construct the action function for a string alias, given its base action function and the - prepended string for the command. + alias value command. """ + prepended_string = cmd_get_rest(alias_command) + + @help_description(f"Alias for {alias_command}") def new_action_func(bot, msg, _rest): # Using `cmd_get_rest(msg.text, ...)` instead of `_rest` to preserve the whitespace # symbol from the message text. @@ -103,7 +107,7 @@ def new_action_func(bot, msg, _rest): 'Note that aliases for aliases are not supported', repr(k_), repr(base_plugin)) base_action_func = updated_actions[base_plugin] - v = complex_alias_from_string(base_action_func, cmd_get_rest(v_)) + v = complex_alias_from_string(base_action_func, v_) elif callable(v_): v = v_ else: @@ -117,5 +121,25 @@ def new_action_func(bot, msg, _rest): del actions, aliases, updated_actions, updated_aliases +# Form the /help text +logger.debug('Building /help message') +help_text = 'Here is the list of actions available:\n\n' +for action_name, action_func in unified_actions.items(): + if hasattr(action_func, 'RimokonHelp'): + description = action_func.RimokonHelp + elif getattr(action_func, '__doc__', None) is not None: + # If no help is explicitly defined, take the first line from the docstring + description = action_func.__doc__.split('\n')[0] + else: + description = '[no description]' + help_text += f'/{action_name} {description}\n\n' +help_text = help_text[:-2] + +if len(help_text) > 4096: # Telegram API limitation + help_text = 'The help message is too large for a Telegram message' + logger.warning('The generated /help message exceeds the maximum Telegram message size. ' + 'File an issue at https://github.com/kolayne/Rimokon/issues') + + # When executed as a script, behave as a config checker. Otherwise just notify of success (print if __name__ == "__main__" else logger.info)("Successfully imported the config file") diff --git a/Rimokon/plugins/plugin_helpers.py b/Rimokon/plugins/plugin_helpers.py index 32c29af..1ed8120 100644 --- a/Rimokon/plugins/plugin_helpers.py +++ b/Rimokon/plugins/plugin_helpers.py @@ -5,6 +5,37 @@ from telebot.types import Message +def with_help_description(f: Callable, help_message: str) -> Callable: + """ + Sets the message `help_message` to be displayed for the action function `f` in the bot's /help. + Modifies the input function and returns it. + + Example: + + def echo_action_function(bot: telebot.TeleBot, msg: telebot.types.Message, rest: str): + bot.reply_to(msg, rest) + with_help_description(echo_action_function, "Replies with the rest of the message after the action name") + """ + setattr(f, 'RimokonHelp', help_message) + return f + +def help_description(help_message: str): + """ + Add message corresponding to this action to display in the bot's /help. + Modifies the input function and returns it. + + Example: + + @help_description("Replies with the rest of the message after the action name") + def echo_action_function(bot: telebot.TeleBot, msg: telebot.types.Message, rest: str): + bot.reply_to(msg, rest) + + """ + def decorate(f: Callable) -> Callable: + return with_help_description(f, help_message) + return decorate + + def _run_notified(f, bot, message, args, kwargs): sent_message = bot.reply_to(message, "Executing...") try: diff --git a/Rimokon/plugins/run_rawrun_shell.py b/Rimokon/plugins/run_rawrun_shell.py index f3eff6f..14dc83e 100644 --- a/Rimokon/plugins/run_rawrun_shell.py +++ b/Rimokon/plugins/run_rawrun_shell.py @@ -6,7 +6,7 @@ from telebot.types import Message from telebot.apihelper import ApiTelegramException -from .plugin_helpers import notify_of_execution_conditionally +from .plugin_helpers import notify_of_execution_conditionally, help_description __all__ = ['shell', 'run', 'rawrun', 'run_parsed_command'] @@ -53,9 +53,11 @@ def run_parsed_command(bot: TeleBot, message: Message, command: Union[str, List[ bot.reply_to(message, f"The command has completed with code {p.returncode}, but I failed " f"to send the response:\n{e}") +@help_description(" Run the command in a shell") def shell(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: run_parsed_command(bot, message, command_rest, shell=True, notify=notify) +@help_description(" Run the command outside of shell (arguments quoting and escaping is supported)") def run(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: try: command = shlex_split(command_rest) @@ -64,5 +66,7 @@ def run(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = Tru else: run_parsed_command(bot, message, command, notify=notify) +@help_description(" Run the command outside of shell (quote and backslash symbols have no " + "special meaning)") def rawrun(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: run_parsed_command(bot, message, command_rest.split(), notify=notify) diff --git a/Rimokon/plugins/screenshot.py b/Rimokon/plugins/screenshot.py index 7294df8..83dc2c8 100644 --- a/Rimokon/plugins/screenshot.py +++ b/Rimokon/plugins/screenshot.py @@ -4,6 +4,8 @@ from PIL import ImageGrab import telebot +from .plugin_helpers import help_description + __all__ = ['screen', 'screenf'] @@ -20,11 +22,13 @@ def take_screenshot_or_complain_to(bot: telebot.TeleBot, message: telebot.types. img.seek(0) return img +@help_description("Make a screenshot and send it as a photo") def screen(bot: telebot.TeleBot, message: telebot.types.Message, _: str) -> None: img = take_screenshot_or_complain_to(bot, message) if img: bot.send_photo(message.chat.id, img, reply_to_message_id=message.message_id) +@help_description("Make a screenshot and send it as a document") def screenf(bot: telebot.TeleBot, message: telebot.types.Message, _: str) -> None: img = take_screenshot_or_complain_to(bot, message) if img: From 961d298d335cf3d74e1828ed07dd8af17cbb9f7f Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 17 Jan 2023 19:30:13 +0300 Subject: [PATCH 12/14] Fix pylint false-positives Pylint reports unexpected keyword argument "notify" --- Rimokon/plugins/run_rawrun_shell.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Rimokon/plugins/run_rawrun_shell.py b/Rimokon/plugins/run_rawrun_shell.py index 14dc83e..cb28b48 100644 --- a/Rimokon/plugins/run_rawrun_shell.py +++ b/Rimokon/plugins/run_rawrun_shell.py @@ -55,6 +55,7 @@ def run_parsed_command(bot: TeleBot, message: Message, command: Union[str, List[ @help_description(" Run the command in a shell") def shell(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: + # pylint: disable=unexpected-keyword-arg run_parsed_command(bot, message, command_rest, shell=True, notify=notify) @help_description(" Run the command outside of shell (arguments quoting and escaping is supported)") @@ -64,9 +65,11 @@ def run(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = Tru except ValueError as e: bot.reply_to(message, f"Failed to parse arguments:\n{e}") else: + # pylint: disable=unexpected-keyword-arg run_parsed_command(bot, message, command, notify=notify) @help_description(" Run the command outside of shell (quote and backslash symbols have no " "special meaning)") def rawrun(bot: TeleBot, message: Message, command_rest: str, *, notify: bool = True) -> None: + # pylint: disable=unexpected-keyword-arg run_parsed_command(bot, message, command_rest.split(), notify=notify) From 65ce781dc0eaa4b85e58fb8c7fb0f21f438c273a Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Sun, 19 Feb 2023 16:27:37 +0300 Subject: [PATCH 13/14] Renames, documentation fixes --- README.md | 4 ++-- Rimokon/__main__.py | 6 +++--- Rimokon/config.py.example | 10 +++++----- Rimokon/import_config.py | 31 ++++++++++++++++++++----------- Rimokon/plugins/plugin_helpers.py | 7 +++++-- Rimokon/util.py | 2 +- 6 files changed, 36 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 38af7be..1bb4c7f 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ The current readme is mainly outdated. - Copy `Rimokon/config.py.example` to `Rimokon/config.py` and modify it according to your use case. - Optionally, install `xdotool`/`ydotool` or a similar utility for graphics interaction. - Use [@BotFather + Optionally, install `xdotool`/`ydotool` or a similar utility for keyboard manipulations. + Use [@BotFather](https://t.me/BotFather) to acquire a Telegram bot token. - Run the bot as a python package: `python3 -m Rimokon` or, from another directory, diff --git a/Rimokon/__main__.py b/Rimokon/__main__.py index 9331bd0..58e4207 100644 --- a/Rimokon/__main__.py +++ b/Rimokon/__main__.py @@ -10,7 +10,7 @@ from .util import cmd_get_action_name, cmd_get_rest from .import_config import bot_token, admins_ids, \ - emergency_shutdown_command, emergency_shutdown_public, \ + emergency_shutdown_command, emergency_shutdown_is_public, \ quick_access_cmds, \ unified_actions, help_text @@ -24,7 +24,7 @@ # Decorator that prevents the actions when executed by a non-admin user. -# Must be specified UNDER the `@bot.*_handler` decorator (must be applied before it) +# MUST be specified UNDER the `@bot.*_handler` decorator (that is, applied BEFORE it) def admins_only_handler(original_handler): @wraps(original_handler) def handler(message, *args, **kwargs): @@ -69,7 +69,7 @@ def shutdown(_): # interval. #Timer(0.1, bot.stop_polling).start() -if not emergency_shutdown_public: +if not emergency_shutdown_is_public: shutdown = admins_only_handler(shutdown) bot.register_message_handler(shutdown, func=lambda message: message.text.strip() == emergency_shutdown_command.strip()) diff --git a/Rimokon/config.py.example b/Rimokon/config.py.example index 8f9402a..fa3bcf5 100644 --- a/Rimokon/config.py.example +++ b/Rimokon/config.py.example @@ -7,8 +7,8 @@ admins_ids = [123, 456] # List of Telegram user identifiers (integers). Find vi # It is an array of arrays of strings. Second-level arrays represent lines of keyboard (top-to-bottom), # strings in them are the commands for the keyboard. # -# `quick_access_cmds` can be empty (or completely omitted in the config), but its subarrays and the strings -# in them must not be empty. +# `quick_access_cmds` can be empty (resulting in no keyboard) but its subarrays and +# the strings in them must not be empty. # # After you update `quick_access_cmds` in the config, you should restart the bot (so that the config file # is reread) and then send `/start` command in Telegram (so that the keyboard on your Telegram client @@ -25,13 +25,13 @@ quick_access_cmds = [['/key space', '/type Hello, World!'], ['/type This will ap # while 'A b123C', '/A b123c', or '/Ab123C' won't. emergency_shutdown_command = 'YOUR_COMMAND_HERE' -# If `emergency_shutdown_public` is `True`, then **any user**, not just the admins, will be allowed +# If `emergency_shutdown_is_public` is `True`, then **any user**, not just the admins, will be allowed # to use the `emergency_shutdown_command`. It is useful in case you loose access to your admin account # while the intruder is still there: with this enabled you will be able to terminate the bot from any # other account. # # It is recommended to leave this enabled and keep the emergency shutdown command in secret. -emergency_shutdown_public = True +emergency_shutdown_is_public = True # Optional: set logging level @@ -66,7 +66,7 @@ actions = { aliases = { # A "simple alias" (or "string alias") just expands the given action name in the beginning of the # received commands to the value. Notice that it is interpreted just like other action names, - # i.e. letters lower/upper case or trailing slashes do not matter. + # i.e. letters lower/upper case or leading slashes do not matter. # For this alias, for example, a command "/Key 123 key 456" is expanded to "Run xdotool key 123 key 456". 'key': 'Run xdotool key', diff --git a/Rimokon/import_config.py b/Rimokon/import_config.py index fb10587..e0644b8 100644 --- a/Rimokon/import_config.py +++ b/Rimokon/import_config.py @@ -1,6 +1,11 @@ """ This file imports the user-defined config.py file and processes all the entries to -bring them to the common form and make it easy to use them. +bring them to the common form and make it easy to use them further in the rest of the code. + +Running this module (as `python3 -m Rimokon.import_config`) can be used to check the config +file for validity. Note, however, that the checks performed here are not comprehensive. +In particular, it is not checked whether the functions specified as actions/aliases +accept the correct set of arguments (they are only checked for being callable). """ import logging @@ -10,7 +15,7 @@ from .plugins.plugin_helpers import help_description from .config import bot_token, admins_ids, \ quick_access_cmds, \ - emergency_shutdown_command, emergency_shutdown_public, \ + emergency_shutdown_command, emergency_shutdown_is_public, \ actions, aliases @@ -23,15 +28,14 @@ def die(*args): - # TODO: use logger object logger.critical(*args) exit(1) def canonicalize_key_or_die(k_: str) -> str: """ - Bring the key to the lowercase stripped and slash-stripped form. + Bring the key to lowercase, strip whitespaces and remove leading slashes. - If the given name is not a valid action name print the error message and terminate the program. + If the given name is not a valid action name, print the error message and terminate the program. """ k = k_.strip().lstrip('/').lower() if any(c.isspace() for c in k): @@ -66,6 +70,8 @@ def noexcept_action_func(bot, message, *args, **kwargs): die('Attempt to redefine action %s (in `config.actions`)', repr(k)) if not callable(v): die('The action function specified by key %s is not callable', repr(k_)) + # Note: the above checks are not comprehensive, as the function isn't ensured to + # accept the correct set of parameters. updated_actions[k] = make_action_noexcept(v) @@ -97,21 +103,24 @@ def new_action_func(bot, msg, _rest): logger.debug('Processing alias %s', repr(k_)) k = canonicalize_key_or_die(k_) if k in updated_aliases.keys(): - die('Attempt to redefine alias %s (in `config.aliases`)', repr(k)) + die('Attempt to redefine an alias %s (in `config.aliases`)', repr(k_)) if k in updated_actions.keys(): - die('Alias %s attempts to overwrite an existing action', repr(k_)) + die('Attempt to overwrite an existing action by alias %s (in `config.aliases`)', + repr(k_)) if isinstance(v_, str): - base_plugin = cmd_get_action_name(v_) - if base_plugin not in updated_actions.keys(): + base_action_name = cmd_get_action_name(v_) + if base_action_name not in updated_actions.keys(): die('Alias %s relies on the action %s which does not exist. ' - 'Note that aliases for aliases are not supported', repr(k_), repr(base_plugin)) - base_action_func = updated_actions[base_plugin] + 'Note that aliases for aliases are not supported', repr(k_), repr(base_action_name)) + base_action_func = updated_actions[base_action_name] v = complex_alias_from_string(base_action_func, v_) elif callable(v_): v = v_ else: die('The alias %s specifies neither a string nor a callable object', repr(k_)) + # Note: the above checks are not comprehensive: in the case `callable(v_)` the function is not + # ensured to accept the correct set of parameters. updated_aliases[k] = make_action_noexcept(v) diff --git a/Rimokon/plugins/plugin_helpers.py b/Rimokon/plugins/plugin_helpers.py index 1ed8120..921f546 100644 --- a/Rimokon/plugins/plugin_helpers.py +++ b/Rimokon/plugins/plugin_helpers.py @@ -64,8 +64,11 @@ def notify_of_execution_conditionally(f: Callable[[TeleBot, Message, ...], Any]) Modifes an action function (which accepts `telebot.TeleBot` and `telebot.Message` as its first position arguments) such that an additional kw-only argument `notify` is added to - the function. If it is `True` (default), the behavior is the same with `notify_of_execution`; - if it is `False`, the behavior is as if the function was unmodified + the function. If it is `True` (default), the user will be notified of execution, + as with `notify_of_execution`. If it is `False`, the behavior is as if the original + function was called. + + The underlying function never receives the `notify` keyword argument. """ @wraps(f) def decorated(bot: TeleBot, message: Message, *args, notify: bool = True, **kwargs): diff --git a/Rimokon/util.py b/Rimokon/util.py index d206ed2..4049568 100644 --- a/Rimokon/util.py +++ b/Rimokon/util.py @@ -10,7 +10,7 @@ def cmd_get_action_name(s: Optional[str]) -> Optional[str]: def cmd_get_rest(s: str, cut_first_whitespace: bool = True) -> str: """ - Cut the first word of the string and (optionally, default) the first whitespace symbol after it. + Cut the first word of the string and, optionally, the first whitespace symbol after it (default). Return the rest. """ s = s.lstrip() From 3e92662154575ddce21959cc666a5f2ee33feb4e Mon Sep 17 00:00:00 2001 From: Nikolay Nechaev Date: Tue, 21 Feb 2023 12:23:52 +0300 Subject: [PATCH 14/14] Readme: remove the outdated notice --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index 1bb4c7f..dbfbd3b 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,6 @@ Telegram bot for simple remote control of the device it is running on. -## Redesign in progress - -The current readme is mainly outdated. - - ## Basic usage - Install requirements: