telemux
Flexible message router add-on for go-telegram-bot-api library.
Table of contents
Motivation
This library serves as an addition to the go-telegram-bot-api library.
I strongly recommend you to take a look at it since telemux is mostly an extension to it.
Patterns such as handlers, persistence & filters were inspired by a wonderful python-telegram-bot library.
This project is in early beta stage. Contributions are welcome! Feel free to submit an issue if you have any questions, suggestions or simply want to help.
Features
Minimal example
package main
import (
tm "github.com/and3rson/telemux"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
"os"
)
func main() {
bot, _ := tgbotapi.NewBotAPI(os.Getenv("TG_TOKEN"))
bot.Debug = true
u := tgbotapi.NewUpdate(0)
u.Timeout = 60
updates, _ := bot.GetUpdatesChan(u)
mux := tm.NewMux().
AddHandler(tm.NewCommandHandler(
"start",
func(u *tm.Update) {
bot.Send(tgbotapi.NewMessage(u.Message.Chat.ID, "Hello! Say something. :)"))
},
)).
AddHandler(tm.NewHandler(
tm.Any(),
func(u *tm.Update) {
bot.Send(tgbotapi.NewMessage(u.Message.Chat.ID, "You said: "+u.Message.Text))
},
))
for update := range updates {
mux.Dispatch(bot, update)
}
}
Documentation
The documentation is available here.
Examples are available here.
Changelog
Changelog is available here.
Terminology
Mux
Mux (multiplexer) is a "router" for instances of tgbotapi.Update
.
It allows you to register handlers and will take care to choose an appropriate handler based on the incoming update.
In order to work, you must dispatch messages (that come from go-telegram-bot-api channel):
mux := tm.NewMux()
updates, _ := bot.GetUpdatesChan(u)
for update := range updates {
mux.Dispatch(bot, update)
}
You can also nest Mux instances:
mux_a := tm.NewMux().
SetGlobalFilter(tm.IsPrivate()).
AddHandler().
AddHandler()
mux_b := tm.NewMux().
SetGlobalFilter(tm.IsGroupOrSuperGroup()).
AddHandler().
AddHandler()
mux = tm.NewMux().
AddMux(mux_a).
AddMux(mux_b).
AddHandler()
Handlers & filters
Handler consists of filter and handle-function.
Handler's filter decides whether this handler can handle the incoming update.
If so, handle-function is called. Otherwise multiplexer will proceed to the next handler.
Filters are divided in two groups: content filters (starting with "Has", such as HasPhoto()
, HasAudio()
, HasSticker()
etc)
and update type filters (starting with "Is", such as IsEditedMessage()
, IsInlineQuery()
or IsGroupOrSuperGroup()
).
There is also a special filter Any()
which makes handler accept all updates.
Filters can also be applied to the Mux instance using mux.SetGlobalFilter(filter)
.
Such filters will be called for every update before any other filters.
Generic handlers can be created with tm.NewHandler
function, however there are shortcuts for adding update-type-specific handlers:
tm.NewMessageHandler(tm.HasPhoto(), func(u *tm.Update) { })
# ...equals to: tm.NewHandler(tm.And(tm.IsMessage(), tm.HasPhoto()), func(u *tm.Update) { })
tm.NewCommandHandler("start", tm.IsPrivate(), func(u *tm.Update) { })
# ...equals to: tm.NewHandler(tm.And(tm.IsCommandMessage("start"), tm.IsPrivate()), func(u *tm.Update) { })
tm.NewCallbackQueryHandler(nil, func(u *tm.Update) { })
# ...equals to: tm.NewHandler(tm.IsCallbackQuery(), func(u *tm.Update) { })
# etc.
Combining filters
Filters can be chained using And
, Or
, and Not
meta-filters. For example:
mux := tm.NewMux()
mux.AddHandler(And(tm.IsPrivate(), tm.HasPhoto()), func(u *tm.Update) { })
mux.AddHandler(Or(tm.HasText(), tm.HasPhoto()), func(u *tm.Update) { })
mux.AddHandler(tm.NewHandler(
tm.And(tm.IsCommandMessage("restart"), func(u *tm.Update) bool {
return u.Message.From.ID == 3442691337
}),
func(u *tm.Update) { },
))
Reusable handler functions
mux.NewHandler
can accept more than one handler function. They are all executed sequentially. The chain can
be interrupted by any of them by calling u.Consume()
.
Here is an example:
mux.AddHandler(tm.NewHandler(
tm.IsCommandMessage("do_work"),
func(u *tm.Update) {
if u.EffectiveUser().ID != 3442691337 {
u.Bot.Send(tgbotapi.Message(u.EffectiveChat().ID, "You are not allowed to ask me to work!"))
return
}
if !u.EffectiveChat().IsPrivate() {
u.Bot.Send(tgbotapi.Message(u.EffectiveChat().ID, "I do not accept commands in group chats. Send me a PM."))
return
}
},
))
To avoid repeating boilerplate checks like if user is not "3442691337" then send error and stop
, you can move them to separate functions.
The above code can be rewritten as follows:
func CheckAdmin(u *tm.Update) {
if u.EffectiveUser().ID != 3442691337 {
u.Bot.Send(tgbotapi.Message(u.EffectiveChat().ID, "You are not allowed to ask me to work!"))
u.Consume()
}
}
func CheckPrivate(u *tm.Update) {
if !u.EffectiveChat().IsPrivate() {
u.Bot.Send(tgbotapi.Message(u.EffectiveChat().ID, "I do not accept commands in group chats. Send me a PM."))
u.Consume()
}
}
mux.AddHandler(tm.NewHandler(
tm.IsCommandMessage("do_work"),
CheckAdmin,
CheckPrivate,
func(u *tm.Update) {
},
))
mux.AddHandler(tm.NewHandler(
tm.IsCommandMessage("other_command")),
func(u *tm.Update) {
},
))
You can implement some more complex handler functions, for example, a parametrized one:
func RequireUserID(userID int) tm.HandleFunc {
return func(u *tm.Update) {
if u.EffectuveUser().ID != userID {
u.Bot.Send(tgbotapi.NewMessage(u.EffectiveChat().ID, "Sorry, I don't know you!"))
u.Consume()
}
}
}
mux.AddHandler(tm.NewHandler(
tm.IsCommandMessage("do_work"),
RequireUserID(3442691337),
func(u *tm.Update) {
},
))
Conversations & persistence
Conversations are handlers on steroids based on the finite-state machine pattern.
They allow you to have complex dialog interactions with different handlers.
Persistence interface tells conversation where to store & how to retrieve the current state of the conversation, i. e. which "step" the given user is currently at.
To create a ConversationHandler you need to provide the following:
-
conversationID string
- identifier that distinguishes this conversation from the others.
The main goal of this identifier is to allow persistence to keep track of different conversation states independently without mixing them together.
-
persistence Persistence
- defines where to store conversation state & intermediate inputs from the user.
Without persistence, a conversation would not be able to "remember" what "step" the user is at.
Persistence is also useful when you want to collect some data from the user step-by-step).
Two convenient implementations of Persistence
are available out of the box: LocalPersistence
& FilePersistence
.
Telemux also supports GORM persistence. If you use GORM, you can store conversation states & data in your database by using GORMPersistence
from a module.
-
states StateMap
- defines what handlers to use in which state.
States are usually strings like "upload_photo", "send_confirmation", "wait_for_text" and describe the "step" the user is currently at.
Empty string (""
) should be used as an initial/final state (i. e. if the conversation has not started yet or has already finished.)
For each state you must provide a slice with at least one Handler. If none of the handlers can handle the update, the default handlers are attempted (see below).
In order to switch to a different state your Handler must call u.PersistenceContext.SetState("STATE_NAME")
replacing STATE_NAME with the name of the state you want to switch into.
Conversation data can be accessed with u.PersistenceContext.GetData()
and updated with u.PersistenceContext.SetData(newData)
.
-
defaults []*Handler
- these handlers are "appended" to every state.
Useful to handle commands such as "/cancel" or to display some default message.
See ./examples/album_conversation/main.go for a conversation example.
Error handling
By default, panics in handlers are propagated all the way to the top (Dispatch
method).
In order to intercept all panics in your handlers globally and handle them gracefully, register your function using SetRecover
:
mux := tm.NewMux()
# ...
mux.SetRecover(func(u *tm.Update, err error, stackTrace string) {
fmt.Printf("An error occurred: %s\n\nStack trace:\n%s", err, stackTrace)
})
Tips & common pitfalls
tgbotapi.Update vs tm.Update confusion
Since Update
struct from go-telegram-bot-api already provides most of the functionality, telemux implements its own Update
struct
which embeds the Update
from go-telegram-bot-api. Main reason for this is to add some extra convenient methods and include Bot instance
with every update.
Getting user/chat/message object from update
When having handlers for wide filters (e. g. Or(And(HasText(), IsEditedMessage()), IsInlineQuery())
) you may often fall
in situations when you need to check for multiple user/chat/message attributes. In such situations sender's data may
be in one of few places depending on which update has arrived: u.Message.From
, u.EditedMessage.From
, or u.InlineQuery.From
.
Similar issue applies to fetching actual chat info or message object from an update.
In such cases it's highly recommended to use functions such as EffectiveChat()
(see the update module for more info):
fmt.Println(u.Message.Chat.ID)
chatId int64
if u.Message != nil {
chatId = u.Message.Chat.ID
} else if u.EditedMessage != nil {
chatId = u.EditedMessage.Chat.ID
} else if u.CallbackQuery != nil {
chatId = u.CallbackQuery.Chat.ID
}
fmt.Println(chatId)
chat := u.EffectiveChat()
if chat != nil {
fmt.Println(chat.ID)
}
Properly filtering updates
Keep in mind that using content filters such as HasText()
, HasPhoto()
, HasLocation()
, HasVoice()
etc does not guarantee
that the Update
describes an actual new message. In fact, an Update
also happens when a user edits his message!
Thus your handler will be executed even if a user just edited one of his messages.
To avoid situations like these, make sure to use filters such as IsMessage()
, IsEditedMessage()
, IsCallbackQuery()
etc
in conjunction with content filters. For example:
tm.NewHandler(HasText(), func(u *tm.Update) { })
tm.NewHandler(And(IsMessage(), HasText()), func(u *tm.Update) { })
tm.NewHandler(And(IsEditedMessage(), HasText()), func(u *tm.Update) { })
The only exceptions are IsCommandMessage("...")
and IsAnyCommandMessage()
filters. Since it does not make sense to react to edited messages that contain
commands, this filter also checks if the update designates a new message and not an edited message, inline query, callback query etc.
This means you can safely use IsCommandMessage("my_command")
without joining it with the IsMessage()
filter:
IsCommandMessage("my_command")
And(IsCommandMessage("start"), IsMessage())
And(IsCommandMessage("start"), Not(IsEditedMessage()))