Привет, читателям Хабра!


В этой статье я расскажу о том, как быстро и легко разработать свой собственный телеграм-бот на языке Kotlin с использованием Spring Boot.


Основная задумка заключается в том, чтобы архитектура Бота была абстрагирована от бизнес процессов. То есть акцент должен быть задействован именно на написании кода, который нужен только бизнесу.


Технологии


Мой выбор пал на следующий стек технологий:


  • Kotlin
  • Spring Boot 2.5+
  • JOOQ
  • Freemarker
  • PostgreSQL
  • org.telegram.telegrambots

Обоснования выбора технологий


Spring Boot и весь Spring Framework в JVM мире стал неотъемлемой частью создания больших и сложных энтерпрайз систем. Пал выбор именно на него, так как порой хочется сделать не просто бота, а полноценное приложение, которым будет удобно пользоваться и удобно масштабировать.


Kotlin считается неким витком развития в мире JVM, он проще JAVA и очень хорошо интегрирован в Spring Framework
JOOQ — механизм, который помогает на DSL подобном языке формировать sql запросы.
Freemarker — шаблонизатор, необходим для формирования динамичных текстовок
PostgreSQL — СУБД. Тут субъективный выбор. Считаю его лучшим из бесплатных инструментов.
org.telegram.telegrambots — набор библиотек для Telegram Api


Источники


Сам код лежит в гитхабе.
Как создать нового бота и описание api можно найти тут


Руководство


Как и в любое приложении в JVM мире, начнем работу с описании зависимостей.


build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import nu.studer.gradle.jooq.JooqEdition
import nu.studer.gradle.jooq.JooqGenerate

val postgresVersion = "42.3.1"
val telegramBotVersion = "5.3.0"

// Список необходимых плагинов
plugins {
    id("nu.studer.jooq") version("6.0.1")
    id("org.flywaydb.flyway") version("7.7.0")
    id("org.springframework.boot") version "2.5.6"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    kotlin("jvm") version "1.5.31"
    kotlin("plugin.spring") version "1.5.31"
}

group = "ru.template.telegram.bot.kotlin"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
    mavenCentral()
}

// механизм, который поможет сгенерить метаданные
configurations {
    compileOnly {
        extendsFrom(configurations.annotationProcessor.get())
    }
}

tasks.clean {
    delete("src/main/java")
}

extra["springCloudVersion"] = "2020.0.4"

val flywayMigration = configurations.create("flywayMigration")

// Надстройка для миграции данных в СУБД
flyway {
    validateOnMigrate = false
    configurations = arrayOf("flywayMigration")
    url = "jdbc:postgresql://localhost:5432/kotlin_template"
    user = "postgres"
    password = "postgres"
}

// список зависимстей
dependencies {
    flywayMigration("org.postgresql:postgresql:$postgresVersion")
    jooqGenerator("org.postgresql:postgresql:$postgresVersion")
    runtimeOnly("org.postgresql:postgresql")

   //Классические стартеры spring boot
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.boot:spring-boot-starter-jooq")
    implementation("org.springframework.boot:spring-boot-starter-freemarker")
    implementation("org.springframework.boot:spring-boot-starter-web")

    implementation("org.telegram:telegrambots:$telegramBotVersion")
    implementation("org.telegram:telegrambotsextensions:$telegramBotVersion")
    implementation("org.telegram:telegrambots-spring-boot-starter:$telegramBotVersion")

    // зависимости, которые помогут сгенерить метаданные
    compileOnly("org.springframework.boot:spring-boot-configuration-processor")
    annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
    }
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "11"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

