Skip to content

GitLab

  • Projects
  • Groups
  • Snippets
  • Help
    • Loading...
  • Help
    • Help
    • Support
    • Community forum
    • Submit feedback
    • Contribute to GitLab
  • Sign in / Register
tehanu tehanu
  • Project overview
    • Project overview
    • Details
    • Activity
    • Releases
  • Repository
    • Repository
    • Files
    • Commits
    • Branches
    • Tags
    • Contributors
    • Graph
    • Compare
  • Issues 1
    • Issues 1
    • List
    • Boards
    • Labels
    • Service Desk
    • Milestones
  • Merge requests 0
    • Merge requests 0
  • CI/CD
    • CI/CD
    • Pipelines
    • Jobs
    • Schedules
  • Operations
    • Operations
    • Incidents
    • Environments
  • Packages & Registries
    • Packages & Registries
    • Container Registry
  • Analytics
    • Analytics
    • CI/CD
    • Repository
    • Value Stream
  • Wiki
    • Wiki
  • Snippets
    • Snippets
  • Members
    • Members
  • Activity
  • Graph
  • Create a new issue
  • Jobs
  • Commits
  • Issue Boards
Collapse sidebar
  • seykron
  • tehanutehanu
  • Wiki
  • Home

Last edited by seykron Jan 29, 2022
Page history

Home

  • Getting started
    • Dependencies
    • Basic setup
    • Key concepts and Components
  • Handling updates
    • Scope
    • Context
    • Media files
    • Replying to messages
  • Service provider
  • Structured user input
    • Validation
    • Built-in field types
    • Custom field types
    • Controls
  • Storing information
    • Persistence backends
      • File system
      • JDBC data source
    • Memory slots
  • Triggers
    • Default trigger
    • Built-in triggers
      • TextTrigger
  • Access control
  • Natural language support
  • Internationalization
  • Spring integration
  • Contributor guide

Getting started

Dependencies

Tehanu is available in maven central. Current version is 3.3.3. Tehanu provides the following artifacts:

Artifact Description
tehanu-core this is the core artifact containing all components. It is required.
tehanu-client-telegram Telegram client, it provides support to create Telegram bots.
tehanu-client-slack Slack client, it provides support to create Slack bots.
tehanu-spring It provides support for the Spring's IoC container. It is convenient to integrate Tehanu into existing applications using the Spring Framework.
<dependency>
    <groupId>be.rlab</groupId>
    <artifactId>tehanu-core</artifactId>
    <version>${currentVersion}</version>
</dependency>
<dependency>
    <groupId>be.rlab</groupId>
    <artifactId>tehanu-client-telegram</artifactId>
    <version>${currentVersion}</version>
</dependency>
<dependency>
    <groupId>be.rlab</groupId>
    <artifactId>tehanu-client-slack</artifactId>
    <version>${currentVersion}</version>
</dependency>
<dependency>
    <groupId>be.rlab</groupId>
    <artifactId>tehanu-spring</artifactId>
    <version>${currentVersion}</version>
</dependency>

This guide will use the Telegram client. If you want to set up a Spring application, look at the Spring integration section.

Basic setup

A very basic setup with lots of defaults requires only a couple of lines:

import be.rlab.tehanu.Tehanu
import be.rlab.tehanu.annotations.Handler
import be.rlab.tehanu.clients.UpdateContext
import be.rlab.tehanu.clients.telegram.telegram

@Handler(name = "/say_hello")
fun sayHello(context: UpdateContext) = context.apply {
    answer("hello!")
}

fun main(args: Array<String>) {
    Tehanu.configure {
        handlers {
            register(::sayHello)
        }

        clients {
            telegram("YOUR_TELEGRAM_TOKEN")
        }
    }.start()
}

First, we register the sayHello Handler and we configure the Telegram client. Try to talk to the bot. The default Trigger will route the /say_hello message to the sayHello() handler.

And that's it, you have a full-featured bot up and running. In the Handling updates section we will cover the different strategies to process Updates.

Key concepts and Components

There are a couple of concepts that we will use in this guide.

