Advanced telegram bot in Java (with free deployment)

It’s 2023. On Habré they still write articles on creating telegram bots, you say, but don’t rush to conclusions. Firstly, let’s feel sorry for the students; in many universities, programming majors are still forced to write bots at least once during their studies. And many guides on the Internet are a little outdated in terms of deployment (Heroku died, live on… but more on that a little later). Those. The main focus of this article will be this aspect. Well, the bot itself will also not be boring and not the simplest one, which is often in guides, but with a full link to the database, i.e. a full-fledged Spring application.

Well, what is the bot about and how did I come up with this idea? Having walked around 2 cities far and wide, I noticed one unpleasant trend: many restrooms are now on pin codes. And the codes themselves are available by receipt. And sometimes it happens that you really need it, but you don’t want to buy anything, it’s also not convenient to ask just like that, including going to look for receipts. What to do? It is for this reason that I decided to create a bot that will store and update pin codes for such establishments. After all, what is the purpose of such codes in general – to protect the toilet from potential pests. But I think those who are able to use a telegram and find a bot are not like that, so I hope such a bot does not violate any laws, after all, the goal is simply to help people.

So, the idea is clear, where should we start? Let’s start, as always, with Spring initializr. We choose maven, you can use any spring version, but I left the default one, enter the normal group and artifact id, name and description (if you want to upload this somewhere in the public domain, read my article on designing pet projects), select jar packaging (we will still need this), java if desired, I chose 17, and add 3 dependencies: Spring Data Jpa (we will use the database), Lombok (useful) and Spring Web (without it the application would not run on the Tomcat server) , and liquibase (migrations will come in handy). It would also be possible to add a driver to our database right here, namely postgres, but suddenly you want to use something else, so let’s leave it like that for now and add it manually to the pom (no, this is of course not because I already took a screenshot and I was too lazy to redo it).

Next, as I said, we’ll add a driver to our pom file and a dependency on the library we need to work with the telegram API:

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.telegram</groupId>
            <artifactId>telegrambots</artifactId>
            <version>6.8.0</version>
        </dependency>

For ease of development, we’ll immediately create a compose file with our database:

services:
  postgres:
    image: 'postgres:14-alpine'
    container_name: postgres
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
    volumes:
      - ./imports/init.sql:/docker-entrypoint-initdb.d/init.sql

Here, in the imports/init.sql file, we immediately initiate the schema we need, so as not to do it manually before each launch (since in liquibase for some reason creating a schema does not work and it is not possible to simply put it into migration): CREATE SCHEMA IF NOT EXISTS pins. Next, set up the application.yml file (I recommend switching to yaml with properties):

spring:
  datasource:
    url: ${POSTGRES_JDBC_URL:jdbc:postgresql://localhost:5432/postgres}
    username: ${POSTGRES_USERNAME:postgres}
    password: ${POSTGRES_PASSWORD:postgres}
    driver-class-name: org.postgresql.Driver
    hikari:
      maximum-pool-size: 10
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate.default_schema: ${POSTGRES_SCHEMA:pins}
  liquibase:
    default-schema: ${POSTGRES_SCHEMA:pins}
    change-log: classpath:/${POSTGRES_SCHEMA:pins}/master.yml

bot:
  name: "PinCityBot"
  token: {$BOT_TOKEN:}

In general, there are a lot of system settings that are not interesting to us, which I omitted (I will leave a link to the git at the end of the tutorial), I will tell you in more detail about the important points: what is in the form ${name:value}– these are environment variables, and they will be useful to us, because… for deployment we will change them, and the default value is indicated after the colon, namely to our docker postgres, for easy local launch. Hikari is a connection pool to a database. The liquibase settings are made in such a way that migrations will be located in the resources folder, in the folder with the name of our scheme. Settings prefixed with bot are settings for telegram, note that the env variable is also used here. ddl-auto: validate indicates that hibernate will validate the database schema against our entities and throw an error if something matches.

So, accordingly, next we will need a database schema, so we’ll design it right away. The approximate plan is such that we will have a list of supported cities (to control and leave only relevant ones and those where you can check that, for example, codes are not added to entrances, etc., but only actual establishments). Secondly, it is necessary to save which city the user chose, for logic. Thirdly, you need a list of places associated with cities, which will store the address, type of establishment, its short name, pin code and by whom it was last updated (an important field to track those who will always update to the wrong ones) ). And to save the status of the correspondence you will also need additional. a table with the status of correspondence (but more on that later). In total, we get something like this:

We do not pay attention to _databasechangelog, this is a liquibase service table

We do not pay attention to _databasechangelog, this is a liquibase service table

