Files
Telegrator/GETTING_STARTED.md
T

20 KiB

Getting Started with Telegrator


This guide will walk you through the core concepts and features of Telegrator.


1. Introduction

Welcome to Telegrator — a modern, aspect-oriented, mediator-based framework for building powerful and maintainable Telegram bots in C#.

  • Why Telegrator?
    • Decentralized logic: no more monolithic state machines.
    • Flexible filtering and handler composition.
    • Advanced state management and concurrency control.
    • Inspired by AOP, but tailored for practical bot development.

2. Key Concepts

At the core of Telegrator are a few fundamental concepts that enable its power and flexibility. Understanding them is key to building robust and scalable bots.

Mediator: The Central Dispatcher

The framework is built around the Mediator pattern. Every incoming update from Telegram (like a message, a button click, or a user joining a chat) is first received by a central UpdateRouter. This router acts as a mediator, responsible for dispatching the update to the appropriate handlers. This decouples the update receiving logic from the processing logic, making the system clean and maintainable.

Handlers: The Logic Processors

A Handler is a class that contains the logic for processing a specific type of update. For example, you might have a WelcomeHandler for new chat members, a CommandHandler for slash commands, or a CallbackQueryHandler for button clicks. Each handler is a small, focused, and reusable component.

  • MessageHandler: For processing text messages.
  • CallbackQueryHandler: For handling button clicks from InlineKeyboardMarkup.
  • AnyUpdateHandler: A catch-all handler for any type of update.

Filters: The Gatekeepers

Filters are attributes that you apply to your handler classes to specify when a handler should be executed. They act as gatekeepers, ensuring that a handler only runs if the incoming update meets certain criteria.

  • [Command("/start")]: Triggers the handler only for the /start command.
  • [MessageText(Contains = "hello")]: Triggers when a message contains the word "hello".
  • [MessageChat(Is = ChatType.Private)]: Triggers only for messages from private chats.
  • [RepliedMessage]: Triggers only when the message is a reply to another message.

Filters can be combined with logical operators (OrNext, Not) to create complex and precise routing rules without writing messy if/else statements.

State Management: The Memory

State Management in Telegrator is a powerful feature for creating multi-step conversations (wizards, forms, quizzes) without a database. It works by using special filter attributes that make handlers execute only when a user or chat is in a specific state.

The core idea is that you don't interact with a "StateKeeper" object directly. Instead:

  1. You define a state machine using a C# enum or simple int values.
  2. You use [EnumState(YourEnum.SomeState)] or [NumericState(1)] attributes to filter which handler should run at which step.
  3. Inside a handler, you use extension methods on the IAbstractHandlerContainer<T> (which I'll call container for simplicity) to change the user's state, e.g., container.ForwardEnumState<YourEnum>().

Example: A Multi-Step Quiz

Let's build a simple two-question quiz.

1. Define the Quiz States:

// Enums/QuizState.cs
public enum QuizState
{
    // We use the SpecialState enum to represent the "no state" condition.
    // This is the default state for all users.
    Start = SpecialState.NoState,
    ExpectingAnswer1,
    ExpectingAnswer2
}

2. Build the Handlers:

The /quiz command will start the process. It will only trigger for users who are not currently in a quiz (QuizState.Start).

// Handlers/QuizHandler.cs
using Telegrator.Annotations.StateKeeping;

[MessageHandler]
[CommandAllias("quiz")]
// This handler only runs if the user's state for QuizState is the default (NoState).
[EnumState<QuizState>(QuizState.Start)]
public class StartQuizHandler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        // Create the state for the user and move it to the first question.
        container.ForwardNumericState<QuizState>(); // If state isnt created, creates default state and formards its value. QuizState.Start -> QuizState.ExpectingAnswer1
        await Reply("Quiz started! Question 1: What is the capital of France?");
    }
}

Now, create handlers for each expected answer. They will only trigger if the user is in the correct state.

// Handlers/QuizAnswerHandlers.cs

