- Getting started
- Handling updates
- Service provider
- Structured user input
- Storing information
- Triggers
- 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:
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 a
scope` 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:
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:
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:
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:
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:
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:
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:
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.TextMessage
s
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.