We are writing migrations for this whole matter. I won’t give it in full, here’s just an example of one migration, they are quite simple:

<changeSet id="0.0.1-1" author="kat">
        <createTable tableName="cities">
            <column name="id" type="int">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="name" type="varchar">
                <constraints nullable="false"/>
            </column>
        </createTable>
</changeSet>

We write the main class. We will implement LongPolling bot communication scheme. Because webhooks are inconvenient to test locally, and we don’t need them with such a communication scheme (and with deployment it would most likely be even more of a hemorrhoid).

@Component
@RequiredArgsConstructor
@Slf4j
public class TelegramBot extends TelegramLongPollingBot {

    public final BotProperties botProperties;

    public final CommandsHandler commandsHandler;

    public final CallbacksHandler callbacksHandler;

    @Override
    public String getBotUsername() {
        return botProperties.getName();
    }

    @Override
    public String getBotToken() {
        return botProperties.getToken();
    }

    @Override
    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            String chatId = update.getMessage().getChatId().toString();
            if (update.getMessage().getText().startsWith("/")) {
                sendMessage(commandsHandler.handleCommands(update));
            } else {
                sendMessage(new SendMessage(chatId, Consts.CANT_UNDERSTAND));
            }
        } else if (update.hasCallbackQuery()) {
            sendMessage(callbacksHandler.handleCallbacks(update));
        }
    }

    private void sendMessage(SendMessage sendMessage) {
        try {
            execute(sendMessage);
        } catch (TelegramApiException e) {
            log.error(e.getMessage());
        }
    }
}

As you can see, the whole scheme of work is logically divided into 2 cases: when the user sends us a command, and when the message comes to us as a callback. The second case will occur when the user presses on the keyboards that we will send to him in response. BotProperties is what stores the token and name of the bot, this is done simply:

@Component
@ConfigurationProperties(prefix = "bot") // тот самый префикс
@Data // lombok
@PropertySource("classpath:application.yml") // наш yaml файлик
public class BotProperties {

    String name;

    String token;

}

CommandsHandler looks like this. In it we register a list of command handlers. At first I stored them in a function, but then I realized that it was easier to create an interface and all handlers inherit from it. As a result, we get something like this:

@Component
@Slf4j
public class CommandsHandler {

    private final Map<String, Command> commands;

    public CommandsHandler(@Autowired StartCommand startCommand,
                           @Autowired PinCommand pinCommand) {
        this.commands = Map.of(
                "/start", startCommand,
                "/pin", pinCommand
        );
    }

    public SendMessage handleCommands(Update update) {
        String messageText = update.getMessage().getText();
        String command = messageText.split(" ")[0];
        long chatId = update.getMessage().getChatId();

        var commandHandler = commands.get(command);
        if (commandHandler != null) {
            return commandHandler.apply(update);
        } else {
            return new SendMessage(String.valueOf(chatId), Consts.UNKNOWN_COMMAND);
        }
    }

}

I won’t talk in detail about some auxiliary things like the constant class, the BotInit class that registers our bot, the dtoshki package, the implementation of entity and repositories (especially since some use “smart” JPA repositories, others do them manually, like me), because there is plenty of this in other guides. I will focus on the implementation of our start command:

@RequiredArgsConstructor
@Component
public class StartCommand implements Command {

    private final CityRepository repository;

    @Override
    public SendMessage apply(Update update) {
        long chatId = update.getMessage().getChatId();
        SendMessage sendMessage = new SendMessage();
        sendMessage.setChatId(String.valueOf(chatId));
        sendMessage.setText(Consts.START_MESSAGE);

        List<CityEntity> allCities = repository.findAll();

        addKeyboard(sendMessage, allCities);
        return sendMessage;
    }

    private void addKeyboard(SendMessage sendMessage, List<CityEntity> allCities) {
        InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();
        List<InlineKeyboardButton> keyboardButtonsRow = new ArrayList<>();
        for (var city : allCities) {
            InlineKeyboardButton inlineKeyboardButton = new InlineKeyboardButton();
            inlineKeyboardButton.setText(city.getName());
            String jsonCallback = JsonHandler.toJson(List.of(CallbackType.CITY_CHOOSE, city.getId().toString()));
            inlineKeyboardButton.setCallbackData(jsonCallback);
            keyboardButtonsRow.add(inlineKeyboardButton);
        }
        List<List<InlineKeyboardButton>> rowList = new ArrayList<>();
        rowList.add(keyboardButtonsRow);
        inlineKeyboardMarkup.setKeyboard(rowList);
        sendMessage.setReplyMarkup(inlineKeyboardMarkup);
    }

}