// Настройка для JOOQ, в которой описано правило формирования POJO классов для формирования запросов при помощи DSL кода
jooq {
    edition.set(JooqEdition.OSS)

    configurations {
        create("main") {
            jooqConfiguration.apply {
                jdbc.apply {
                    driver = "org.postgresql.Driver"
                    url = flyway.url
                    user = flyway.user
                    password = flyway.password
                }
                generator.apply {
                    name = "org.jooq.codegen.DefaultGenerator"
                    generate.apply {
                        isDeprecated = false
                        isRecords = true
                        isImmutablePojos = false
                        isFluentSetters = false
                        isJavaBeansGettersAndSetters = false
                        isSerializablePojos = true
                        isVarargSetters = false
                        isPojos = true
                        isNonnullAnnotation = true
                        isUdts = false
                        isRoutines = false
                        isIndexes = false
                        isRelations = true
                        isPojosEqualsAndHashCode = true
                    }
                    database.apply {
                        name = "org.jooq.meta.postgres.PostgresDatabase"
                        inputSchema = "public"
                        excludes = "flyway_schema_history"
                    }
                    target.apply {
                        // Пакет куда отрпавляются сгенерированные классы
                        packageName = "ru.template.telegram.bot.kotlin.template.domain"
                        directory = "src/main/java"
                    }
                    strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy"
                }
            }
        }
    }

    // таска для генерации JOOQ классов
    tasks.named<JooqGenerate>("generateJooq").configure {
        inputs.files(fileTree("src/main/resources/db/migration"))
            .withPropertyName("migrations")
            .withPathSensitivity(PathSensitivity.RELATIVE)
        allInputsDeclared.set(true)
        outputs.upToDateWhen { false }
    }
}

Как мы видим из описания кода выше, мы собираем зависимости при помощи Gradle. Не будем подробно останавливаться на теме: как правильно написать gradle-файл. В интернете много примеров. Сейчас нам не так это важно.


Следующим этапом — создание главного класса, который будет запускать нашего бота.


Application.kt
package ru.template.telegram.bot.kotlin.template

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

// Все Spring Boot приложения начинаются с аннотации @SpringBootApplication
@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

Для примера, весь наш код описан в пакете ru.template.telegram.bot.kotlin.template. Там будут лежать и прочие компоненты нашей архитектуры.


Создадим пакеты:


  • api — Классы, которые относятся к непосредственному взаимодействию с Телеграм API (отправка и получение данных)
  • command — Список команд телеграм бота
  • component — Прочие бины.
  • config — Конфигурация приложения
  • dto — DTO классы
  • enums — Енумы
  • event — Список классов для формирования ивентов Application Publisher
  • listener — Приём событий Application Publisher
  • properties — Классы настроек приложения. Сами настройки лежат в ресурсах приложения (appication.yml)
  • repository — Слой взаимодействия с СУБД
  • service — Сервисы приложения
  • strategy — Стратегии. Это те компоненты, которые нужно менять, добавлять и удалять по ходу изменения бизнес процессов

Начало формирование архитектуры с небольшим примером


Нам необходимо создать такой компонент, который бы принимал наши сообщения от Бота и начинал их обрабатывать.


Создадим файл application.yml


# Настройка для телеграм апи
bot:
  username: kotlin_template_bot
  token: [your bot token here]
# Настройка для СУБД
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/kotlin_template
    username: postgres
    password: postgres

После чего опишем нашего бота в виде класса


BotProperty.kt
package ru.template.telegram.bot.kotlin.template.properties

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component

@Component
@ConfigurationProperties(prefix = "bot")
// Всё, что есть в application.yml можно описать в виде класса. Делается в первую очередь для удобства
data class BotProperty(
    var username: String = "",
    var token: String = ""
)

В примере представлены несколько команд бота. Они описаны в виде енумов


CommandCode.kt
package ru.template.telegram.bot.kotlin.template.enums

// Енум состоит из самой команды, команды бота в телеграме и ее словесное описание
enum class CommandCode(val command: String, val desc: String) {
    START("start", "Start work"),
    USER_INFO("user_info", "user info"),
    BUTTON("button", "button yes no")
}

Для примера реализуем несколько команд: Начало работы (приветствие), информация о пользователе ( на этом этапе просто обновим данные в базе случайным текстом) и сообщение с кнопками (здесь нажмём на кнопку из предложенных)


Для простоты у нас будет 1 таблица users


Создадим миграцию в resources/db/migration


V1__init.sql
create table users
(
    id        int8 primary key not null,
    step_code varchar(100), -- код этапа
    text      varchar(100), -- произвольный текст
    accept    varchar(3) -- данные из кнопок
);

Теперь необходимо подготовить UsersRepository в котором будут реализованы все методы, которые понадобятся нам в работе.


UsersRepository.kt
package ru.template.telegram.bot.kotlin.template.repository

