Nodes
- Chapter
- 5 / 12
- Time
- 4 min
- Code
- 8
If you want the short version, nodes in telegrinder are a way to stop repeating the same preparation logic in every handler.
Instead of thinking like this:
- "first get text from the message"
- "then try to convert it to an integer"
- "then get the user"
you can think like this:
- "I need
Texthere" - "I need
TextIntegerhere" - "I need
UserSourcehere"
and telegrinder figures out how to build those values.
First intuition#
A node is just a description of how one value can be obtained from other values.
For example:
- from
Messagewe can getText - from
Textwe can getint - from
CallbackQuerywe can getPayload - from
Sourcewe can get a user or a chat
A good beginner rule of thumb:
if you keep repeating the same data preparation code in handlers, that is probably a candidate for a node.
What telegrinder already gives you#
During update processing these root objects are already available:
APIUpdateContext
Everything else can be composed from there.
There are already many built-in nodes:
TextTextIntegerSourceUserSourceChatSourceChatIdPayloadFilePhotoCaptionError
So on day one you often do not need to write your own nodes at all.
Your first custom node#
A node is defined as a class with a __compose__ method.
from nodnod import NodeError
from telegrinder import Message
from telegrinder.node import scalar_node
@scalar_node
class Text:
@classmethod
def __compose__(cls, message: Message) -> str:
# If the message has no text, this node cannot be composed.
if not message.text:
raise NodeError("Message has no text.")
# unwrap() is safe here because we already checked the condition above.
return message.text.unwrap()What is happening here:
@scalar_nodetells telegrinder this is a scalar nodemessage: Messagemeans the node needs aMessage-> strmeans the final result is a stringNodeErrormeans "this node cannot be built right now"
Then you use it as a normal handler argument:
@bot.on.message()
async def text_message_handler(message: Message, text: Text) -> None:
# The handler receives ready-to-use text.
await message.answer(text.lower())That is usually the moment when nodes start feeling nice: the handler becomes about what to do, not how to obtain the inputs.
Nodes also work in rules#
This is one of the nicest parts.
from telegrinder.bot.rules import ABCRule
class TextIsOfLength(ABCRule):
def __init__(self, length: int) -> None:
self.length = length
async def check(self, text: Text) -> bool:
# The rule receives a ready Text node.
return len(text) == self.length
@bot.on.message(TextIsOfLength(6))
async def six_handler() -> str:
return "I like messages of that length."So nodes are not only useful in handlers. They are also great for keeping custom rules clean.
Node chains#
This is where things start feeling powerful.
from nodnod import NodeError
from telegrinder.node import scalar_node
@scalar_node
class TextInteger:
@classmethod
def __compose__(cls, text: Text) -> int:
# This node depends on an already composed Text node.
if not text.isdigit():
raise NodeError("Text is not a digit.")
return int(text)Now your handler can simply ask for the final value:
@bot.on.message()
async def number_handler(message: Message, value: TextInteger) -> None:
await message.answer(f"{value} + 3 = {value + 3}")Internally the chain is:
Message -> Text -> TextInteger
That is the real benefit of nodes.
The handler does not care how the integer appeared. It just works with an integer.
A slightly more practical example#
Suppose you often need the incoming message id.
from nodnod.interface.scalar import scalar_node
from telegrinder import Message
MessageId = type("MessageId", (int,), {})
@scalar_node
class IncomingMessageId:
@classmethod
def __compose__(cls, message: Message) -> MessageId:
# Tiny wrapper types can make code more expressive.
return MessageId(message.message_id)
@bot.on.message()
async def show_id(message: Message, message_id: IncomingMessageId) -> None:
await message.answer(f"Your message id is {message_id}")At first this can look a bit abstract, but on larger bots these tiny nodes are very good at removing duplication.
Common node shapes#
In practice you will mostly see:
scalar_nodefor one typed valueDataNodefor dataclass-style containersgeneric_nodefor generic nodespolymorphicfor nodes that can be built from multiple event types
For example, telegrinder's Payload node is polymorphic. It can extract payload not only from CallbackQuery, but from other relevant events too.
For most beginner use cases, scalar_node is enough.
Lifetimes#
Sometimes the important question is not only "how do I build this node?" but also "how long should it live?"
Telegrinder has three scopes:
PER_EVENTfor one update, this is the defaultPER_CALLfor a fresh value every timeGLOBALfor one value for the whole application
PER_EVENT#
This is the default.
If the same node is needed twice during one update, telegrinder does not rebuild it from scratch.
That is usually what you want.
PER_CALL#
Useful when you want a fresh object every time.
import aiosqlite
import typing
from telegrinder.node import per_call, scalar_node
@per_call
@scalar_node
class DB:
@classmethod
async def __compose__(cls) -> typing.AsyncGenerator[aiosqlite.Connection, None]:
# Create the resource before use.
connection = await aiosqlite.connect("test.db")
# Yield it to the handler or another node.
yield connection
# Cleanup happens after processing is done.
await connection.close()This also shows another very nice feature: a node may be a generator.
That means a node can:
- prepare a resource
- yield it for processing
- clean it up afterwards
GLOBAL#
Global nodes are good for configuration, clients, and values that almost never change.
from telegrinder.node import DataNode, global_node, scalar_node
@global_node
class Settings(DataNode):
api_url: str
secret: str
@classmethod
def __compose__(cls) -> "Settings":
# No need to rebuild this on every update.
return cls(
api_url=env["API_URL"],
secret=env["APP_SECRET"],
)
@global_node
@scalar_node
class Secret:
@classmethod
def __compose__(cls) -> str:
return generate_secret(16)When you do not need a node#
This matters too.
You probably do not need a node if:
- the logic is used only once
- the code is already short and obvious
- you are hiding too much simple logic behind "magic wrappers"
The healthy balance is important. Nodes are there to simplify code, not to make it mysterious.
What to remember#
- a node is a way to describe how one value is obtained from another
- the main method in the current API is
__compose__ - nodes work in both handlers and rules
- the default lifetime is per event
- if you repeat the same preparation code often, try turning it into a node