Skip to content

Architecture

Leonid Kozarin edited this page Apr 20, 2023 · 4 revisions

Architecture

A standard bot architecture with handlers, which are separate implementations for processing incoming messages or requests, is used. Each handler is an implementation of one of the following interfaces:

They all implement the same concept:

  1. by returning true from the CanHandle() method, the handler indicates that the message is intended for processing by the handler;
  2. the processing itself is implemented in the Handle() method.

The main goroutine either run a message receiving loop (development/debug mode, convenient for use on a local machine), or start a server that accepts requests (web hooks) from Telegram servers (production mode). For each request, a new goroutine is created, which determines a handler, responsible for the given message, and calls its Handle() method.

However, unlike bots implemented in interpreted languages like Python, here we need to specify a list of handlers explicitly, which is fixed at compile time and cannot be changed later without rebuilding the application.

Telemetry

Metrics for handlers are also registered and incremented automatically. They're exposed as an HTTP endpoint in a format suitable for scrape by Prometheus. More details can be found in a separate document.

Forms and wizards

To execute most commands, the bot needs to get a bunch of parameters from the user. This is often impossible to do in one message. Therefore, there is a need for forms - a data structure consisting of named fields of different types and preserving its state in Redis between command calls. Remembering the good old installation wizards for Windows applications, I've called forms as wizards. They're implemented as a separate wizard module.

Most message handlers that process messages implement the WizardMessageHandler interface, which extends the MessageHandler so the handler can provide the following information to the caller:

  • the name of the form under which it will be saved in Redis;
  • a wrapped connection to Redis;
  • a descriptor.

Descriptors

The descriptor is a description of form fields that is not stored in Redis but must be restored along with the state for processing of subsequent messages. It consists of descriptors of individual fields that allow you to specify:

  • a label,
  • validators,
  • optionality,
  • keyboard buttons.

The descriptor also contains a link to a function - the final action that will be executed when all required fields are filled. The function must match the signature of the FormAction type. Throughout the code, such functions are usually named according to the scheme <handler/form name without suffix>Action, which can cause confusion: searchModeAction is not a search function at all. 🙂

At application startup, descriptors of all implementations of WizardMessageHandler are registered because otherwise they would only be taken into consideration when the corresponding command is first executed. However, in this case, when the bot was restarted, the user may encounter a state where he/she continues to fill out the form, but its description is no longer available in memory! For this situation, the text message under the wizard.errors.state.missing key is provided, which, however, should never be displayed in the current implementation.

Localization

A simple approach is used for localization. The go-l10n library provides a simple two-level KV-storage: first, you get a map by language code, from which you get the required text using the key. What you need to do for localization is to initialize the map in a regular Go file.

The user's language is determined from the Users table in the database - the "language" parameter, which is set either when the bot is started, if the user clicks on a language button (which, however, can be ignored), or using the /language command. However, if it was not set, then the language of the user's system obtained from Telegram is taken.

Repositories

(abstraction layer over the database connection)

The package repo consists of simple services providing facilities to manipulate data in the database.

Base API

This is a more specific and boring part, but let me explain a little about the features of the base module types.

BotAPI

There is a small wrapper over the library struct that is primarily needed for testing (DummyMode disables sending of real requests), but also provides more convenient functions Reply*().

RequestEnv

A more interesting struct is a container containing information about the environment, which is passed through the entire request. It has references to user settings and a map of strings in the user's language that has been already determined at this point.

ApplicationEnv

Another container that has references to global scoped resources like the BotAPI, a connection to the database, the context object of the application.

Context and graceful shutdown

The application supports graceful shutdown, accepts and responds to signals of termination. A common application context is passed almost everywhere so the parts of the system can react appropriately to the signal. However, there is a "special behavior" in development/debug mode: the reaction of the library used to interact with the Telegram API on stopReceiveUpdates() is pretty weird. As a result, exiting the loop is delayed in the timeout of the request. However, for development, you may kill the application with SIGKILL. This is OK. The production environment on the server works on WebHooks. So, this is not a problem either.