import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import ru.template.telegram.bot.kotlin.template.domain.Tables
import ru.template.telegram.bot.kotlin.template.domain.tables.pojos.Users
import ru.template.telegram.bot.kotlin.template.enums.StepCode

@Repository
class UsersRepository(private val dslContext: DSLContext) {

    private val users = Tables.USERS

    // Проверка на существование пользователя в базе. Нужно 1 раз для команды /start
    fun isUserExist(chatId: Long): Boolean {
        return dslContext.selectCount().from(users).where(users.ID.eq(chatId)).fetchOneInto(Int::class.java) == 1
    }

   // Созадние пользователя для команды /start
    fun createUser(chatId: Long): Users {
        val record = dslContext.newRecord(users, Users().apply {
            id = chatId
            stepCode = StepCode.START.toString()
        })
        record.store()
        return record.into(Users::class.java)
    }

    // получить информацию о пользователе
    fun getUser(chatId: Long) =
        dslContext.selectFrom(users).where(users.ID.eq(chatId)).fetchOneInto(Users::class.java)

    // Обновление этапа в боте
    fun updateUserStep(chatId: Long, stepCode: StepCode): Users =
        dslContext.update(users)
            .set(users.STEP_CODE, stepCode.toString())
            .where(users.ID.eq(chatId)).returning().fetchOne()!!.into(Users::class.java)

    // Обновление текста. Этот метод срабатывает у команды /user_info
    fun updateText(chatId: Long, text: String) {
        dslContext.update(users)
            .set(users.TEXT, text)
            .where(users.ID.eq(chatId)).execute()
    }

    // Обновление данных пришедшие от кнопок в команде /button
    fun updateAccept(chatId: Long, accept: String) {
        dslContext.update(users)
            .set(users.ACCEPT, accept)
            .where(users.ID.eq(chatId)).execute()
    }
}

Здесь уже можно и нужно сформировать POJO объекты при помощи таски gradle flywayMigrate generateJooq


Создать новую команду тоже для нас не проблема. В данной статье опишем только одну команду, всё остальное есть в исходниках. Делается по аналогии


StartCommand.kt
package ru.template.telegram.bot.kotlin.template.command

import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Component
import org.telegram.telegrambots.extensions.bots.commandbot.commands.BotCommand
import org.telegram.telegrambots.meta.api.objects.Chat
import org.telegram.telegrambots.meta.api.objects.User
import org.telegram.telegrambots.meta.bots.AbsSender
import ru.template.telegram.bot.kotlin.template.enums.CommandCode
import ru.template.telegram.bot.kotlin.template.enums.StepCode
import ru.template.telegram.bot.kotlin.template.event.TelegramStepMessageEvent
import ru.template.telegram.bot.kotlin.template.repository.UsersRepository

@Component
class StartCommand(
    private val usersRepository: UsersRepository,
    private val applicationEventPublisher: ApplicationEventPublisher // Интерфейс который отправляет событие 
) : BotCommand(CommandCode.START.command, CommandCode.START.desc) {

    companion object {
        private val START_CODE = StepCode.START
    }

    override fun execute(absSender: AbsSender, user: User, chat: Chat, arguments: Array<out String>) {
        val chatId = chat.id // chatId передает телеграм

       // если пользователя в базе не существует, то создаём его, иначе обновляешь этап
        if (usersRepository.isUserExist(chatId)) {
            usersRepository.updateUserStep(chatId, START_CODE)
        } else usersRepository.createUser(chatId)

        applicationEventPublisher.publishEvent(
            TelegramStepMessageEvent(chatId = chatId, stepCode = START_CODE)
        )
    }

}

Как мы видим из кода, начинаем формировать событие TelegramStepMessageEvent.
BotCommand — это интерфейс описания команд телеграм АПИ


Класс TelegramStepMessageEvent лежит в пакете event


TelegramStepMessageEvent.kt
package ru.template.telegram.bot.kotlin.template.event

import ru.template.telegram.bot.kotlin.template.enums.StepCode

class TelegramStepMessageEvent(
    // chatId из бота
    val chatId: Long,
    // Этап или шаг в боте (стартовый, выбор кнопки, сообщение пришедшее после кнопки и тд и тп). Не путать с командами, так как в команде может быть несколько этапов
    val stepCode: StepCode
)

StepCode — enum, который носит информацию о типе сообщения, о шаге и прочую системную информацию


