Routers and filters¶
Bot and SyncBot are routers themselves: everything on this page works on a
bot and on a Router. The guide is Events, filters and middleware.
Router¶
- class Router(name=None)¶
A set of handlers, with their middlewares and error handlers, and sub-routers.
BotandSyncBotare routers, so every decorator here works on a bot. A separateRoutersplits a bot into modules: register handlers on it, then include it into the bot withinclude_router.For each update, a router tries its own handlers first: higher
priorityfirst, then in the order they were registered. Then come the included routers, in the order they were included, each with the routers it includes (depth first). Only the handlers for the update’s kind are tried, along with those registered withon_update. The first one whose filters all match gets the update, and the others are not tried unless it raisesSkipHandler. When no handler matches, nothing happens and the update counts as handled.A handler’s first parameter gets the event (a
Message, aCallbackQuery, …). The other parameters are filled by name:bot: the bot running the handler;update: the wholeUpdate;command: theParsedCommand, for a command;match: there.Matchof a regex filter;any key that a filter or a middleware added.
A
**kwargsparameter gets all of them. The first parameter must then have another name:async def log(update, **kwargs)in anon_updatehandler getsupdatetwice, and the call fails withTypeError. A parameter that nothing fills keeps its default; without one the call raisesTypeError, which goes to the error handlers.A
Botrunsasync defhandlers, error handlers and middlewares, and aSyncBotplain functions, with noasyncfilters. The bot checks this when it starts and raisesConfigurationErroron a mismatch.Example
A module with its own router, included into the bot:
# shop.py router = Router(name="shop") @router.command("buy", description="Buy something") async def buy(message, command): await message.reply(f"{command.args} is in your cart") # main.py import shop bot = Bot() bot.include_router(shop.router) bot.run()
Makes an empty router.
- Parameters:
name (str | None) – A name for error messages and
repr(), such as"admin".Noneor""gives"router-"followed by the object’sid()in hex.
- include_router(router)¶
Adds a sub-router, whose handlers are tried after this router’s own.
They also come after the handlers of the routers included before it. The middlewares of this router wrap the handlers of
routertoo, and run before its own. A router can be included only once.- Parameters:
router (Router) – The router to include.
- Returns:
routeritself, so a router can be made and included in one line.- Raises:
ValueError – When
routeris this router, is already included somewhere, or contains this router (that would make a loop).- Return type:
- include_routers(*routers)¶
Adds several sub-routers, in order, as
include_routerdoes.- Parameters:
*routers (Router) – The routers to include.
- Raises:
ValueError – As for
include_router. The routers before the one that failed stay included.
- on_message(*filters, private=None, group=None, channel=None, priority=0)¶
Registers a handler for new messages.
The handler gets a
Message(aSyncMessageon aSyncBot). Commands are messages too: register thecommandhandlers first, or a handler here with no filters takes them.- Parameters:
*filters (Any) – Filters that must all match: the ready-made ones in
pgpcbot.filters,Filterobjects, or plain functions that take the message.private (bool | None) –
Truetakes only private chats,Falseanything but private chats,None(the default) does not look at the chat. SeveralTrueflags take either kind:private=True, group=True.group (bool | None) – The same, for groups.
channel (bool | None) – The same, for channels.
priority (int) – Handlers with a higher number are tried first within this router. The same number keeps the order of registration.
- Returns:
A decorator that registers the function and gives it back unchanged.
- Return type:
Callable[[F], F]
Example
An echo for private chats:
@bot.on_message(f.has_text, private=True) async def echo(message): await message.reply(message.text)
- on_edited_message(*filters, private=None, group=None, channel=None, priority=0)¶
Registers a handler for edits: somebody edited a message the bot had received.
The handler gets the
Messageas it is now, with the new text (aSyncMessageon aSyncBot). The arguments work as inon_message.- Parameters:
*filters (Any) – Filters that must all match.
private (bool | None) –
Truetakes only private chats,Falseanything but private chats,None(the default) does not look at the chat.group (bool | None) – The same, for groups.
channel (bool | None) – The same, for channels.
priority (int) – Handlers with a higher number are tried first within this router.
- Returns:
A decorator that registers the function and gives it back unchanged.
- Return type:
Callable[[F], F]
- command(*names, description=None, filters=(), ignore_mention=False, priority=0)¶
Registers a handler for one or more commands,
/nameand/name@yourbot.The handler gets the
Message(aSyncMessageon aSyncBot) and, if it has a parameter calledcommand, theParsedCommandwith the arguments. A command addressed to another bot, such as/start@otherbot, is ignored unlessignore_mentionisTrue. Only new messages count: a message edited into a command does not run it.- Parameters:
*names (str) – The command names, each 1 to 32 letters, digits or
_, with or without/. Case does not matter. The names are not checked here: one that breaks this rule, such as"my-command", never matches.description (str | None) – The line shown next to the command in the menu the app opens when someone types
/, 1 to 256 characters.Bot.sync_commandspublishes the commands that have one (seedeclared_commands), and checks the names and descriptions then;Noneleaves the command out of the menu.filters (tuple[Any, ...]) – More filters that must match, as a tuple:
filters=(f.private,).ignore_mention (bool) –
Truealso takes the commands addressed to another bot.priority (int) – Handlers with a higher number are tried first within this router.
- Returns:
A decorator that registers the function and gives it back unchanged.
- Raises:
ValueError – When no name is given. To take any command, use
on_message(f.command()).- Return type:
Callable[[F], F]
Example
/weather milananswers for Milan,/weatheralone for Rome:@bot.command("weather", description="Forecast for a town") async def weather(message, command): town = command.args or "Rome" await message.reply(f"{town}: sun.")
- on_callback_query(*filters, data=None, prefix=None, pattern=None, priority=0)¶
Registers a handler for presses on callback buttons.
The handler gets a
CallbackQuery(aSyncCallbackQueryon aSyncBot). Answer it withCallbackQuery.answerwithin 60 seconds. A press that nothing answered gets an empty answer once the handlers are done, even when they failed, so the button stops spinning (unless the bot was made withauto_answer_callbacks=False).- Parameters:
*filters (Any) – Filters that must all match.
data (str | None) – Takes only the presses whose data is exactly this, such as
"menu:settings".prefix (str | None) – Takes only the presses whose data starts with this, such as
"vote:". Given withdata, either one is enough.pattern (str | None) – A regular expression searched in the data (
re.search()); the handler can ask for thematch. Given withdataorprefix, both must match.priority (int) – Handlers with a higher number are tried first within this router.
- Returns:
A decorator that registers the function and gives it back unchanged.
- Return type:
Callable[[F], F]
Example
One handler for every
vote:button:@bot.on_callback_query(prefix="vote:") async def vote(query): choice = query.data.removeprefix("vote:") await query.answer(f"You voted {choice}")
- on_deleted_messages(*filters, priority=0)¶
Registers a handler for deletions: messages were deleted for everyone.
The handler gets a
DeletedMessages, with the chat and the IDs of the messages.- Parameters:
- Returns:
A decorator that registers the function and gives it back unchanged.
- Return type:
Callable[[F], F]
- on_chat_member(*filters, priority=0)¶
Registers a handler for changes to the bot’s own place in a chat.
The bot was added, promoted, restricted or removed, or a person blocked or unblocked it. The handler gets a
ChatMemberUpdate.- Parameters:
- Returns:
A decorator that registers the function and gives it back unchanged.
- Return type:
Callable[[F], F]
Example
A thank-you when the bot is added:
@bot.on_chat_member() async def membership(change): if change.joined: await bot.send_message(change.chat, "Thanks for adding me.")
- on_update(*filters, priority=0)¶
Registers a handler for every update, of any kind.
The handler gets the
Updateitself, and so do its filters: the ones that look at the chat or at the sender (f.private,f.chat(...),f.user(...)) work, the others (text, media, button data) never match. It takes its turn like any other handler: registered before the others, it gets every update unless it raisesSkipHandler.
- on_error(*exceptions)¶
Registers an error handler, for errors raised while an update is processed.
It takes the errors raised by filters, middlewares and handlers, and the ones raised while an encrypted message is opened (
DecryptionError,SignatureError, andKeyChangedErrorwithpin_keys=True), in which case no handler runs. The callback gets the error first, and can ask forbot,updateanderrorby name; what filters and middlewares added does not reach it. With**kwargs, the first parameter must not be callederror, or the call fails withTypeError.Error handlers are tried in order: this router’s first, in the order they were registered, then those of the included routers, depth first. The first one whose types match runs; raise
SkipHandlerin it to pass the error on to the next one. If it returns normally, the update counts as handled. If it raises, or no error handler takes the error, the update fails: a running bot keeps it in its failed list with the error (Bot.failed_updates), andBot.process_updateraises the error.- Parameters:
*exceptions (type[BaseException]) – The exception classes to take, subclasses included. None at all takes any
Exception. Errors that are not anException, such asKeyboardInterrupt, never reach an error handler.- Returns:
A decorator that registers the function and gives it back unchanged.
- Raises:
TypeError – When one of
exceptionsis not an exception class.- Return type:
Callable[[F], F]
Example
A last resort that logs the error, after which the update counts as handled:
@bot.on_error() async def everything_else(error, update): log.error("update %s failed", update.update_id, exc_info=error)
Note
Error handlers are not limited to their own router: the bot tries all of them, in the order above, for an error raised anywhere.
- middleware(func)¶
Adds a middleware around every handler of this router and of its sub-routers.
A middleware is called as
mw(handler, event, data).handlerruns the rest of the chain (the inner middlewares, then the handler),eventis what the handler gets, anddatais the dict of extra handler arguments. Callawait handler(event, data)to go on, and return what it returns. Not calling it stops the update there: no other handler is tried. RaisingSkipHandlerpasses the update on to the next matching handler. A key added todatareaches the handler, which can ask for it by name.Middlewares run only around a handler whose filters matched, from the outside in: the outer routers’ first, then this router’s, in the order they were added. On a
SyncBota middleware is a plain function that returnshandler(event, data).- Parameters:
- Returns:
funcitself, so this method works as a decorator.- Raises:
TypeError – When
funcis not callable.- Return type:
Example
Loading a profile that handlers can ask for:
@bot.middleware async def load_profile(handler, event, data): data["profile"] = await db.load(event.from_user.id) if event.from_user else None return await handler(event, data) @bot.on_message() async def hello(message, profile): ...
- remove_handler(callback)¶
Removes every handler and error handler of
callback, here and in the sub-routers.The decorators give the function back unchanged, so pass the decorated function. Middlewares are not touched.
- property handlers: tuple[Handler, ...]¶
This router’s own handlers, in the order they are tried.
The handlers of included routers are not in it;
all_handlerswalks the whole tree.
- iter_handlers(kind)¶
Yields the handlers that can take an update of
kind, in the order they are tried.The bot walks this for each update. The handlers registered with
on_updatecome up for every kind.
- iter_error_handlers()¶
Yields every error handler of this router and below, in the order they are tried.
This router’s come first, in the order they were registered, then those of each included router, depth first.
- Yields:
The
ErrorHandlerrecords.- Return type:
- all_handlers()¶
Yields every handler of this router and below, whatever kind of update it takes.
They come in the order they are tried: this router’s first, then those of each included router, depth first.
- all_middlewares()¶
Yields every middleware of this router and below.
This router’s come first, in the order they were added, then those of each included router, depth first.
- declared_commands()¶
Lists the commands registered with a description, in this router and below.
This is the menu that
Bot.sync_commandspublishes. Commands without a description are left out. When a name has several descriptions, the first one wins.
- exception SkipHandler¶
Raised by a handler to pass the update on to the next matching handler.
The next handler is tried as if this one’s filters had not matched. When no handler is left, the update counts as handled. A middleware can raise it too.
Raised by an error handler, it passes the error on to the next error handler whose types match. When no error handler is left, the update fails, as if no error handler had taken the error.
A filter must not raise it: there it is an error like any other, which goes to the error handlers, and no handler runs. A filter that does not match returns
False.Example
A guard that deletes spam and lets everything else through:
@bot.on_message(priority=10) async def spam_guard(message): if not looks_like_spam(message.text): raise SkipHandler # not spam: the next handler gets it await message.delete()
Commands¶
- class ParsedCommand(name, args, mention, token)¶
A command read from a message, such as
/start@weatherbot rome.A handler registered with
Router.command, or with acommandfilter, gets one when it has a parameter calledcommand.parse_commandmakes one from any text.- args¶
Everything after the command, with the spaces at both ends removed:
"rome". An empty string when nothing follows.- Type:
- token¶
The command exactly as written, with its
@handleif it has one:"/start@weatherbot".- Type:
Example
>>> from pgpcbot.filters import parse_command >>> command = parse_command("/Weather@WeatherBot rome 3") >>> command.name, command.mention, command.args ('weather', 'WeatherBot', 'rome 3') >>> command.argv ['rome', '3']
Reading the handlers back¶
Router.iter_handlers() and the other readers give back these records. The dispatcher uses
them; in tests they show what a router holds.
- class Handler(
- callback,
- kind,
- filters,
- priority=0,
- commands=(),
- _params=frozenset({}),
- _takes_kwargs=False,
One registered handler: the function, the kind of update it takes and its filters.
The decorators of
Routermake these, andhandlers,iter_handlersandall_handlersgive them back. There is no need to make one by hand.- kind¶
The kind of update it takes, or
Nonefor every kind (on_update).- Type:
UpdateKind | None
- filters¶
The filters that must all match, each wrapped in a
Filter. The ones that the decorator’s own arguments add (the command names,private=True,prefix=...) come first.
- commands¶
For a
commandhandler, a(name, description)pair for each name, lowercased and without/, withNonewhen there is no description. Empty for the other handlers.
- property is_async: bool[source]¶
Truewhen the callback isasync.That is an
async deffunction, or an object whose__call__isasync def. ABotrefuses to start with a handler that is not async, aSyncBotwith one that is.
- property name: str[source]¶
The callback’s
__qualname__, or itsrepr()when it has none.Error messages name the handler with it.
- kwargs_for(data)[source]¶
Picks out of
datathe extra arguments the callback asks for.The callback’s first parameter takes the event. Each other parameter that can be passed by keyword is filled by name, when
datahas that key. A callback with**kwargs, or one whose signature cannot be read, gets all ofdata.
- class ErrorHandler(callback, exceptions, _params=frozenset({}), _takes_kwargs=False)[source]¶
One registered error handler: the function and the exception types it takes.
on_errormakes these, anditer_error_handlersgives them back. There is no need to make one by hand.- exceptions¶
The exception classes it takes.
(Exception,)whenon_errorwas given none.- Type:
tuple[type[BaseException], …]
- property is_async: bool[source]¶
Truewhen the callback isasync.That is an
async deffunction, or an object whose__call__isasync def.
- matches(error)[source]¶
Tells whether this error handler takes
error.- Parameters:
error (BaseException) – The exception raised while the update was processed.
- Returns:
Truewhenerroris an instance of one ofexceptions, subclasses included.- Return type:
- kwargs_for(data)[source]¶
Picks out of
datathe extra arguments the callback asks for.The callback’s first parameter takes the error. Each other parameter that can be passed by keyword is filled by name, when
datahas that key. A callback with**kwargs, or one whose signature cannot be read, gets all ofdata.
Filters¶
Filters decide which handler gets an update.
The filters go in the decorator, and all of them must match. They run in order, and the first one that does not match stops the others:
from pgpcbot import filters as f
@bot.on_message(f.private, f.has_text)
async def chat(message): ...
A filter is called with the event the handler would get (a Message, a
CallbackQuery, …) and answers whether it matches. Any function that takes the
event and returns True or False is a filter, lambdas included. It can be async,
except on a SyncBot. A function with two positional parameters also gets a
FilterContext, with the bot’s own handle.
A filter can answer with a dict instead of True. That is a match, and the handler can
ask for each item of the dict by name: this is how command hands over command and
regex hands over match. False, None and any other false value mean no match.
Filter objects combine with & (and), | (or) and ~ (not). A plain function
combines with a Filter on the other side, so f.private & my_function works.
Ready-made filters, to use as they are:
has_text: the message has text.has_media: the message has attachments.encrypted: the message came end-to-end encrypted.signed: encrypted, with a valid signature from the sender’s key.reply: the message answers another one.mentioned: the text mentions the bot’s@handle.
Filters you make with your own values:
text(f.text("yes", "no")): the whole text is one of the values.regex(f.regex(r"^order [0-9]+$")): the text, or a button’s data, contains a match.command(f.command("start", "help")): one of these commands; with no names, any.user(f.user(42, "@anna")): from one of these people.chat(f.chat(7781, "@news")): in one of these chats.callback_data(f.callback_data(prefix="vote:")): a button press with this data.
parse_command reads a command the way the server does.
- class Filter(func, name=None)[source]¶
A condition on an event, which combines with others through operators.
The ready-made filters of this module are
Filterobjects. Plain functions given to a decorator are wrapped for you; wrap one yourself to give it a name, or to use~on it. The operators make new filters:a & bmatches when both match.bis not called whenadoes not match. The values that both hand over go to the handler.a | bmatches when either matches.bis called only whenadoes not match, and the handler gets the values of the one that matched.~amatches whenadoes not, and hands over nothing.
One side of
&and|can be a plain function. A combination with anasyncpart isasyncitself.Example
Private messages from people who are not subscribers yet:
async def is_subscriber(message): return await db.is_subscriber(message.from_user.id) @bot.on_message(f.private & ~f.Filter(is_subscriber)) async def pitch(message): await message.reply("Subscribe to read more.")
Wraps
funcas a filter.- Parameters:
func (FilterFunc) – The condition. It takes the event, or the event and a
FilterContextwhen it has two or more positional parameters (or*args). It returnsTrue,Falseor adictof values for the handler, and may beasync.name (str | None) – The name shown by
repr()and in error messages.Nonetakes the function’s__name__, or"filter"when it has none.
- property is_async: bool[source]¶
Truewhen the function isasync, or for a combination when any part is.A
SyncBotrefuses to start with anasyncfilter (ConfigurationError).
- callback_data(*values, prefix=None)[source]¶
Makes a filter for button presses with this data.
pgpcbot.Router.on_callback_querydoes the same with itsdataandprefixarguments.
- channel = <Filter channel>¶
Matches events in a channel. It works for every kind of update.
- Return type:
Any
- command(*names, ignore_mention=False)[source]¶
Makes a filter for the commands
/nameand/name@yourbot.The command must start the text and be followed by whitespace or nothing (see
parse_command). A command addressed to another bot, such as/start@otherbot, does not match unlessignore_mentionisTrue.pgpcbot.Router.commandregisters its handlers with this filter; use the filter directly to combine it with others.- Parameters:
- Returns:
A filter that matches those commands, and hands over the
ParsedCommandascommand.- Return type:
Example
An answer to every command that no handler before it took:
@bot.on_message(f.command(), private=True) async def unknown(message, command): await message.reply(f"I don't know /{command.name}")
- encrypted = <Filter encrypted>¶
Matches a message that came end-to-end encrypted.
The handler still gets it decrypted. Only messages match.
- Return type:
Any
- group = <Filter group>¶
Matches events in a group. It works for every kind of update.
- Return type:
Any
- has_media = <Filter has_media>¶
Matches a message with attachments, such as pictures, clips or files.
- Return type:
Any
- has_text = <Filter has_text>¶
Matches a message that has text, decrypted when the message came encrypted.
A message with attachments and no text does not match, and neither does a button press.
- Return type:
Any
- mentioned = <Filter mentioned>¶
Matches a message whose text mentions the bot’s
@handle.The rule is the one the server uses in groups: the
@handlecan be anywhere in the text, in any case, but not glued to a letter, a digit or_on either side.- Return type:
Any
- parse_command(text)[source]¶
Reads the command at the start of a text, by the same rule as the server.
A command is
/, then 1 to 32 letters, digits or_, then optionally@and a handle of 5 to 32 of the same characters, then whitespace or the end of the text. Only ASCII letters count, in any case. Nothing may come before the/.- Parameters:
text (str | None) – The text of a message.
Noneand""are fine.- Returns:
The command, or
Nonewhen the text does not start with one.- Return type:
ParsedCommand | None
Example
>>> from pgpcbot.filters import parse_command >>> parse_command("/start@WeatherBot rome") ParsedCommand(name='start', args='rome', mention='WeatherBot', token='/start@WeatherBot') >>> parse_command("/start") ParsedCommand(name='start', args='', mention=None, token='/start') >>> print(parse_command("hello /start")) None
- private = <Filter private>¶
Matches events in a private chat, between the bot and one person.
It works for every kind of update, since each one carries its chat.
private=Trueinpgpcbot.Router.on_messagedoes the same.- Return type:
Any
- regex(pattern, flags=0)[source]¶
Makes a filter that searches the text of a message, or the data of a button press.
The pattern can match anywhere in the text (
re.search()): anchor it with^and$to match the whole text. The match is handed to the handler asmatch.- Parameters:
- Returns:
A filter that matches when the pattern is found, and hands over the
re.Matchasmatch.- Return type:
Example
An order number, read from the match:
@bot.on_message(f.regex(r"^order ([0-9]+)$")) async def order(message, match): await message.reply(f"Order {match[1]}: shipped")
- reply = <Filter reply>¶
Matches a message that answers (quotes) another message.
- Return type:
Any
- signed = <Filter signed>¶
Matches an encrypted message with a valid signature from the sender’s key.
The sender’s key is the one the server lists for them in the chat. With
verify_signatures="off"the bot checks no signature, so nothing matches.- Return type:
Any
- text(*values, ignore_case=False)[source]¶
Makes a filter for messages whose whole text is one of
values.Spaces at both ends of the message are ignored. The values are compared as they are given.
- Parameters:
*values (str) – The texts to take, such as
"yes", "no".ignore_case (bool) –
Truecompares without case (str.casefold()).
- Returns:
A filter that matches a message whose trimmed text equals one of
values.- Return type:
Example
A yes or no answer, in any case:
@bot.on_message(f.text("yes", "no", ignore_case=True)) async def answer(message): ...
- user(*who)[source]¶
Makes a filter for messages and button presses from one of these people.
- Parameters:
*who (int | str) – User IDs (
int) and handles (str, with or without@, in any case).- Returns:
A filter that matches when the sender (
from_user) is one of them. A post that speaks as the channel has no sender, and never matches.- Return type:
Example
A command only the admins can use:
@bot.command("stats", filters=(f.user(*ADMINS),)) async def stats(message): ...
- class FilterContext(bot_username, update, bot)[source]¶
What a filter can look at besides the event.
The bot makes one for each update, and passes it to every filter function that takes two positional parameters.
- bot_username¶
The bot’s handle, without
@.Noneuntil the bot has read its own profile from the server, which it does before the first update.- Type:
str | None
Example
Messages that start with the bot’s handle:
def starts_with_my_name(message, context): handle = f"@{context.bot_username}".lower() return (message.text or "").lower().startswith(handle) @bot.on_message(starts_with_my_name) async def called(message): ...