Creating a new Command¶
A Royalnet Command is a small script that is run whenever a specific message is sent to a Royalnet platform.
A Command code looks like this:
import royalnet.commands as rc
class PingCommand(rc.Command):
name = "ping"
description = "Play ping-pong with the bot."
# This code is run just once, while the bot is starting
def __init__(self, serf: "Serf", config):
super().__init__(serf=serf, config=config)
# This code is run every time the command is called
async def run(self, args: rc.CommandArgs, data: rc.CommandData):
await data.reply("Pong!")
Creating a new Command¶
First, think of a name
for your command.
It’s the name your command will be called with: for example, the “spaghetti” command will be called by typing /spaghetti in chat.
Try to keep the name as short as possible, while staying specific enough so no other command will have the same name.
Next, create a new Python file with the name
you have thought of.
The previously mentioned “spaghetti” command should have a file called spaghetti.py
.
Then, in the first row of the file, import the Command
class from royalnet, and create a new class inheriting from it:
import royalnet.commands as rc
class SpaghettiCommand(rc.Command):
...
Inside the class, override the attributes name
and description
with respectively the name of the command and a small description of what the command will do:
import royalnet.commands as rc
class SpaghettiCommand(rc.Command):
name = "spaghetti"
description = "Send a spaghetti emoji in the chat."
Now override the run()
method, adding the code you want the bot to run when the command is called.
To send a message in the chat the command was called in, you can use the reply()
coroutine:
import royalnet.commands as rc
class SpaghettiCommand(rc.Command):
name = "spaghetti"
description = "Send a spaghetti emoji in the chat."
async def run(self, args: rc.CommandArgs, data: rc.CommandData):
await data.reply("🍝")
Finally, open the commands/__init__.py
file, and import your command there, then add a reference to your imported
command to the available_commands
list:
# Imports go here!
from .spaghetti import SpaghettiCommand
# Enter the commands of your Pack here!
available_commands = [
SpaghettiCommand,
]
# Don't change this, it should automatically generate __all__
__all__ = [command.__name__ for command in available_commands]
Formatting command replies¶
You can use a subset of BBCode to format messages sent with reply()
:
async def run(self, args: rc.CommandArgs, data: rc.CommandData):
await data.reply("[b]Bold of you to assume that my code has no bugs.[/b]")
Available tags¶
Here’s a list of all tags that can be used:
[b]bold[/b]
[i]italic[/i]
[c]code[/c]
[p]multiline \n code[/p]
[url=https://google.com]inline link[/url]
(will be rendered differently on every platform)
Command arguments¶
A command can have some arguments passed by the user: for example, on Telegram an user may type /spaghetti carbonara al-dente
to pass the str
“carbonara al-dente” to the command code.
These arguments can be accessed in multiple ways through the args
parameter passed to the run()
method.
If you want your command to use arguments, override the syntax
class attribute with a brief description of the
syntax of your command, possibly using {curly braces} for required arguments and [square brackets] for optional
ones.
import royalnet.commands as rc
class SpaghettiCommand(rc.Command):
name = "spaghetti"
description = "Send a spaghetti emoji in the chat."
syntax = "{first_pasta} [second_pasta]"
async def run(self, args: rc.CommandArgs, data: rc.CommandData):
first_pasta = args[0]
second_pasta = args.optional(1)
if second_pasta is None:
await data.reply(f"🍝 Here's your {first_pasta}!")
else:
await data.reply(f"🍝 Here's your {first_pasta} and your {second_pasta}!")
Direct access¶
You can consider arguments as if they were separated by spaces.
You can then access command arguments directly by number as if the args object was a list of str
.
If you request an argument with a certain number, but the argument does not exist, an
InvalidInputError
is raised, making the arguments accessed in this way required.
args[0]
# "carbonara"
args[1]
# "al-dente"
args[2]
# raises InvalidInputError
Optional access¶
If you don’t want arguments to be required, you can access them through the optional()
method: it
will return None
if the argument wasn’t passed, making it optional.
args.optional(0)
# "carbonara"
args.optional(1)
# "al-dente"
args.optional(2)
# None
You can specify a default result too, so that the method will return it instead of returning None
:
args.optional(2, default="banana")
# "banana"
Full string¶
If you want the full argument string, you can use the joined()
method.
args.joined()
# "carbonara al-dente"
You can specify a minimum number of arguments too, so that an InvalidInputError
will be
raised if not enough arguments are present:
args.joined(require_at_least=3)
# raises InvalidInputError
Regular expressions¶
For more complex commands, you may want to get arguments through regular expressions.
You can then use the match()
method, which tries to match a pattern to the command argument string,
which returns a tuple of the matched groups and raises an InvalidInputError
if there is no match.
To match a pattern, re.match()
is used, meaning that Python will try to match only at the beginning of the string.
args.match(r"(carb\w+)")
# ("carbonara",)
args.match(r"(al-\w+)")
# raises InvalidInputError
args.match(r"\s*(al-\w+)")
# ("al-dente",)
args.match(r"\s*(carb\w+)\s*(al-\w+)")
# ("carbonara", "al-dente")
Raising errors¶
If you want to display an error message to the user, you can raise a CommandError
using the error message as argument:
if not kitchen.is_open():
raise CommandError("The kitchen is closed. Come back later!")
There are some subclasses of CommandError
that can be used for some more specific cases:
UserError
The user did something wrong, it is not a problem with the bot.
InvalidInputError
The arguments the user passed to the command by the user are invalid. Additionally displays the command syntax in the error message.
UnsupportedError
The command is not supported on the platform it is being called.
ConfigurationError
A value is missing or invalid in the
config.toml
section of your pack.ExternalError
An external API the command depends on is unavailable or returned an error.
ProgramError
An error caused by a programming mistake. Equivalent to
AssertionError
, but includes a message to facilitate debugging.
Coroutines and slow operations¶
You may have noticed that in the previous examples we used await data.reply("🍝")
instead of just data.reply("🍝")
.
This is because reply()
isn’t a simple method: it is a coroutine, a special kind of function that
can be executed separately from the rest of the code, allowing the bot to do other things in the meantime.
By adding the await
keyword before the data.reply("🍝")
, we tell the bot that it can do other things, like
receiving new messages, while the message is being sent.
You should avoid running slow normal functions inside bot commands, as they will stop the bot from working until they are finished and may cause bugs in other parts of the code!
async def run(self, args, data):
# Don't do this!
image = download_1_terabyte_of_spaghetti("right_now", from="italy")
...
If the slow function you want does not cause any side effect, you can wrap it with the royalnet.utils.asyncify()
function:
async def run(self, args, data):
# If the called function has no side effect, you can do this!
image = await asyncify(download_1_terabyte_of_spaghetti, "right_now", from="italy")
...
Avoid using time.sleep()
function, as it is considered a slow operation: use instead asyncio.sleep()
,
a coroutine that does the same exact thing but in an asyncronous way.
Delete the invoking message¶
The invoking message of a command is the message that the user sent that the bot recognized as a command; for example,
the message /spaghetti carbonara
is the invoking message for the spaghetti
command run.
You can have the bot delete the invoking message for a command by calling the delete_invoking
method:
async def run(self, args, data):
await data.delete_invoking()
Not all platforms support deleting messages; by default, if the platform does not support deletions, the call is ignored.
You can have the method raise an error if the message can’t be deleted by setting the error_if_unavailable
parameter
to True:
async def run(self, args, data):
try:
await data.delete_invoking(error_if_unavailable=True)
except royalnet.error.UnsupportedError:
await data.reply("🚫 The message could not be deleted.")
else:
await data.reply("✅ The message was deleted!")
Sharing data between multiple calls¶
The Command
class is shared between multiple command calls: if you need to store some data, you may store it as a protected/private field of your command class:
class SpaghettiCommand(rc.Command):
name = "spaghetti"
description = "Send a spaghetti emoji in the chat."
syntax = "(requestedpasta)"
__total_spaghetti = 0
async def run(self, args: rc.CommandArgs, data: rc.CommandData):
self.__total_spaghetti += 1
await data.reply(f"🍝 Here's your {args[0]}!\n"
f"[i]Spaghetti have been served {self.__total_spaghetti} times.[/i]")
Values stored in this way persist only until the bot is restarted, and won’t be shared between different serfs; if you need persistent values, it is recommended to use a database through the Alchemy service.
Using the Alchemy¶
Royalnet can be connected to a PostgreSQL database through a special SQLAlchemy interface called
royalnet.alchemy.Alchemy
.
If the connection is established, the self.alchemy
and data.session
fields will be
available for use in commands.
self.alchemy
is an instance of royalnet.alchemy.Alchemy
, which contains the
sqlalchemy.engine.Engine
, metadata and tables, while data.session
is a
sqlalchemy.orm.session.Session
, and can be interacted in the same way as one.
Querying the database¶
You can sqlalchemy.orm.query.Query
the database using the SQLAlchemy ORM.
The SQLAlchemy tables can be found inside royalnet.alchemy.Alchemy
with the royalnet.alchemy.Alchemy.get()
method:
import royalnet.backpack.tables as rbt
User = self.alchemy.get(rbt.User)
Adding filters to the query¶
You can filter the query results with the sqlalchemy.orm.query.Query.filter()
method.
Note
Remember to always use a table column as first comparision element, as it won’t work otherwise.
query = query.filter(User.role == "Member")
Ordering the results of a query¶
You can order the query results in ascending order with the sqlalchemy.orm.query.Query.order_by()
method.
query = query.order_by(User.username)
Additionally, you can append the .desc() method to a table column to sort in descending order:
query = query.order_by(User.username.desc())
Fetching the results of a query¶
You can fetch the query results with the sqlalchemy.orm.query.Query.all()
,
sqlalchemy.orm.query.Query.first()
, sqlalchemy.orm.query.Query.one()
and
sqlalchemy.orm.query.Query.one_or_none()
methods.
Remember to use royalnet.utils.asyncify()
when fetching results, as it may take a while!
Use sqlalchemy.orm.query.Query.all()
if you want a list
of all results:
results: list = await asyncify(query.all)
Use sqlalchemy.orm.query.Query.first()
if you want the first result of the list, or None
if
there are no results:
result: typing.Union[..., None] = await asyncify(query.first)
Use sqlalchemy.orm.query.Query.one()
if you expect to have a single result, and you want the command to
raise an error if any different number of results is returned:
result: ... = await asyncify(query.one) # Raises an error if there are no results or more than a result.
Use sqlalchemy.orm.query.Query.one_or_none()
if you expect to have a single result, or nothing, and
if you want the command to raise an error if the number of results is greater than one.
result: typing.Union[..., None] = await asyncify(query.one_or_none) # Raises an error if there is more than a result.
Calling Events¶
You can call an event from inside a command, and receive its return value.
This may be used for example to get data from a different platform, such as getting the users online in a specific Discord server.
You can call an event with the Serf.call_herald_event()
method:
result = await self.serf.call_herald_event("event_name")
You can also pass parameters to the called event:
result = await self.serf.call_herald_event("event_name", ..., kwarg=..., *..., **...)
Errors raised by the event will also be raised by the Serf.call_herald_event()
method as one of the exceptions described in the Raising errors section.
Distinguish between platforms¶
To see if a command is being run on a specific platform, you can check the type of the self.serf
object:
import royalnet.serf.telegram as rst
import royalnet.serf.discord as rsd
...
if isinstance(self.serf, rst.TelegramSerf):
await data.reply("This command is being run on Telegram.")
elif isinstance(self.serf, rsd.DiscordSerf):
await data.reply("This command is being run on Discord.")
...
Displaying Keyboards¶
A keyboard is a message with multiple buttons (“keys”) attached which can be pressed by an user viewing the message.
Once a button is pressed, a callback function is run, which has its own CommandData
context and can do everything a regular comment call could.
The callback function is a coroutine accepting a single data: CommandData
argument:
async def answer(data: CommandData) -> None:
await data.reply("Spaghetti were ejected from your floppy drive!")
To create a new key, you can use the KeyboardKey
class:
key = KeyboardKey(
short="⏏️", # An emoji representing the key on platforms the full message cannot be displayed
text="Eject spaghetti from the floppy drive", # The text displayed on the key
callback=answer # The coroutine to call when the key is pressed.
)
To display a keyboard and wait for a keyboard press, you can use the keyboard()
asynccontextmanager.
While the contextmanager is in scope, the keyboard will be valid and it will be possible to interact with it.
Any further key pressed will be answered with an error message.
async with data.keyboard(text="What kind of spaghetti would you want to order?", keys=keyboard):
# This will keep the keyboard valid for 10 seconds
await asyncio.sleep(10)
Replies in callbacks¶
Calls to reply()
made with the CommandData
of a keyboard callback won’t always result in a message being sent: for example, on Telegram, replies will result in a small message being displayed on the top of the screen.
Reading data from the configuration file¶
You can read data from your pack’s configuration section through the config
attribute:
[Packs."spaghettipack"]
spaghetti = { mode="al_dente", two=true }
await data.reply(f"Here's your spaghetti {self.config['spaghetti']['mode']}!")
Running code on Serf start¶
The code inside __init__
is run only once, during the initialization step of the bot:
def __init__(self, serf: "Serf", config):
super().__init__(serf=serf, config=config)
# The contents of this variable will be persisted across command calls
self.persistent_variable = 0
# The text will be printed only if the config flag is set to something
if config["spaghetti"]["two"]:
print("Famme due spaghi!")
Note
Some methods may be unavailable during the initialization of the Serf.
Running repeating jobs¶
To run a job independently from the rest of the command, you can schedule the execution of a coroutine inside __init__
:
async def mycoroutine():
while True:
print("Free spaghetti every 60 seconds!")
await asyncio.sleep(60)
def __init__(self, serf: "Serf", config):
super().__init__(serf=serf, config=config)
self.loop.create_task(mycoroutine())
As it will be executed once for every platform Royalnet is running on, you may want to run the task only on a single platform:
def __init__(self, serf: "Serf", config):
super().__init__(serf=serf, config=config)
if isinstance(self.serf, rst.TelegramSerf):
self.loop.create_task(mycoroutine())