StepCode.kt
package ru.template.telegram.bot.kotlin.template.enums

// Тип (Простой текст или текст с кнопками) и botPause - остановить переход на новый этап для принятия решения пользователем
enum class StepCode(val type: StepType, val botPause: Boolean) {
    START(StepType.SIMPLE_TEXT, false),
    USER_INFO(StepType.SIMPLE_TEXT, true),
    BUTTON_REQUEST(StepType.INLINE_KEYBOARD_MARKUP, true),
    BUTTON_RESPONSE(StepType.SIMPLE_TEXT, true)
}

enum class StepType {
    // Простое сообщение
    SIMPLE_TEXT,
    // Сообщение с кнопкой
    INLINE_KEYBOARD_MARKUP
}

Остановимся немного на енуме StepCode и StepType. Когда мы выбираем ту или иную команду формируется сообщение, которое отправляется пользователю. Иногда нужно отправить несколько сообщений подряд. Например START и затем USER_INFO. botPause нужен в первую очередь, чтобы проинформировать пользователя о необходимости принятия решений. Некоторые сообщения приходят с кнопками. Для этого и нужен енум StepType


Непосредственная реализация приёма сообщений будет представлена в компоненте ApplicationListener


ApplicationListener.kt
package ru.template.telegram.bot.kotlin.template.listener

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Lazy
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
import ru.template.telegram.bot.kotlin.template.enums.ExecuteStatus
import ru.template.telegram.bot.kotlin.template.event.TelegramReceivedCallbackEvent
import ru.template.telegram.bot.kotlin.template.event.TelegramReceivedMessageEvent
import ru.template.telegram.bot.kotlin.template.event.TelegramStepMessageEvent
import ru.template.telegram.bot.kotlin.template.repository.UsersRepository
import ru.template.telegram.bot.kotlin.template.service.MessageService
import ru.template.telegram.bot.kotlin.template.strategy.LogicContext
import ru.template.telegram.bot.kotlin.template.strategy.NextStepContext

@Component
class ApplicationListener(
    private val logicContext: LogicContext, // Основная бизнес логика
    private val nextStepContext: NextStepContext, // Выбор следующего этапа
    private val usersRepository: UsersRepository, // Слой СУБД
    private val messageService: MessageService // Сервис, который формирует объект для отрпавки сообщения в бота
) {

    // Слушаем событие TelegramReceivedMessageEvent
    inner class Message {
        @EventListener
        fun onApplicationEvent(event: TelegramReceivedMessageEvent) {
            logicContext.execute(chatId = event.chatId, message = event.message)
            val nextStepCode = nextStepContext.next(event.chatId, event.stepCode)
            if (nextStepCode != null) {
                stepMessageBean().onApplicationEvent(
                    TelegramStepMessageEvent(
                        chatId = event.chatId,
                        stepCode = nextStepCode
                    )
                )
            }
        }
    }

    // Слушаем событие TelegramStepMessageEvent
    inner class StepMessage {
        @EventListener
        fun onApplicationEvent(event: TelegramStepMessageEvent) {
            // Обновляем шаг
            usersRepository.updateUserStep(event.chatId, event.stepCode)
            // Отправляем сообщение в бота (и формируем)
            messageService.sendMessageToBot(event.chatId, event.stepCode)
        }
    }

    // Слшуаем событие TelegramReceivedCallbackEvent
    inner class CallbackMessage {
        @EventListener
        fun onApplicationEvent(event: TelegramReceivedCallbackEvent) {
            val nextStepCode = when (logicContext.execute(event.chatId, event.callback)) {
                ExecuteStatus.FINAL -> { // Если бизнес процесс одобрил переход на новый этап
                    nextStepContext.next(event.chatId, event.stepCode)
                }
                ExecuteStatus.NOTHING -> throw IllegalStateException("Не поддерживается")
            }
            if (nextStepCode != null) {
                // редирект на событие TelegramStepMessageEvent
                stepMessageBean().onApplicationEvent(
                    TelegramStepMessageEvent(
                        chatId = event.chatId,
                        stepCode = nextStepCode
                    )
                )
            }
        }
    }

    @Bean
    @Lazy
    // Бин поступления сообщения от пользователя
    fun messageBean(): Message = Message()

    @Bean
    @Lazy
    // Бин отправки сообщения ботом
    fun stepMessageBean(): StepMessage = StepMessage()

    @Bean
    @Lazy
    // Бин, который срабатывает в момент клика по кнопке
    fun callbackMessageBean(): CallbackMessage = CallbackMessage()

}

