Skip to main content

Async user state for bots (with Redis)

Project description

botnodes – async user state for bots (with Redis)

A tiny helper for building bots (Telegram, Discord, etc.) that need to track user state between messages — in a flexible and persistent way.

Each user is placed on a Node – a small async class that defines:

  • what happens when a user enters the node
  • how the node handles incoming events (e.g., messages)
  • what the next node should be
  • what data is stored while the user is on the node

Installation

pip install botnodes

Basic Usage

1. Define a context (Ctx) shared across nodes

# nodes_types.py
from dataclasses import dataclass
import aiogram # Assuming using aiogram as the bot library

...

@dataclass
class Ctx:
    bot: aiogram.Bot   # Assuming using aiogram as the bot library
    some_config: str
    food_api: FoodAPI  # some external service
    logger: Logger     # some logger class

2. Define the Event and UserId types of your bot's library, and a function to extract the user ID

# nodes_types.py
from aiogram.types import Message # Assuming using aiogram as the bot library

...

Event = Message
UserId = int  # BigInt

event_to_user_id = lambda event: event.from_user.id

3. Define a starting node

# nodes/Start.py
from pydantic import BaseModel
from botnodes import Node
from ..nodes_types import Ctx, Event, UserId

class StartModel(BaseModel):
    ...

class Start(Node[Ctx, Event, UserId, StartModel]):
    # Should always return the model, that is expected to be stored in Redis for the node
    @classmethod
    def _get_model(cls):
        return StartModel

    # Not required to be saved during node's lifetime
    # (between on_enter and on_event calls)
    # therefore outside StartModel
    _username: str
    _is_first_launch: bool

    def __init__(self, username: str, is_first_launch: bool = False):
        self._data = StartModel()
        self._username = username
        self._is_first_launch = is_first_launch

    async def on_enter(self, user_id, ctx):
        if self._is_first_launch:
            await ctx.bot.send_message(user_id, f"Hello, {self._username}! Are you hungry?") # Your bot library call
        else:
            await ctx.bot.send_message(user_id, f"Again main menu! Are you hungry?") # Your bot library call

    async def on_event(self, user_id, message, ctx):
        # Imports inside the function to avoid circular imports
        from nodes.Hungry import Hungry
        from nodes.Hold import Hold

        if message.text == 'Yes': # Your bot library's event's stucture
            await ctx.bot.send_message(user_id, 'No worries, I\'ve got you back!') # Your bot library call
            return Hungry() # Move to Hungry node
        elif message.text == 'No': # Your bot library's event's stucture
            await ctx.bot.send_message(user_id, 'Write me when you are ready then!') # Your bot library call
            return Hold() # Move to Hold node

        else:
            await ctx.bot.send_message(user_id, 'Say what?') # Your bot library call
            return Start(message.from_user.username) # Unrecognized command — stay in the main menu

4. Define the function that provides the starting node for a user

# main.py
from nodes.Start import Start

...

def get_start_node(message: Event):
    return Start(message.from_user.username or message.from_user.full_name, True)

5. Define other nodes like Hungry

# nodes/Hungry.py
from pydantic import BaseModel
from botnodes import Node
from ..nodes_types import Ctx, Event, UserId

class HungryModel(BaseModel):
    order: list[str] # We will store the active order, while the user continues choosing

class Hungry(Node[Ctx, Event, UserId, HungryModel]):
    # Should always return the model, that is expected to be stored in Redis for the node
    @classmethod
    def _get_model(cls):
        return HungryModel

    def __init__(self, order: list[str] | None = None):
        self._data = HungryModel(order=order if order else [])

    async def on_enter(self, user_id, ctx):
        cart_string = ', '.join(self._data.order) if self._data.order else 'Empty'

        # Your bot library call
        await ctx.bot.send_message(
            user_id,
            f"What are you gonna order? Cart: {cart_string}"
            )

    async def on_event(self, user_id, message, ctx):
        # Imports inside the function to avoid circular imports
        from nodes.Start import Start

        if message.text == 'Finish': # Your bot library's event's stucture
            if (not self._data.order):
                await ctx.bot.send_message(
                  user_id,
                  'Cart is empty! Write Cancel to cancel the order'
                  ) # Your bot library call
                return Hungry([])
            else:
                cart_string = ', '.join(self._data.order)
                await ctx.bot.send_message(user_id, f'Ordering {cart_string}') # Your bot library call
                try:
                    await ctx.food_api.order_food(self._data.order) # Some food api call
                    await ctx.bot.send_message(user_id, f'Ordered!') # Your bot library call
                except Exception as e:
                    ctx.logger.error(f'Order for {user_id} failed due to {e} | {self._data.order=}')
                    await ctx.bot.send_message(user_id, f'Error') # Your bot library call
                return Start(message.from_user.username) # Back to main menu

        if message.text == 'Cancel':
            await ctx.bot.send_message(user_id, f'Order cancelled!') # Your bot library call
            return Start(message.from_user.username) # Back to main menu

        else:
            new_item = message.text # Your bot library's event's stucture
            await ctx.bot.send_message(user_id, f'Added {message.text}') # Your bot library call
            return Hungry(order=[*self._data.order, new_item])

