Async Master: Technical Review
I want to dump some of my thought on the small project that I have written to play TTRPGs with my friends online. There are some thoughts on the golang, refactoring and writing bots in general. Whats and Whys you can read in the previous post here.
This article will be about refactoring and a little bit about mock testing.
Overview
Project is written in Golang. It is basically a Chat Bot that used as intermediate communication layer between players and master.
Dependencies
Project is written in golang with minimal usage of extra libriries and frameworks. I wanted to write most of the stuff manually and train myself a little, so there is no any backend frameworks and ORMs (yeah, raw SQL). I still use some “big” libraries, just so project do not take more time that I am ready to spent on it.
The main dependency is telebot which covers all api communication with Telegram. Library is solid, but not without its bugs. Second big thing is pq pq - handling postgresql connection, requests and data type conversions.
And some small dependencies which I probably could have written myself:
- goose - handling database migrations. (Helped with testing, but I never changed DB after added it to the codebase -_-)
- godotenv - reading .env file for special flags on execution. Probably could have written analoug myself.
Takeaways
Generally there is not much to discuss in terms of intersting technical decision. Thats just a Chat Bot. But I still learned something out of it. Not much, but it is honest work.
“Bad Refactor”
So, when you are not using big backend frameworks, you need to route your handlers yourself.
I have 2 requirements:
- I need to process 2 different inputs from user
- Press of the button
- Text input
- There are handlers that take several inputs in sequence, so I need to keep a user session state
I started small
// Telebot receives user action. Text or Button pressed. I route them into 2 separate functions
b.Handle(tele.OnText, func(ctx tele.Context) error { return app.HandleText(ctx, botData) })
b.Handle(tele.OnCallback, func(ctx tele.Context) error {
return app.HandleCallbacks(ctx, botData)
})
// Then each function has a small state machine, where request is routed to the proper handler
func HandleText(context tele.Context, bot *BotData) error {
chatID := context.Chat().ID
state := bot.UserSessionState[chatID]
switch state {
case UserStateDefault:
return SendMainMenu(context, bot)
case UserStateAwaitSavingMessage, UserStateAwaitTitleForMesssage:
return HandleSave(context, bot)
case UserStateAwaitMessage:
return handlePlayerMessage(context, bot)
case UserStateAwaitTitle:
bot.MessageCache[chatID].Title = context.Message().Text
return handlePlayerFinalMessageSending(context, bot)
}
return nil
}
Simple, beautiful. And I decided that it scales badly. I have some scenarious where user need to answer several requests in a row, so I need to keep session state. Other thing is that I need to connect functions with each other in this dance of state machine madness.
So I decided to make this system fancy and smart
First of all you need to obscure how router handlers are registered. To do so you need:
// 1) Create specific folder with 2 files: handle.go and session.go
// 2) Session will hold part of the bolierplate
type Session struct {
// Session is an interface, which you need to implement
// conatins data required for your handlers to work
// all per session data goes here
}
func (s *Session) IsSupportedCallback(cb string) bool {
// you need to specify which button callback do these scenario support
// so router knows of where to route what
}
func (s *Session) IsDone() bool {
// How to know if route is done working? Can we release user to do something else?
}
func (s *Session) DispatchCallback(context tele.Context, cbUnique string, cbData string) error {
// Then goes inner routing for function that does some "actual work"
switch cbUnique {
case contract.CBGetMessageList:
return handleStartFlow(context, s)
}
func (s *Session) DispatchText(context tele.Context) error {
// Inner routing for text if scenario expects user to write text
return fmt.Errorf("met unsupported state while handling master request: %d", s.UserState)
}
func NewSession(db *sql.DB) *Session {
// Factory pattern - of only this file knows what session are we using, so only we can properly intialize it
return &Session{
DB: db,
UserState: FlowStart,
Done: false,
}
}
// And state stuff
type State int
const (
FlowStart State = 0
AwaitMessagePick State = 1
)
And handle.go file will contain the actual function that do some work.
This setup is just bad. I need to do 3 times more work without any benefits. Now when I want to add more routes I need to fight all this bolierplate, because I will forget something. I even worte error message for myself in this case:
// In the callback dispatcher file I have this
// ...
if session == nil {
factory, ok := UniqueToSessionFactory[cbUnique]
if !ok {
log.Printf("Met not start flow cbUnique: %s. Have you forgot to register it?", cbUnique)
}
}
// ...
What is this UniqueToSessionFactory thing?
Of coure it is a map of specific button callback that connects to specific scenario factory for Session.
var UniqueToSessionFactory = map[string]SessionFactory{
sendmsgc.CBSend: func(db *sql.DB) bot.FlowSession {
return sendmessage.NewSession(db)
},
mstrreqc.CBStartMasterRequest: func(db *sql.DB) bot.FlowSession {
return masterrequest.NewSession(db)
},
answrmstrc.CBReplyToMaster: func(db *sql.DB) bot.FlowSession {
return answermaster.NewSession(db)
},
}
Patternssss, niiiice.
In normal frameworks this thing is done automatically. You drop onto function some attributes and language reflection does this boilerplate stuff for you. But I thought I can do this stuff smarter. I can not.
So, I decided to re-do this refactor and introduce something simplier and easier to maintain.
Refactor v2
To simplify it I have done several things:
Removed different Session structs for each user scenario. Now it all one Session, that contains all data possibly required This removes layer of abstraction, Session is no longer an interface with different implementations. It is just fat-struct.
I made a one place, where all of the Routes data lives.
var Routes = []*bot.Route{
{
Callback: bot.HID_list_factions,
Handler: handlers.ListFactions,
NextPossibleCallbacks: []bot.HandlerID{},
},
{
Callback: bot.HID_message_send,
Handler: handlers.InitialSendMessage,
NextPossibleCallbacks: []bot.HandlerID{bot.HID_message_player_name},
},
... and moree...
Here is everthing needed. Which callback routes to which function. And most importantly - if you want to implement scenarious where user need to do several actions in a sequence - there is a NextPossibleCallback field, which give you opportunity to make finite state machine based where next you route may go.
And thats basically it. But it made it so much easier. I do not need factories of Session production. Making 2 different files for each new Scenario that I come up with. It just very straight forward.
My commit for this change was
+816; -1,619
It feels so good to delete more code then write it. Code is obligation. Less code, less obligation, the same functionality. Absolute win for me.
Mock Testing
And before the end, I want to show also small mock testing setup that I implemented.
The problem is - I need to test how 4-8 people communicate inside the bot. And Telegram does not provide testing profiles, so you need different sim-cards to make Telegram accounts. Maybe there is an other way or I has not found where the developer environement in Telegram is, but this is what I was working with.
So, I decided to decouple my application implementation from the Telebot interface and introduce runtime interface and use 2 different runtimes.
- Telebot runtime. Regular bot functionality
- MockRuntime. My fake telegram.
Basically, I want to make fake users and so that they can “write” messages and “press” buttons.
Therefore, I added executable flag to start mock runtime. In this mode you can communicate with program in a terminal and write commands.
Commands look like this:
input := []string{
`/server create_user John JohnTG`,
`/server create_user Alice AliceTG`,
`/server create_user Bob BobTG`,
`/user John "pepega26"`, // Things in quotes - are treated as Text Send in Telegram Chat.
`/user John message_send`, // message_send in this case is a button pressed callback.
`/user John message_player_name|1`, // and you can also pass parameteres to call back after vertical line
}
I can create users, and those users can write text and send command callback. And Async Master will treat those actions as if they were sent through Telegram API.
This also allowed me to implement some sort of Integration Testing with those commands. And I can test chunks of functionality, whole scenarious. And check for regressions.
Thoughts
It is ridiculosly easy to make things worse while thinking that you are doing a good job. Not matter how much I read or watched how to do things “right”, only after doing it wrong myself I obtained some experience.
THE FIN