Concept Description
Tehanu When we refer to Tehanu, we'll talk about the be.rlab.tehanu.Tehanu class. This is the root class to create a bot.
Client Tehanu supports multiple messaging services. Clients connect Tehanu to messaging services like Telegram or Slack. Tehanu is client-agnostic and all components will work for any Client. Take into account that some Clients do not support all features.
Dispatcher The Dispatcher takes Updates from Clients and it routes the Update to a Handler.
Update The Update interface represents any type of event from the messaging service. An Update is optionally scoped to a User in a Chat, and it might have a Message or not. This abstraction allows the integration of multiple messaging services. Clients might define their own Update implementation.
Handler A Handler is a function or method within an object that is called every time Tehanu receives a message from a Client. Tehanu routes messages from Clients to Handlers using the Dispatcher. The Dispatcher evaluates all configured Triggers for each registered Handler and routes the Update to the first matching Handler. By convention, the default Trigger for a Handler is the Handler's name used as prefix, so if your Handler's name is /say_hello, Tehanu will route Updates that start with /say_hello to the underlying Handler.
Trigger A Trigger defines the set of conditions that must be fulfilled in order to route an Update to a Handler.
Context The Context is an object provided by Tehanu to access the Update information and respond back to the User. The Context is always scoped to a single User on a Chat, so you will always answer to a single User. The Context has more features like the ability to build Structured User Input forms.
Message The Message object contains the information provided by the user. It supports different type of contents like text, location, contact, media, along others. Both the Context and the Message are provided as parameters to the Handler.
User Input The User Input is the object/DSL that allows you to build form-like wizards with steps and different type of Controls. Look at the Structured user input section for further information.
Memory The Memory is a persistence layer that allows you to store arbitrary information in the bot. So far Tehanu supports FileSystemMemory, DatabaseMemory and DisposableMemory. Look at the Storing information section for further information.
Service Provider An interface to access internal services and application-defined services. Tehanu uses the configured Service Provider to resolve Handler parameters. This is also available in the User Input context, so it's possible to access services from fields and other User Input components.

Handling updates

As we mentioned in the Getting started section, Tehanu delegates messages to Handlers. Handlers are functions or methods in objects annotated with the @Handler annotation. You need to register your Handler in the handlers section of Tehanu's configuration. You can register either a function or an object. If you register an object, Tehanu looks for all annotated Handlers in the object.

Handlers take any number of parameters. Tehanu binds any of the context or Service Provider objects to the Handler function. If you specify a parameter that cannot be resolved by Tehanu, it will throw a runtime exception. It might happen in some cases when the Chat, User or Message are not available in the underlying Update.

The following table describes the available objects to bind to a Handler function.

Object Description
be.rlab.tehanu.clients.model.UpdateContext Current Update context.
be.rlab.tehanu.clients.model.User User that sent the current Update, if any.
be.rlab.tehanu.clients.model.Chat Chat related to the Current Update, if any.
be.rlab.tehanu.clients.Update The raw Update object as it was provided by the Client.
be.rlab.tehanu.clients.State The persistent State that tracks the interaction with a User in a Chat along different Updates.
be.rlab.tehanu.clients.Client The Client that received the Update.
be.rlab.tehanu.messages.model.Message Message sent by the User, if any.
be.rlab.tehanu.i18n.MessageSource The Message Source to get translated messages.
be.rlab.tehanu.BotServiceProvider::getService(Any::class) Any service provided by the configured Service Provider. Look at the Service provider section for more information.

The following example shows three Handlers, a simple /echo Handler that just reply back with the same message; two Math Handlers to make a sum and a substraction.

@Handler(name = "/echo")
fun echo(
    context: UpdateContext,
    message: Message
) = context.apply {
    answer(message.text.substringAfter("/echo"))
}

object Math {
    private val sumExpr: Regex = Regex("(\\d+)\\s*\\+\\s*(\\d+)")
    private val subExpr: Regex = Regex("(\\d+)\\s*-\\s*(\\d+)")

    @Handler(name = "/math_sum")
    fun sum(
        context: UpdateContext,
        message: Message
    ) = context.apply {
        val operands = require(
            sumExpr.find(message.text.substringAfter(" "))?.groupValues,
            "you must specify two operands, like in: 3+5"
        )
        val result: Long = operands[1].toLong() + operands[2].toLong()
        answer(result.toString())
    }