MessageService — сервис, который формирует объект Телеграм АПИ сообщения и делает запрос на отправку в бота


MessageService.kt
package ru.template.telegram.bot.kotlin.template.service

import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.telegram.telegrambots.meta.api.methods.BotApiMethod
import org.telegram.telegrambots.meta.api.methods.ParseMode
import org.telegram.telegrambots.meta.api.methods.send.SendMessage
import org.telegram.telegrambots.meta.api.objects.Message
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup
import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardRemove
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton
import ru.template.telegram.bot.kotlin.template.api.TelegramSender
import ru.template.telegram.bot.kotlin.template.dto.MarkupDataDto
import ru.template.telegram.bot.kotlin.template.dto.markup.DataModel
import ru.template.telegram.bot.kotlin.template.enums.StepCode
import ru.template.telegram.bot.kotlin.template.enums.StepType.*
import ru.template.telegram.bot.kotlin.template.event.TelegramStepMessageEvent
import ru.template.telegram.bot.kotlin.template.strategy.MarkupContext
import ru.template.telegram.bot.kotlin.template.strategy.MessageContext
import ru.template.telegram.bot.kotlin.template.strategy.NextStepContext

@Service
class MessageService(
    private val telegramSender: TelegramSender, // отправщик сообщения
    private val messageContext: MessageContext, // Формирование текстовок сообщения
    private val applicationEventPublisher: ApplicationEventPublisher,
    private val markupContext: MarkupContext<DataModel>, // Формирование текстовок с кнопками
    private val nextStepContext: NextStepContext // Выбор следующего этапа
) {

    fun sendMessageToBot(
        chatId: Long,
        stepCode: StepCode
    ) {
        when (stepCode.type) {
            // Простое сообщение
            SIMPLE_TEXT -> telegramSender.execute(simpleTextMessage(chatId))
            // Сообщение с кнопками
            INLINE_KEYBOARD_MARKUP -> telegramSender.sendInlineKeyboardMarkup(chatId)
        }

        if (!stepCode.botPause) { // если нет паузы, то формируем следующее сообщение
            applicationEventPublisher.publishEvent(
                TelegramStepMessageEvent(
                    chatId = chatId,
                    stepCode = nextStepContext.next(chatId, stepCode)!!
                )
            )
        }
    }

    // SendMessage - объект телеграм АПИ для отправки сообщения
    private fun simpleTextMessage(chatId: Long): SendMessage {
        val sendMessage = SendMessage()
        sendMessage.chatId = chatId.toString()
        sendMessage.text = messageContext.getMessage(chatId)
        sendMessage.enableHtml(true)

        return sendMessage
    }

    // Отправляем в бота сообщение с кнопками
    private fun TelegramSender.sendInlineKeyboardMarkup(chatId: Long) {
        val inlineKeyboardMarkup: InlineKeyboardMarkup
        val messageText: String

        val inlineKeyboardMarkupDto = markupContext.getInlineKeyboardMarkupDto(chatId)!!
        messageText = inlineKeyboardMarkupDto.message
        inlineKeyboardMarkup = inlineKeyboardMarkupDto.inlineButtons.getInlineKeyboardMarkup()

        this.execute(sendMessageWithMarkup(chatId, messageText, inlineKeyboardMarkup))
    }

    private fun sendMessageWithMarkup(
        chatId: Long, messageText: String, inlineKeyboardMarkup: InlineKeyboardMarkup
    ): BotApiMethod<Message> {
        val sendMessage = SendMessage()
        sendMessage.chatId = chatId.toString()
        sendMessage.text = messageText

        sendMessage.replyMarkup = inlineKeyboardMarkup
        sendMessage.parseMode = ParseMode.HTML
        return sendMessage
    }

    // Формируем модель кнопок
    private fun List<MarkupDataDto>.getInlineKeyboardMarkup(): InlineKeyboardMarkup {
        val inlineKeyboardMarkup = InlineKeyboardMarkup()
        val inlineKeyboardButtonsInner: MutableList<InlineKeyboardButton> = mutableListOf()
        val inlineKeyboardButtons: MutableList<MutableList<InlineKeyboardButton>> = mutableListOf()
        this.sortedBy { it.rowPos }.forEach { markupDataDto ->
            val button = InlineKeyboardButton()
                .also { it.text = markupDataDto.text }
                .also { it.callbackData = markupDataDto.text }
            inlineKeyboardButtonsInner.add(button)
        }
        inlineKeyboardButtons.add(inlineKeyboardButtonsInner)
        inlineKeyboardMarkup.keyboard = inlineKeyboardButtons
        return inlineKeyboardMarkup
    }
}

