Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Redesigned for plugins and aliases support #19

Merged
merged 14 commits into from
Feb 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,4 @@ dmypy.json
.pyre/

# Project-level
/config.py
Rimokon/config.py
72 changes: 28 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,47 @@

Telegram bot for simple remote control of the device it is running on.

## 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 keyboard manipulations.
Use [@BotFather](https://t.me/BotFather) to acquire a Telegram bot token.

- 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

Expand All @@ -69,6 +51,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.
Expand All @@ -85,6 +68,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
Expand Down
Empty file added Rimokon/__init__.py
Empty file.
99 changes: 99 additions & 0 deletions Rimokon/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
from functools import wraps
from threading import Thread #, Timer
from time import sleep
from sys import stderr
from traceback import format_exc

import telebot
from requests.exceptions import RequestException

from .util import cmd_get_action_name, cmd_get_rest
from .import_config import bot_token, admins_ids, \
emergency_shutdown_command, emergency_shutdown_is_public, \
quick_access_cmds, \
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)


# Decorator that prevents the actions when executed by a non-admin user.
# 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):
if message.chat.id in admins_ids:
return original_handler(message, *args, **kwargs)
else:
bot.reply_to(message, "You are not my admin. Ignored")

return handler


@bot.message_handler(func=lambda message: cmd_get_action_name(message.text) == 'start')
def start(message: telebot.types.Message):
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, help_text)

def shutdown(_):
print("Stopping due to emergency shutdown command received", flush=True)

# Default behavior: stop polling immediately, but the shutdown command might remain in
# the unprocessed messages on Telegram side, so unless you run after_shutdown.py,
# the bot might receive this command again when you start it next time.
bot.stop_polling()

# Alternative behavior (don't forget to import `threading.Timer`):
# stop the bot after a small timeout. This will make sure that the shutdown command is
# processed and the bot won't receive it again, however, it is theoretically less safe:
# there is a chance that a malicious command will arrive in the one tenth of a second
# interval.
#Timer(0.1, bot.stop_polling).start()

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())

@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 unified_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")


if __name__ == "__main__":
while True:
try:
bot.polling(non_stop=True)
break # If successfully stopped polling, should not retry
except RequestException:
# If internet connection issue, wait a little and restart
print("Having internet connection issues. Retrying in 3 seconds...", file=stderr)
print(format_exc(), file=stderr)
sleep(3)
print("Restarted\n", file=stderr)
78 changes: 78 additions & 0 deletions Rimokon/config.py.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
bot_token = '123456:ABCDEFGHI' # Telegram bot token from @BotFather
admins_ids = [123, 456] # List of Telegram user identifiers (integers). Find via @myidbot

# `quick_access_cmds` represents the keyboard that will be displayed as Telegram keyboard (for quick
# access to frequently used commands).
#
# 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 (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
# is updated)
quick_access_cmds = [['/key space', '/type Hello, World!'], ['/type This will appear on second line']]

# `emergency_shutdown_command` is a string that can be used for emergency shutdown, intended for
# the scenario when a malicious user gets access to one of the admins' accounts.
# If the bot receives a message with this text, it terminates immediately. Notice, that the message
# text must exactly match the string (including lowercase/capital letters and special symbols),
# except for the leading and trailing whitespaces (they will be ignored).
#
# For example, if the command is set to '/A b123C ', message ' /A b123C' will terminate the bot,
# while 'A b123C', '/A b123c', or '/Ab123C' won't.
emergency_shutdown_command = 'YOUR_COMMAND_HERE'

# 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_is_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.

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 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',

# 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)
}
Loading