[MessageHandler]
// This handler only runs if the user's state is ExpectingAnswer1.
[EnumState<QuizState>(QuizState.ExpectingAnswer1)]
public class Answer1Handler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        if (Input.Text.Trim().Equals("Paris", StringComparison.InvariantCultureIgnoreCase))
        {
            await Reply("Correct!");
        }
        else
        {
            await Reply("Incorrect. The answer is Paris.");
        }

        // Move to the next state.
        container.ForwardEnumState<QuizState>(); // Moves state to ExpectingAnswer2
        await Reply("Question 2: What is 2 + 2?");
    }
}

[MessageHandler]
// This handler only runs if the user's state is ExpectingAnswer2.
[EnumState<QuizState>(QuizState.ExpectingAnswer2)]
public class Answer2Handler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        if (Input.Text.Trim() == "4")
        {
            await Reply("Correct!");
        }
        else
        {
            await Reply("Incorrect. The answer is 4.");
        }

        // The quiz is over, so we delete the user's state.
        container.DeleteEnumState<QuizState>();
        await Reply("Quiz finished!");
    }
}

How it Works:

  • [EnumState(QuizState.Start)]: This is a filter. It checks the user's current state for the QuizState enum. SpecialState.NoState is a conventional way to say "the user has no state set yet".
  • container.CreateEnumState<QuizState>(): This initializes the state for the current user (or chat, depending on the key resolver) and sets it to the first actual value of the enum (Start).
  • container.ForwardEnumState<QuizState>(): This moves the user's state to the next value in the enum sequence.
  • container.DeleteEnumState<QuizState>(): This removes the state for the user, effectively resetting them to SpecialState.NoState.

This declarative, attribute-based approach keeps your handler logic clean and focused on a single task, while the framework manages the complexity of the state machine.


3. Installation

Telegrator is distributed as a NuGet package. You can install it using the .NET CLI, the NuGet Package Manager Console, or by managing NuGet packages in Visual Studio.

Prerequisites

  • .NET 6.0 SDK or later.
  • A Telegram Bot Token from @BotFather.

.NET CLI

dotnet add package Telegrator

Package Manager Console

Install-Package Telegrator

The framework also has integrations for different hosting models, which can be installed separately:

  • Telegrator.Hosting: For console applications and background services.
  • Telegrator.Hosting.Web: For ASP.NET Core applications and Webhook support.
# For console apps
dotnet add package Telegrator.Hosting

# For ASP.NET Core apps
dotnet add package Telegrator.Hosting.Web

4. Your First Bot: A "Hello, World!" Example

Let's create a simple bot that replies with "Hello, {FirstName}!" when a user sends the /start command.

1. Create the Handler

First, create a new class that inherits from MessageHandler. This class will contain the logic for handling the message.

// Handlers/StartHandler.cs
using System.Threading;
using System.Threading.Tasks;
using Telegram.Bot.Types;
using Telegrator.Handlers;
using Telegrator.Annotations;

namespace Handlers;

// This attribute registers the class as a message handler.
[MessageHandler]
// This filter ensures that the message's text equals to "Hello".
[TextEquals("Hello")]
public class StartHandler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        // Get the user's first name from the incoming message.
        var firstName = Input.From?.FirstName ?? "User";
        
        // Reply to the user.
        await Reply($"Hello, {firstName}!", cancellationToken: cancellation);
    }
}

2. Set Up and Run the Bot

Next, in your application's entry point (e.g., Program.cs), create an instance of ReactiveClient, add your handler, and start listening for updates.

// Program.cs
using System;
using System.Threading;
using Telegrator;
using Handlers; // Assuming your handler is in the "Handlers" namespace

class Program
{
    static void Main(string[] args)
    {
        // Replace "<YOUR_BOT_TOKEN>" with your actual bot token.
        var bot = new ReactiveClient("<YOUR_BOT_TOKEN>");

        // Automatically discover and add all **public** handlers from the current assembly.
        bot.Handlers.CollectHandlersDomainWide();
        
        // Or, add a specific handler manually:
        // bot.Handlers.AddHandler<StartHandler>();

        // Start receiving updates from Telegram.
        bot.StartReceiving();

        Console.WriteLine("Bot started. Press any key to exit.");
        Console.ReadKey();
    }
}