Сам отправщик сообщений выглядит проще. Все механизмы в библиотеке, но для ее реализации нужно создать класс


TelegramSender.kt
package ru.template.telegram.bot.kotlin.template.api

import javax.annotation.PostConstruct
import org.springframework.stereotype.Component
import org.telegram.telegrambots.extensions.bots.commandbot.TelegramLongPollingCommandBot
import org.telegram.telegrambots.extensions.bots.commandbot.commands.IBotCommand
import org.telegram.telegrambots.meta.api.methods.commands.SetMyCommands
import org.telegram.telegrambots.meta.api.methods.send.SendMessage
import org.telegram.telegrambots.meta.api.objects.Update
import org.telegram.telegrambots.meta.api.objects.commands.BotCommand
import org.telegram.telegrambots.meta.api.objects.commands.scope.BotCommandScopeChat
import ru.template.telegram.bot.kotlin.template.properties.BotProperty
import ru.template.telegram.bot.kotlin.template.service.ReceiverService

@Component
class TelegramSender(
    private val botProperty: BotProperty,
    private val botCommands: List<IBotCommand>,
    private val receiverService: ReceiverService
) : TelegramLongPollingCommandBot() {

    @PostConstruct
    // Регистрация команд в системе
    fun initCommands() {
        botCommands.forEach {
            register(it)
        }

        registerDefaultAction { absSender, message ->

            val commandUnknownMessage = SendMessage()
            commandUnknownMessage.chatId = message.chatId.toString()
            commandUnknownMessage.text = "Command '" + message.text.toString() + "' unknown"

            absSender.execute(commandUnknownMessage)
        }
    }

    // токен. Формируем в @BotFather
    override fun getBotToken() = botProperty.token

    // username. Формируем в @BotFather
    override fun getBotUsername() = botProperty.username

    // событие, которое пришли от пользователя (кромер команд)
    override fun processNonCommandUpdate(update: Update) {
        receiverService.execute(update)
    }
}

TelegramLongPollingCommandBot — это базовый класс Телеграм АПИ, который отправляет и принимает сообщения. Хочу отметить, что в примере есть проперти, который нужно задать через BotFather


Осталось дело за малым. Сервис приёма сообщений ReceiverService непосредственно принимает текст, введенный пользователем или мета информацию по кнопке.


ReceiverService.kt
package ru.template.telegram.bot.kotlin.template.service

import org.springframework.context.ApplicationEventPublisher
import org.springframework.stereotype.Service
import org.telegram.telegrambots.meta.api.objects.CallbackQuery
import org.telegram.telegrambots.meta.api.objects.Message
import org.telegram.telegrambots.meta.api.objects.Update
import ru.template.telegram.bot.kotlin.template.enums.StepCode
import ru.template.telegram.bot.kotlin.template.event.TelegramReceivedCallbackEvent
import ru.template.telegram.bot.kotlin.template.event.TelegramReceivedMessageEvent
import ru.template.telegram.bot.kotlin.template.repository.UsersRepository