6. Define the Hold node

# nodes/Hold.py
from pydantic import BaseModel
from botnodes import Node
from ..nodes_types import Ctx, Event, UserId

class HoldModel(BaseModel):
    ...

class Hold(Node[Ctx, Event, UserId, HoldModel]):
    def __init__(self):
        self._data = HoldModel()

    # Should always return the model, that is expected to be stored in Redis for the node
    @classmethod
    def _get_model(cls):
        return HoldModel

    async def on_enter(self, *_):
        pass # We are on hold — no action required

    async def on_event(self, user_id, message, ctx):
        from nodes.Start import Start
        return Start(message.from_user.username) # Return to main menu as soon as a new event from the user is recieved

7. Register all nodes using unique keys (not to be changed because app's state saved in Redis relies on them)

# main.py

...

nodes_dict = {
  'start': Start,
  'hungry': Hungry,
  'hold': Hold
}

8. Set up Redis and define a property key to store state

# main.py
from redis.asyncio import Redis

...
redis_property_name = 'nodes_state'

async def main():
    redis = Redis()

9. Initialize the Nodes manager

# main.py
from nodes_types import Ctx

import aiogram # Assuming using aiogram as the bot library

...

async def main():
    # previous code...

    bot = aiogram.Bot(bot_token)
    some_config = 'hello world'
    food_api = ...
    logger = ...

    ctx = Ctx(bot, some_config, food_api, logger)

    nodes = Nodes(
      redis_client=redis,
      redis_property_name="mybot_state",
      nodes_dict=nodes_dict,
      discriminator_field="node_name", # Must not be in any nodes' model to avoid name conflicts
      get_start_node=get_start_node,
      ctx=ctx,
      event_to_user_id=event_to_user_id
    )

10. Route events using your nodes instance

# main.py
from aiogram.types import Message
from aiogram.filters import BaseFilter

...

class IsPrivateChatMessage(BaseFilter):
    async def __call__(self, event: Message) -> bool:
        return event.chat.type == "private"

async def main():
    # previous code...

    dp = aiogram.Dispatcher() # Assuming using aiogram as the bot library

    default_router = ... # Create a router for default things, accesible from any state — if you need

    personal_router = aiogram.Router()
    personal_router.message.filter(IsPrivateChatMessage())
    personal_router.include_router(default_router)

    nodes_router = aiogram.Router()

    nodes_router.message()(nodes.route)
    personal_router.include_router(nodes_router)

    dp.include_router(personal_router)

    await dp.start_polling(bot, allowed_updates=["message"])

✅ That’s it!

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

botnodes-0.1.0.tar.gz (6.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

botnodes-0.1.0-py3-none-any.whl (6.8 kB view details)

Uploaded Python 3

File details

Details for the file botnodes-0.1.0.tar.gz.

File metadata

  • Download URL: botnodes-0.1.0.tar.gz
  • Upload date:
  • Size: 6.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.11.0

File hashes

Hashes for botnodes-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ea1e34d9b85050b5f576d08cd40a938968bad61eee329a020a3c71f578309255
MD5 3969536e26b28440f0744d973001c899
BLAKE2b-256 a802a7f2fc2fa45577c9ffe5a5ce37a4eb086aae7bb40f52b5e608a59b6a8da2

See more details on using hashes here.

File details

Details for the file botnodes-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: botnodes-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 6.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.11.0

File hashes

Hashes for botnodes-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 3d417fe608c8db6fce3329ea8164ff6c4747a984a7b57d102c30e46880cabad2
MD5 e0e9d24b030ee5be626336148a85ada5
BLAKE2b-256 ec4b431d8be2992f4d8c52271c3f4be34fe67679d7c5b794a0fe4e8a0ee88f9b

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page