What's Happening?

  1. [MessageHandler]: This attribute marks StartHandler as a handler for Message updates.
  2. [TextEquals("Hello")]: This is a Filter. It tells the UpdateRouter to only execute this handler if the message text is equals Hello.
  3. ReactiveClient: This is the main bot client. It manages the connection to Telegram and the update processing pipeline.
  4. bot.Handlers.CollectHandlersDomainWide(): This convenient extension method scans your project for all classes marked with handler attributes (like [MessageHandler]) and registers them automatically.
  5. bot.StartReceiving(): This method starts the long-polling loop to fetch updates from Telegram and passes them to the UpdateRouter.
  6. Reply(...): This is a helper method from the base MessageHandler class that simplifies sending a reply to the original message.

5. Step-by-Step Tutorial

This tutorial will guide you through building a bot that demonstrates common features like command handling, filtering, and waiting for user input.

5.1 The Problem: An Overly Eager Echo Bot

Let's start with two simple handlers: one for the /start command and one to echo messages.

// StartHandler.cs
[CommandHandler]
[CommandAllias("start")]
public class StartHandler : CommandHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        await Reply("Welcome! Send me any message and I will echo it back.");
    }
}

// EchoHandler.cs
[MessageHandler]
public class EchoHandler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        await Reply($"You said: {Input.Text}");
    }
}

If you run this bot and send /start, you'll get two replies!

  1. "Welcome! Send me any message and I will echo it back." (from StartHandler)
  2. "You said: /start" (from EchoHandler)

This happens because EchoHandler has no filters, so it triggers for every message, including the /start command.

5.2 The Traditional Fix: if Statements

The classic way to solve this is to add a check inside the EchoHandler.

// EchoHandler.cs (with an if-statement)
[MessageHandler]
public class EchoHandler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        // Manually ignore commands.
        if (Input.Text.StartsWith("/"))
        {
            return;
        }

        await Reply($"You said: {Input.Text}");
    }
}

This works, but it clutters our handler with boilerplate logic. As you add more commands and conditions, this approach becomes messy.

5.3 The Reactive Fix: Declarative Filters

Telegrator lets you solve this cleanly using filter attributes. We can tell the EchoHandler to only trigger if the message is not a command.

// EchoHandler.cs (the reactive way)
using Telegrator.Attributes; // For FilterModifier

[MessageHandler]
// This filter ensures the handler only runs for messages that are NOT the command.
[TextStartsWith("/", Modifiers = FilterModifier.Not)]
public class EchoHandler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        await Reply($"You said: {Input.Text}");
    }
}

Now, the EchoHandler will correctly ignore the any command without any if statements in the handler body. The routing logic is declared right where it belongs: on the class itself.

5.4 Waiting for Input with AwaitingProvider

What if you want to ask a question and wait for the user's next message? You could use the state management system, but for a simple one-off question, that's overkill. The AwaitingProvider is perfect for this.

Let's create a /question command that asks for the user's name and then greets them with the name they provide.

// QuestionHandler.cs
[CommandHandler]
[CommandAllias("question")]
public class QuestionHandler : CommandHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        await Reply("What is your name?");

        // Await the user's next message.
        // We apply a filter to ensure we only catch messages from the same user in the same chat.
        var nextMessage = await Container.AwaitMessage().BySenderId(cancellation);
        await Client.SendMessage(Input.Chat, $"Hello, {nextMessage.Text}!");
    }
}

How it works:

  1. The handler first sends the question "What is your name?".
  2. AwaitMessage() temporarily registers an internal handler that waits for the next Message sended by this user.

This pattern is extremely powerful for creating dynamic, interactive conversations without the complexity of a full state machine.


6. Advanced Topics

This section covers more advanced features of the framework.

Handler Concurrency and Priority

By default, handlers are executed in the order they are added. However, you can control the execution order using the Priority property in the handler attribute. A Greater number means higher priority.

[MessageHandler(Priority = 1)] // This will run before handlers with the default priority (0)
public class HighPriorityHandler : MessageHandler
{
    // ...
}