    @Handler(name = "/math_sub")
    fun subtract(
        context: UpdateContext,
        message: Message
    ) = context.apply {
        val operands = require(
            subExpr.find(message.text.substringAfter(" "))?.groupValues,
            "you must specify two operands, like in: 9-2"
        )
        val result: Long = operands[1].toLong() - operands[2].toLong()
        answer(result.toString())
    }
}


fun main(args: Array<String>) {
    Tehanu.configure {
        handlers {
            register(::echo)
            register(Math)
        }
    }.start()
}

Result:

image

Scope

You can specify in which scope Tehanu will execute a Handler. The scope is one of the elements in the be.rlab.tehanu.clients.model.ChatType enumeration. There is ascope` field in the @Handler annotation defines the scope:

@Handler(scope = [ChatType.PRIVATE, ChatType.GROUP])

The following table describes supported scopes.

Scope Description
PRIVATE Tehanu will execute a handler if the User sends a private message to the bot.
GROUP Tehanu will execute a handler if the User sends a message in a public channel.

If a User triggers a Handler in an invalid scope, Tehanu will answer with an error message.

Context

When Tehanu routes an Update to a Handler, it creates a Context. The Context is a persistent state scoped to a User in a Chat. The Handler will get the same Context as long as there is any pending interaction with the User in the underlying Chat. So, a Context will remain active after the Handler execution if:

  • Tehanu is waiting for Structured User Input
  • There is a transition to another Handler in progress

The Context provides an API to access the Update information and respond back to the User. The following table shows the available fields and methods in the Context.

Symbol Description
chat A reference to the current be.rlab.tehanu.messages.model.Chat in the Client. The Update might not have a Chat. It throws an error if the Chat is not available.
user The be.rlab.tehanu.messages.model.User that sent the message, if any. Messages might not come from a User. It throws an error if the User is not available.
messages be.rlab.tehanu.i18n.MessageSource
answer(text: String): UpdateContext Replies to the User with a simple text message.
sendMessage(callback: (MessageBuilder.() -> Unit)? = null): UpdateContext Allows to build custom responses.
talk(text: String, vararg args: String, callback: (MessageBuilder.() -> Unit)? = null): UpdateContext Talks to the current Chat, and optionally allows to create customize the response message.
talkTo(user: User, text: String, vararg args: String, callback: (MessageBuilder.() -> Unit)? = null): UpdateContext Sends a message to the specified User.
talkTo(chatId: UUID, text: String, vararg args: String, callback: (MessageBuilder.() -> Unit)? = null): UpdateContext Sends a message to the specified Chat.
require(value: T?, answerIfNull: String): T Requires a value to be not null, and if it's null it answers with a text message.
userInput(helpMessage: String? = null, callback: UserInput.() -> Unit): UpdateContext -
readContent(file: MediaFile): InputStream Resolves a media file and returns the Input Stream to read the content.

Media files

Tehanu provides an abstraction to work with media files (photos, videos, documents). The Message class provides a files: List<MediaFile> attribute with all the available media in the Update. The Context has a readContent method that takes a MediaFile and returns an InputStream to read the content.

In the following example, we define a Handler that will be triggered only when there is media content in the message (which means the files list is not empty). Then it will reply back using the same photo.

@Handler(name = "like")
@Trigger(MessageContentTrigger::class, TriggerParam(MessageContentTrigger.HAS_MEDIA))
fun like(
    context: UpdateContext,
    message: Message
) = context.apply {
    if (message.files.last().mimeType == "image/jpeg") {
        sendMessage {
            photo(readContent(message.files.last()), "<3")
        }
    }
}

Replying to messages

Tehanu provides an extensible strategy to build response messages. The sendMessage() method in the Context class allows to build custom messages using a MessageBuilder. The problem building custom messages is that each messaging service has a different API. Each Client implementation provides extension functions for the MessageBuilder so you are able to customize the response using the Client-specific objects.

The only common response for all Clients are text responses. The following table describes the MessageBuilder's Client-agnostic API. For additional information about the supported responses for each Client, look at the Client documentation.

Symbol Description
forChat(id: UUID?) Sends the response to the specified Chat.
forUser(id: Long?) Sends the response to the specified User.
replyTo(message: Message) Sends the response as a reply to a Message.
text(message: String, vararg args: String) Text response, it takes arguments to expand using the context MessageSource.
custom(response: Response) Sends a custom response. Clients use this method to provide extensions to their own API.
options(vararg newOptions: Pair<String, Any?>) Adds custom options. Clients use this method to configure the custom response.
option(name: String): T? Returns an option, if it does exist.

Service provider

Tehanu provides the be.rlab.tehanu.BotServiceProvider interface to access internal and application-specific services from different components. The default implementation registers all the internal services and it allows to register application-specific services in the configuration. You can provide your own implementation. For instance, the SpringServiceProvider resolves services from the ApplicationContext.

The following snippet extracted from the example project shows how to register application-specific services:

fun main(args: Array<String>) {
    Tehanu.configure {
        services {
            register { ProfileRepository(ref()) }
            register { TaskRepository(ref()) }
            register { TaskListRepository(ref(), ref()) }
        }
    }.start()
}

Take into account that the initialization is lazy, it means the services are created when the first service is required at runtime.

The ref() utility provides an existing service instance. It does not resolve dependencies, it just provides a registered instance, so you need to be careful with the order you register your services.

The following table shows the internal services registed by default:

Type Description
be.rlab.tehanu.acl.AccessControl Provides access to registered Users and Chats and manages permissions.
be.rlab.tehanu.clients.UpdateDispatcher The main Dispatcher instance.
be.rlab.tehanu.media.MediaManager Client-specific Media Manager to handle media content.
be.rlab.tehanu.store.Memory Configured storage service.
be.rlab.tehanu.view.PreconditionResolver Evaluates and fulfills Preconditions.
be.rlab.tehanu.clients.UserInputManager Manages messages in the context of an active User Input.

Structured user input

Structured user input allows you to build form-like wizards to ask for user input by using a functional DSL. Let's start with a simple form.

@Handler(name = "/form")
fun yourName(context: UpdateContext) = context.userInput {
    val firstName: String by field("Tell me your first name")
    val lastName: String by field("Tell me your last name")

    onSubmit {
        answer("Hi $firstName $lastName!")
    }
}

The DSL leverage the Kotlin delegated properties to bind variables to dynamic fields. Once the User completed all fields, Tehanu will invoke the onSubmit callback where you can use any of the fields provided by the User. If you test this Handler you will see the following output:

image

Validation

Fields support validation. The Validator must throw an IllegalArgumentException if the validation does not pass. There are some helpers in the be.rlab.tehanu.view.Validators class.

In the following example we define a validator that expects the first letter of a name to be capitalized. The validator takes the user input as parameter.

@Handler(name = "/form")
fun yourName(context: UpdateContext) = context.userInput {
    fun Field.nameValidator(value: Any) {
        assertRequired(
            (65..90).contains(value.toString().codePointAt(0)),
            "your name must start with uppercase!"
        )
    }
    val firstName: String by field("Tell me your first name") {
        validator(Field::nameValidator)
    }
    val lastName: String by field("Tell me your last name") {
        validator(Field::nameValidator)
    }

    onSubmit {
        answer("Hi $firstName $lastName!")
    }
}

Output:

image

Built-in field types

Tehanu provides a set of built-in field types. Some field types perform format validation and keeps asking the User for a valid value until the validation succeeds.

import be.rlab.tehanu.view.ui.number

@Handler(name = "/register")
fun checkAge(context: UpdateContext) = context.userInput {
    form {
        val age: Int by number("Tell me your age")

        onSubmit {
            if (age < 5) {
                answer("You are underage!")
            } else {
                answer("Welcome sir")
            }
        }
    }
}

Output:

image

The following table shows the list of built-in fields. To create custom fields and validations, look at the Custom Fields section below. All the built-in fields are available in the be.rlab.tehanu.view.ui package.

Field Kotlin Type Bot Type Description
number(helpMessage: String, minValue: Number? = null, maxValue: Number? = null) Number String Ask for a number and validates the number format.
dateTime(helpMessage: String, format: String = "dd/MM/YYYY") DateTime String Ask for a date and validates the specified format.
enumeration(description: String, validationMessage: String) Enum Button Shows buttons for each element in a Kotlin Enum. The user must select one of the elements.

Custom field types

Built-in field types are convenient for simple use cases, but let's say you have a Currency data class and you want to build a Currency value based on user input. Tehanu provides a Field Group concept for these scenarios.

Field Groups allows to ask the User to fill several fields before building a composite value. This is a powerful concept that allows you to build complex wizards. You need to provide a buildValue implementation to return the composite value.

In the following example, the value field uses the custom currency field type. The currency type uses a Field Group to ask for two different values: the currency unit and the amount. Once the User provides both values, the currency field type builds the underlying value using the buildValue callback.

enum class CurrencyUnit(val displayName: String) {
    DOLLAR("u\$s"),
    BITCOIN("btc")
}

data class Currency(
    val unit: CurrencyUnit,
    val amount: BigDecimal
)

fun UserInput.currency(description: String): FieldGroup<Currency> = fieldGroup {
    val amount: Double by number(description)
    val currencyUnit: CurrencyUnit by enumeration<CurrencyUnit>("select the currency unit")

    buildValue {
        Currency(currencyUnit, BigDecimal.valueOf(amount))
    }
}

@Handler(name = "/pay")
fun pay(context: UpdateContext) = context.userInput {
    form {
        val value: Currency by currency("how much it cost?")

        onSubmit {
            answer("got ${value.unit.displayName}${value.amount} from you, thanks!")
        }
    }
}

Output:

image

Controls

Tehanu supports some visual components like buttons. When you use visual components in fields, the User needs to interact with the components in order to fulfill the field and go to the next field. The field value will take the component's value. Let's start with a simple keyboard:

@Handler(name = "/select_pet")
fun selectPet(context: UpdateContext) = context.userInput {
    form {
        val pet: String by field("what's your favorite animal?") {
            keyboard {
                radio("Cats", "\uD83D\uDE3A")
                radio("Dogs", "\uD83D\uDC36")
                submitButton("Awww")
            }
        }
        val photos: List<MediaFile> by media("please, send me the photo of your favorite animal")

        onSubmit {
            sendMessage {
                photo(readContent(photos.last()), "aww, I like your $pet")
            }
        }
    }
}

Output:

image image

The keyboard component allows you to define a set of buttons. Once the user clicks on one of the buttons, the field value is set to the selected button's value. In this simple configuration, the keyboard only allows to select one option. Look at the full documentation below for additional configuration.

Storing information

Sometimes you need to store information related to the bot operations. For instance, you want to store the list of products in the User's shopping cart. Tehanu introduces the concept of Memory, an idiomatic way for storing arbitrary information.

Persistence backends

Tehanu supports different persistence backends for the Memory. By default, the data is stored in a Map and it's gone when you restart the bot. Only one persistence backend is allowed by bot instance. The following table describes the supported persistence backends.

Backend Description
Disposable It uses a Map to store objects. Everything is gone once you restart the bot.
File system Stores data in JSON files. You need to configure the directory for storing the data.
JDBC Data Source You can configure a JDBC Data Source.

The following sections will describe how to configure each backend.

File system

The file system backend stores data in a file system directory. If the directory does not exist, it's created. You need to be sure that Tehanu has write permissions to create the directory if required. You need to configure the directory when you setup Tehanu as in the following example.

Tehanu.configure {
    persistence {
        fileSystem(File("/tmp/my-bot"))
    }
}

Tehanu will write to disk synchronously every time you set a value (look at the Memory slots section below). This backend is thread-safe.

JDBC data source

Tehanu uses exposed and HikariCP to connect to the JDBC Data Source. The following example shows how to configure the data source.

Tehanu.configure {
    persistence {
        dataSource {
            jdbcUrl = "jdbc:h2:file:/tmp/tehanu-db"
            username = "sa"
            password = ""
            driverClassName = "org.h2.Driver"
            connectionTimeout = 5000
            logStatements = true
        }
    }
}

In this example we configure a file-based H2 database. Tehanu does not include JDBC drivers, so you need to add the driver dependency in order to make it work. For convenience, this is the H2 dependency:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.200</version>
    <scope>test</scope>
</dependency>

The dataSource callback is executed in the context of a com.zaxxer.hikari.HikariDataSource object, so you can configure any valid property from HikariCP.

Memory slots

As we mentioned earlier, Tehanu introduces the component be.rlab.tehanu.store.Memory to store arbitrary information. When you register a Handler it is possible to provide the Memory component as dependency.

The Memory contains slots. A slot can store any type of serializable object. The Memory uses delegated properties to bind a variable to a slot.

Let's look at the following example:


class Cart(memory: Memory) {
    private var products: List<String> by memory.slot("PRODUCTS", emptyList<String>())

    @Handler(name = "/cart_add")
    fun add(context: UpdateContext) = context.userInput {
        form {
            val productName: String by field("which product would you like to add to the cart?")

            onSubmit {
                products = products + productName
                answer("we added the product to the cart, thanks!")
            }
        }
    }

    @Handler(name = "/cart_list")
    fun list(context: UpdateContext) = context.apply {
        talk("List of products:\n${products.joinToString("\n")}")
    }
}

Tehanu.configure {
    handlers {
        register(Cart(memory()))
    }

    // Configure clients here
    // Configure persistence here
}.start()

In this example, first we instantiate the Cart Handler providing the memory configured in the context. We bind the products variable to a slot by using the memory.slot method. Then the Cart.add handler overwrites the products variable causing the Memory to store the new value.

The Cart.list handler reads the list of products pulling the data from the Memory slot. Take into account that read and write operations on slots are atomic and thread-safe by design.

This is the result:

image

Triggers

Triggers are components that allow you to configure how Tehanu delegates Updates to Handlers. Triggers can be configured on each Handler by using the @Triggers or @Trigger annotations.

The @Triggers annotation allows you to configure a collection of triggers with a fulfillment condition, while @Trigger only declares a single Trigger.

If you register an object with several handlers, you can specify Triggers at object-level. Triggers at object level are inherited by all Handlers. Triggers at Handler level have precedence over the Triggers at object level.

Triggers accept parameters that can be configured in the @Trigger annotation. In the following example, the yarrr handler does not respond to the default /yarrr trigger since we configured a custom Trigger. Look at the Built-in triggers section below to check the available Triggers and parameters.

@Handler(name = "/yarrr")
@Trigger(TextTrigger::class, TriggerParam(TextTrigger.CONTAINS, "hey yo"))
fun yarrr(context: UpdateContext) = context.apply {
    answer("yarrr!")
}

Output:

image

Default trigger

If you don't explicitly define a Trigger, Tehanu will define a TextTrigger expecting the handler name as prefix. The following example shows how looks the default trigger:

@Handler(name = "/yarrr")
@Trigger(TextTrigger::class, TriggerParam(TextTrigger.STARTS_WITH, "/yarrr"))
fun yarrr(context: UpdateContext) = context.apply {
    answer("yarrr!")
}

Built-in triggers

There are a couple of built-in Triggers that Tehanu provides for convenience. We describe built-in Triggers in the following sections.

TextTrigger

The be.rlab.tehanu.messages.TextTrigger class allows to match be.rlab.tehanu.messages.TextMessages using different type of text matchers. This is the more flexible Trigger and it's designed to support any text-based decision.

This trigger supports the following parameters:

Parameter Default value Description
contains emptyList() List of terms that must be present in the message.
starts-with null The message must start with this exact prefix.
ends-with null The message must end with this exact suffix.
regex null The message must match this regex.
distance -1.0F If greater than or equal to zero, it indicates how close must be the terms in the text to the terms specified in the contains parameter. It uses the Jaro-Winkler distance metric to measure distance between terms. It must be a number between 0 and 1, being 0 completely different and 1 completely equal terms.
ignore-case false Makes comparisons case insensitive. It applies to contains, starts-with, ends-with and regex parameters.
normalize false Normalizes the input text before validating. It uses a be.rlab.nlp.Normalizer. Look at the Normalization section below for further information.
stemming false Indicates whether as part of the normalization it will apply the Snowball stemmer algorithm to transform the input text.
strip-entities false Indicates whether to strip all be.rlab.tehanu.messages.model.MessageEntity elements from the input text before the validation.
operator TextTriggerOperator.ANY Indicates the matching criteria for the list of terms defined in the contains parameter.

Access control

TODO

Natural language support

TODO

Internationalization

TODO

Spring integration

TODO

Contributor guide

If you want to contribute to the project, please take a look at the contributors guide.

Clone repository
  • Contributing
  • Home