As you can see, everything is quite simple, here we look for a list of cities from our database and add them to the user’s keyboard, sending a callback with the type along with each key CITY_CHOOSE and send the id of the selected city as data. Why leaf? Because, unfortunately, the size of the callback in a telegram is limited to 64 bytes, so we don’t need extra fields. Next, we will analyze the callback handler, in which the main logic will take place. Generally speaking, if I initially stored the chat state in the database, then I could get by with regular messages, but since… I initially developed a scheme with callbacks, and only then, when callbacks were no longer enough, I started saving the state in the database, so I had to leave this mixed scheme. But I think in the future, as the bot’s logic becomes more complex, it will be possible to switch to a scheme with states in the database. I’ll tell you about the points that the bot can work on at the end. The callback handler looks like this:

@Component
public class CallbacksHandler {

    private final Map<CallbackType, CallbackHandler> callbacks;

    public CallbacksHandler(@Autowired TypeChooseCallback typeChooseCallback,
                            @Autowired CityChooseCallback cityChooseCallback,
                            @Autowired AddressChooseCallback addressChooseCallback,
                            @Autowired PinReviewCallback pinReviewCallback,
                            @Autowired PinActionCallback pinActionCallback) {
        this.callbacks = Map.of(CallbackType.TYPE_CHOOSE, typeChooseCallback,
                CallbackType.CITY_CHOOSE, cityChooseCallback,
                CallbackType.ADDRESS_CHOOSE, addressChooseCallback,
                CallbackType.PIN_OK, pinReviewCallback,
                CallbackType.PIN_WRONG, pinReviewCallback,
                CallbackType.PIN_ADD, pinActionCallback,
                CallbackType.PIN_DONT_ADD, pinActionCallback
        );
    }

    public SendMessage handleCallbacks(Update update) {
        List<String> list = JsonHandler.toList(update.getCallbackQuery().getData());
        long chatId = update.getCallbackQuery().getMessage().getChatId();

        SendMessage answer;
        if (list.isEmpty()) {
            answer = new SendMessage(String.valueOf(chatId), Consts.ERROR);
        } else {
            Callback callback = Callback.builder().callbackType(CallbackType.valueOf(list.get(0))).data(list.get(1)).build();
            CallbackHandler callbackBiFunction = callbacks.get(callback.getCallbackType());
            answer = callbackBiFunction.apply(callback, update);
        }

        return answer;
    }

}

As you can see, the processing logic itself is similar, there is a map in which a handler is stored as a callback, and, as you can see, this time there are much more handlers. I think I won’t describe everything in detail, I’ll give a screenshot of the bot’s operation and then the scheme of its operation will immediately become clear. In short, first the city is selected, then the type of establishment, then a specific establishment, then according to the following logic:

It is also possible that a PIN code is issued that someone else has marked as irrelevant, and the person can mark it as current or irrelevant, and then provide their own (or not provide it). In general, everything didn’t fit into the screenshot, but you get the idea. I will give the simplest example of a specific callback handler, because and so the article turns out to be long, and we need it later in the article so as not to provide the code twice:

@RequiredArgsConstructor
@Component
public class PinActionCallback implements CallbackHandler {

    private final PlacesRepository placesRepository;

    private final ChatsPinsRepository chatsPinsRepository;

    public SendMessage apply(Callback callback, Update update) {
        long chatId = update.getCallbackQuery().getMessage().getChatId();
        long userId = update.getCallbackQuery().getFrom().getId();
        SendMessage answer = new SendMessage();
        Integer addressId = Integer.valueOf(callback.getData());
        if (callback.getCallbackType() == CallbackType.PIN_DONT_ADD) {
            answer = new SendMessage(String.valueOf(chatId), Consts.PIN_DONT_ADD_BYE);
        } else if (callback.getCallbackType() == CallbackType.PIN_ADD) {
            placesRepository.updateState(PinState.OUTDATED, addressId, userId);
            chatsPinsRepository.merge(new ChatsPinsEntity(chatId, addressId));
            answer = new SendMessage(String.valueOf(chatId), Consts.PIN_ADD_MSG);
        }

        return answer;
    }

}

As you can see, the essence here is approximately the same as in the command handler, specifically here we do not send the keyboard in response with a callback, but you saw how to do this above in the command handler. Here is the case when a person chooses whether or not he wants to add a current pin code, if positive, we save by chat_id id of the establishment, and please send the pin code as a command /pin 1234#. This is done so that we can see that this is a command, and only then check the address in the database. Otherwise, you would have to check the status in the database with each message, which would be an extra load on it. And then we just save this pin. It seems that from the point of view of code and logic we have everything, finally I will give an example of a repository, why it is worth using hibernate directly, and not “smart” JPA repositories:

@Repository
public class PlacesRepository extends BaseRepository<PlacesEntity> {