Creating Custom Filters

You can create your own filters by inheriting from UpdateFilterAttribute<T>. This is useful for encapsulating complex or reusable filtering logic.

Let's create a filter that only allows messages from a specific user ID.

// Filters/AdminOnlyFilter.cs
using Telegram.Bot.Types;
using Telegrator.Attributes;

public class AdminOnlyAttribute : UpdateFilterAttribute<Message>
{
    private readonly long _adminId;

    public AdminOnlyAttribute(long adminId)
    {
        _adminId = adminId;
    }

    public override Message? GetFilterringTarget(Update update) => update.Message;

    public override bool CanPass(FilterExecutionContext<Message> context)
    {
        return context.Input.From?.Id == _adminId;
    }
}

// Usage in a handler:
[MessageHandler]
[AdminOnly(123456789)] // Replace with your actual admin ID
public class AdminCommandHandler : MessageHandler
{
    // ...
}

Integration with Dependency Injection

Telegrator is designed to work seamlessly with dependency injection (DI) containers like the one built into ASP.NET Core.

When using the Telegrator.Hosting or Telegrator.Hosting.Web packages, handlers and their dependencies are automatically registered with the DI container. This means you can inject any registered service directly into your handler's constructor.

[MessageHandler]
public class MyHandler : MessageHandler
{
    private readonly IMyService _myService;
    private readonly ILogger<MyHandler> _logger;

    // Dependencies are injected automatically.
    public MyHandler(IMyService myService, ILogger<MyHandler> logger)
    {
        _myService = myService;
        _logger = logger;
    }

    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        _logger.LogInformation("MyHandler executed!");
        var result = _myService.DoSomething();
        await Reply(result);
    }
}

Concurrency Control

You can limit the number of concurrent executions for a specific handler by setting the concurrency parameter in the handler attribute. This is useful for preventing race conditions or for managing resource-intensive operations.

// This handler will only allow one execution at a time for a given chat.
[MessageHandler(concurrency: 1)]
public class SlowHandler : MessageHandler
{
    public override async Task Execute(IAbstractHandlerContainer<Message> container, CancellationToken cancellation)
    {
        await Task.Delay(5000, cancellation); // Simulate a long-running operation
        await Reply("Done!");
    }
}

7. FAQ / Troubleshooting

Q: My handler is not being triggered. What should I do?

  • Check Handler Registration: Ensure you are calling bot.Handlers.AddHandlers() or bot.Handlers.AddHandler<MyHandler>(). If you are using DI, make sure the assembly containing your handlers is being scanned.
  • Check Filters: Double-check your filter attributes. A common mistake is a typo in a command or text filter. Remember that filters are combined with a logical AND by default. If you have multiple filters, the update must pass all of them.
  • Check Update Type: Make sure your handler is for the correct update type. A MessageHandler will not be triggered by a CallbackQuery update.
  • Enable Debug Logging: You can subscribe to the UpdateRouter.OnUpdate and UpdateRouter.OnHandlerEnter events to see how updates are being processed in real-time.

Q: How can I access the ITelegramBotClient or the original Update object inside a handler?

The base AbstractUpdateHandler<T> class provides access to these:

  • Client: The ITelegramBotClient instance.
  • Update: The raw Update object.
  • Input: The specific update payload (e.g., Message, CallbackQuery).

Q: How do I handle errors?

You can subscribe to the UpdateRouter.OnError event to receive notifications about exceptions that occur during update processing. This is a good place to log errors or send notifications to an administrator.

bot.UpdateRouter.OnError += (sender, args) =>
{
    Console.WriteLine($"An error occurred: {args.Exception.Message}");
    return Task.CompletedTask;
};

Q: How can I organize my code for a large bot?

  • Folders: Organize your handlers, filters, and state keepers into separate folders (e.g., Handlers/Commands, Handlers/Callbacks, StateKeepers).
  • Feature Modules: For very large bots, consider structuring your code into "feature modules", where each module is a separate class library containing all the related handlers, filters, and services for a specific feature.


Feel free to contribute, ask questions, or open issues!