@Service
class ReceiverService(
    private val applicationEventPublisher: ApplicationEventPublisher,
    private val usersRepository: UsersRepository
) {

    // выходной метод сервиса
    fun execute(update: Update) {
        if (update.hasCallbackQuery()) { // Выполнить, если это действие по кнопке
            callbackExecute(update.callbackQuery)
        } else if (update.hasMessage()) { // Выполнить, если это сообщение пользователя
            messageExecute(update.message)
        } else {
            throw IllegalStateException("Not yet supported")
        }
    }

    private fun messageExecute(message: Message) {
        val chatId = message.chatId
        val stepCode = usersRepository.getUser(chatId)!!.stepCode // Выбор текущего шага
        applicationEventPublisher.publishEvent( // Формируем событие TelegramReceivedMessageEvent
            TelegramReceivedMessageEvent(
                chatId = chatId,
                stepCode = StepCode.valueOf(stepCode),
                message = message
            )
        )
    }

    private fun callbackExecute(callback: CallbackQuery) {
        val chatId = callback.from.id
        val stepCode = usersRepository.getUser(chatId)!!.stepCode // Выбор текущего шага
        applicationEventPublisher.publishEvent( // Формируем событие TelegramReceivedCallbackEvent
            TelegramReceivedCallbackEvent(chatId = chatId, stepCode = StepCode.valueOf(stepCode), callback = callback)
        )
    }
}

Здесь всё просто, если Кнопка, то событие для кнопки, если текст, то событие для обработки текста.


Бизнес процессы


Так как эта статья отражает лишь архитектуру бота, то описывать реализацию бизнес процессов нет смысла. Код с примером можно найти в гитхабе по ссылке выше.


Отмечу только тот факт, что для описания самих бизнес процессов необходимо для каждого этапа реализовать имплементацию интерфейсов



Заключение


Итого, что мы имеем? Описанный код продемонстрировал "поляну" для того, чтобы разработчики накидали классы, в которых будет реализована основная бизнес логика (добавление данных в базу, действие по кнопке). Также можно реализовать бизнес логику на роутинг следующего шага. Конечно, данная статья не поможет реализовать достаточно сложного бота. Однако, поработав с аналитиком и архитектором, можно с лёгкостью реализовать новые слои в пакете strategy.

Комментарии (14)


  1. rehci
    11.11.2021 14:39

    Сколько такой бот занимает RAM?


    1. minpor Автор
      11.11.2021 14:50

      Замеры специально не делал, но полноценный маркетплейс, который основан на текущей архитектуре вполне умещается на 500мб ram. Там 500+ классов, очереди и шедулеры


    1. PqDn
      11.11.2021 16:01

      если за рам борьба идет можно на граальVM сделать


      1. minpor Автор
        11.11.2021 16:11

        есть в планах. Но у меня не была цель экономии RAM, цель: удобство масштабирования!


  1. PqDn
    11.11.2021 16:02
    +1

    предлагаю весь код запихать в каллапс блоки (чтобы могли сворачиваться)


  1. xonix
    11.11.2021 17:44
    +1

    Как-то выглядит переусложненным, так что за деревьями не видно леса. Вот пример одного из моих ботов https://github.com/xonixx/bookmark-telegram-bot - гораздо проще в реализации, реализует реальный сценарий и даже имеет тесты.


    1. minpor Автор
      11.11.2021 17:51

      В моём примере готовая архитектура, если нужно добавить новый функционал, то просто наследуемся от InlineKeyboardMarkup или Chooser или Message и ChooseNextStep. Не забываем про паттерны ООП.


      1. xonix
        11.11.2021 18:02

        А была ли эта архитектура испытана на хоть одной реальной реализации бота?


        1. minpor Автор
          11.11.2021 18:03

          да, конечно. https://t.me/my_flower_com_bot


  1. MisterFix
    12.11.2021 09:51
    +1

    А почему было принято решение конфигурировать JOOQ на генерацию исходников в src/main/java? ИМХО спорный момент, особенно, учитывая, что clean у вас удаляет эти исходники, а они лежат в VCS, возможно стоило их оставить в build директории


    1. minpor Автор
      12.11.2021 09:58

      Да, вы правы. Просто нужно добавить в .gitignore src/main/java

      У вас наверно еще возникнет вопрос: почему не котлин генерация жука? Да всё потому, что котлин генерит до сих пор кривые дата классы POJO объектов


      1. MisterFix
        12.11.2021 10:13

        А в чем получаемые дата классы кривые? Проблема в том, что генерирует JOOQ или как они реализованы в Котлине? Сообщали ли вы разработчикам? ;)


        1. minpor Автор
          12.11.2021 10:21

          Там все дата классы помечаются как nullable. У них есть таска на то, чтобы сделать нормально. Но пока не произошло


        1. minpor Автор
          12.11.2021 10:33

          изменения запушил