    public List<AddressDto> getAddressesOfType(PlaceType placeType, Long chatId) {
        return em.createQuery("""
                        select new kg.arzybek.bots.pincity.dto.AddressDto(p.id,p.address, p.name)
                        from PlacesEntity p
                        inner join ChatsCitiesEntity c
                        on p.cityId = c.cityId
                        where p.type =: placeType and c.chatId =: chatId
                        """, AddressDto.class)
                .setParameter("placeType", placeType)
                .setParameter("chatId", chatId)
                .getResultList();
    }
...

Firstly, yes, I inherit from the base repository, which I also wrote myself, it contains a couple of commands that will be needed in any repository. The main advantage is that you can write SQL queries here directly linking them with Java code, and write them of any complexity, even perform some operations with data here, choose what to return, etc. I’m not sure that this is possible in those “smart” repositories that have interfaces, but in any case, I think it’s not so convenient.

Well, now let’s finally move on to deployment. Because Heroku stopped providing a free tariff, and it doesn’t allow you to register from Russia, as far as I remember, we had to look for other options that were convenient for us. And the ideal service is fly.io (no advertising). It provides a plan in which you will have up to 3 CPUs with up to 256 MB RAM and 1 GB of storage. It’s fine for a bot. Register, then install flyctl curl -L https://fly.io/install.sh | sh (important: at the end of the installation it may tell you to add a couple of variables to your .profile, if it says do so, otherwise it will not run from the terminal). Next, go to our account fly auth login.

Next we will need postgres for our service. Unfortunately, this cannot be done from a compose file, so we will use the fly utility. We write flyctl postgres create, then select a name, the organization will be automatically personalized, select the region to suit your taste, select the configuration “developement”, this is what is included in the free tariff. Next, he will give you your database credentials. Be sure to save them, we will need them later!

$ fly postgres create
? Choose an app name (leave blank to generate one): pincity-db
? Select Organization: Arzybek (personal)
? Select regions: Singapore, Singapore (sin)
? Select configuration: Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk

Next we will need to create a dockerfile for our application. The simplest one will work. Here from ENV we set only those that do not differ in the local and remote databases. I’ll tell you about the “-XX” parameter later.

FROM openjdk:17
EXPOSE 8080
COPY target/pin-city-0.0.1-SNAPSHOT.jar app.jar
ENV POSTGRES_SCHEMA "pins"
ENTRYPOINT ["java","-XX:MaxRAM=100M", "-jar", "/app.jar"]

Then you can push this image somewhere, or write fly launch , and it will find your docker file, understand that you want to build it and start building it. Here you can also select your region and organization. The file will be created fly.toml, this is a configuration file for deployment, we will need it later. Next, we set up the database access credentials, this is done like this: flyctl secrets set POSTGRES_PASSWORD=***, here write the password he gave you. Next, the most important thing is the database access link. Because this is all not happening locally anymore, the link will be different:
flyctl secrets set POSTGRES_JDBC_URL=jdbc:postgresql://yourapp.internal:5432/databasenamelook at the message that he gave you after creating postgres, there you will find an address with internal, insert it instead of yourapp.

It would seem that everything is ready, but it was not so. Because fly we only provide 256MB of memory; with liquibase, your application will crash at startup, because will ask for more, even if he doesn’t need it. That’s what the flag was for -XX:MaxRAM=100M, however, even one flag will not be enough. Even if you specify 70MB, this will not be enough, so you also need to add the line in the same fly.toml file swap_size_mb = 512, this will activate swap and our application will have more than enough memory to start and run without problems. I also recommend changing the file auto_stop_machines = false if you don’t want your bot to turn off if there is no traffic, and comment # force_https = trueit seems this opens ports to the application, but to be honest, I’m not sure what port telegram bots work on, the application itself, as you saw, I run on 8080, and everything seems to work, in any case it works for me, you can experiment.

That’s it, after that all you have to do is type fly deploy and your bot will start deploying and deploy, after which you can check its performance. Yes, it was a long article, but the bot itself turned out to be relatively simple. Initially, I planned to connect the Google API altogether and make it possible to search for an establishment not by the list loaded at startup in the database, but by address, but first I decided to do it with a limited list of establishments and cities, because it seems that I’ll have to figure it out with the Google maps API . In any case, I hope this article will be useful to someone, at least because in it I showed not only how important it is to deploy for free in 2023, but also how to turn a telegram bot into a full-fledged spring application, which means the possibilities for the implemented logic are limited only your imagination and knowledge. Oh, well, I almost forgot, link to the projectif someone wants to study it in more detail, follow the development (if it happens, of course, which I do not guarantee), etc.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *