Conversation Bots With Spring

June 2024 ยท 5 minute read

tl;dr: code is here https://github.com/gasabr/spring-telegrambot

Motivation

There are a lot of (AI) conversation bots right now. Telegram is one of the most popular messaging platforms in the world comparable with WhatsApp and others. Java Spring Framework is being used in a lot of projects, so combining them might be useful for many people building online conversation who do not want to set up virtual environment or download npm packages:)

IMO chatbot provides questionable UX, but it’s so common nowadays, I won’t continue this line of thought. The examples in the article would focus on telegram, but the idea can be used to implement conversations with any chat platform.

Idea

This approach leverages Spring State Machine (SSM) to manage conversation flows in a Telegram bot. By mapping chat interactions to SSM concepts, we create a robust architecture for handling complex dialogs. Key benefits include:

While demonstrated with Telegram and Spring, these concepts can be adapted to other platforms. The goal is to provide a scalable, maintainable architecture for conversation bots that can handle complex interactions while remaining flexible.

Implementation

  1. Creating telegram bean
  2. Getting updates
  3. Mapping conversation to state machine abstractions
  4. Sending updates to state machine
  5. Sending messages back to the user
  6. Error handling

Creating telegram bean

I’m going to use pengrad telegram library since it has complete support for the telegram api. First, we need to create an instance of the TelegramBot class which is responsible for getting updates and sending responses. Here is the configuration and the code

telegram:
  api-token: ${TELEGRAM_API_TOKEN}   
@ConfigurationProperties(prefix = "telegram")
data class TelegramConfigurationProperties @ConstructorBinding constructor(
    val apiToken: String,
)

@Configuration
@EnableConfigurationProperties(TelegramConfigurationProperties::class)
class TelegramConfiguration {
    @Bean
    fun telegramBot(properties: TelegramConfigurationProperties) =
        TelegramBot(properties.apiToken)
}

In the actual code you might want to add enabled flag or a channel id, this is the simplest possible configuration.

Getting updates

Once we got the bot, getting updates is quite straightforward, I opted to implement it in a separate component to simplify adding support for webhooks later. Other than that it would enough to use telegramBot.setUpdatesListener and log the message for now.

@Component
class TelegramBotSetup(
    private val telegramBot: TelegramBot,
) {

    companion object {
        val logger = LoggerFactory.getLogger(TelegramBotSetup::class.java)!!
    }

    @PostConstruct
    fun configureBot() {
        logger.info("Configuring telegram bot to use pulling.")

        telegramBot.setUpdatesListener {
            logger.info("Got some updates.")
            it.forEach { upd ->
                logger.info("Got an update: `$upd`.")
            }

            return@setUpdatesListener UpdatesListener.CONFIRMED_UPDATES_ALL
        }
    }
}

Implementing conversation logic

In this toy example I’m going to implement 2 functions for the bot:

Those are not useful by any means, but they would showcase everything one would need to create a proper bot:

![[Pasted image 20240610072027.png]]

So to convert telegram conversation to the spring state machine I would use:

@Configuration
@EnableStateMachine
class StateMachineConfig(
    private val telegramBot: TelegramBot,
) : StateMachineConfigurerAdapter<States, Events>() {

    override fun configure(states: StateMachineStateConfigurer<States, Events>) {
        super.configure(states)
        states.withStates()
            .initial(States.IDLE)   // the state where sm starts when created
            .choice(States.GOT_CMD)   // phantom state that routes sm based on condition
            .states(States.entries.toTypedArray().toMutableSet())
            .and()
            .withStates()
            .parent(States.GOT_CMD)  // a way to define branches of the convo
            .initial(States.GOT_HELLO_CMD)   // start of the branch
            .stateEntry(
                States.GOT_HELLO_CMD,
                CallbackAction(telegramBot) { c, b ->
                    sendNamePrompt(c, b)
                })
            .state(States.GOT_NAME, CallbackAction(telegramBot) { c, b ->
                sendHelloWorld(c, b)
            })  // next state in the branch
            .end(States.CONVERSATION_ENDED)  // global end of the conversation
    }

    override fun configure(transitions: StateMachineTransitionConfigurer<States, Events>) {
        super.configure(transitions)
        transitions
            .withExternal()
            .source(States.IDLE)
            .event(Events.GOT_TEXT)
            .guard(AnyCommandGuard())
            .target(States.GOT_CMD)

            .and()
            .withExternal()
            .event(Events.GOT_TEXT)
            .source(States.GOT_HELLO_CMD)
            .target(States.GOT_NAME)

            .and()
            .withExternal()
            .event(Events.GOT_TEXT)
            .source(States.GOT_NAME)
            .target(States.CONVERSATION_ENDED)

            .and()
            .withExternal()
            .event(Events.GOT_INVALID_INPUT)
            .source(States.GOT_NAME)
            .target(States.GOT_HELLO_CMD)

            .and()
            .withChoice()
            .source(States.GOT_CMD)
            .first(States.GOT_HELLO_CMD, CommandGuard("hello"))
            .then(States.GOT_ANOTHER_CMD, CommandGuard("another"))
            .last(States.IDLE, CallbackAction(telegramBot) { c, b -> })

    }
}

Error handling

How do professionals do it? Telegram itself just resends you a message implying that the last one contained the error, so that would be a good enough tactic for me. To implement that the cleanest way would probably be to return conversation to the previous state which would cause resending of the message, since it is binded to OnStateEntry event. The implementation will be quite straightforward:

class CallbackAction(
    private val errorEvent: Events = Events.GOT_INVALID_INPUT,
    private val callback: (StateContext<States, Events>) -> Unit,
) : Action<States, Events> {
    companion object {
        private val logger = LoggerFactory.getLogger(CallbackAction::class.java)
    }

    override fun execute(context: StateContext<States, Events>) {
        try {
            callback(context)
        } catch (e: Exception) {
            logger.error("Got error executing callback: ${e.message}")
            sendSmEvent(context.stateMachine, errorEvent)
        }
        sendSmEvent(context.stateMachine, Events.SENT_RESPONSE)
    }
}

sendSmEvent is just a helper to avoid tedious error handling in async functions

private fun sendSmEvent(stateMachine: StateMachine<States, Events>, event: Events) {
    val logger = LoggerFactory.getLogger("SmSender")
    stateMachine.sendEvent(Mono.just(GenericMessage(event))).subscribe(
        {
            logger.debug("Sent event={} to the sm", event)
        }, { sendingException ->
            logger.error("Error when sending event to state machine: $sendingException")
        })
}

we catch the exception and return conversation to the previous state with the configured error event and transition.

Conclusion

I hope this article have demonstrated how Spring State Machine can create robust, flexible Telegram bots for complex conversations. This approach offers structured dialog management, easy error handling, and platform independence. While basic, this implementation provides a solid foundation for more advanced chatbots, balancing scalability with maintainability.

Future work

References