Чат-бот под несколько месенджеров

Kate

Administrator
Команда форума
Привет, я Паша, руковожу эксплуатацией инфраструктуры крупного хайлоад-проекта. Хочу поделиться опытом разработки бота на Golang для различных мессенджеров.

2556f7fb5d68949863ead3a09af78a93.png

Всё началось с того, что у нас был бот на python-telegram-bot, делал он простые вещи, умел послать случайную весёлую гифку из Интернета, кошечку, собачку, затем мы крикрутили к нему наш таск-трекер и бот стал создавать тикеты прямо из чата.

Спустя время, руководство приняло решение о разработке собственного мессенджера для общения сотрудников компании и встал вопрос о поддержке нашего бота в новом мессенджере. У нас уже была разработана отдельная модельная часть, которую оставалось лишь прикрутить к новому источнику сообщений, но в этот момент мы уже одной ногой торчали в Golang и мы решили, что у нас есть отличный шанс максимально развить свои навыки разработки, мы видели в Golang большие перспективы для развития нашей инфраструктуры.

И-так, поехали.

cf45a52eed2a66a08973d91245d2ce07.jpeg

Используемые модули​

Следующие внешние модули были использованы при разработке

github.com/andygrunwald/go-jira v1.13.0
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible
github.com/gorilla/mux v1.8.0
github.com/urfave/cli/v2 v2.3.0
go.mongodb.org/mongo-driver v1.6.0
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a

Входная точка​

в качестве парсера аргументов мы использовали urfave/cli2 - мы используем и cobra, для создания утилит, но, по нашему мнению, cli2 для демонов подходит больше, там при описании аргумента из коробки доступно дефолтное значение + указание переменной окружения, можно задать required, алиасы и многое другое, вот, например, параметры текстового флага (аргумента утилиты)

type StringFlag struct {
Name string
Aliases []string
Usage string
EnvVars []string
FilePath string
Required bool
Hidden bool
TakesFile bool
Value string
DefaultText string
Destination *string
HasBeenSet bool
}
используется примерно так:

package main
import (
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Flags: []cli.Flag{
/* приложение может работать в разных dev/production, поддержим это
- можно вызывать будущую утилиту с аргументом --environment
- либо указать это в переменной окружения ENVIRONMENT
- по умолчанию будет использовано значение production
*/
&cli.StringFlag{Name: "environment", Value: "production", Usage: "environment name", EnvVars: []string{"ENVIRONMENT"}},

//прочитаем логин и пароль от JIRA, пользуемся этим аналогично
&cli.StringFlag{Name: "jira-login", Usage: "jira bot login", EnvVars: []string{"jira_login"}},
&cli.StringFlag{Name: "jira-password", Usage: "jira bot password", EnvVars: []string{"jira_password"}},

/*есть и числовые аргументы, если ввести там текст то будет что то такое:
Incorrect Usage. invalid value "foo" for flag -jira-default-search-limit: parse error
*/
&cli.IntFlag{Name: "jira-default-search-limit", Value: 5, Usage: "jira default search limit"},

/*список пользователей, которые могут писать боту, задаётя множественным перечислением аргумента --telegram-permit-users:
--telegram-permit-users foo --telegram-permit-users bar
*/
&cli.StringSliceFlag{Name: "telegram-permit-users", Value: cli.NewStringSlice(
"Paulstrong",
), Usage: "telegram permitted users list"},

//и так далее
&cli.StringFlag{Name: "mongodb-host", Usage: "mongodb server host/ip", EnvVars: []string{"mongodb_host"}},
&cli.StringFlag{Name: "mongodb-user", Usage: "mongodb server username", EnvVars: []string{"mongodb_user"}},
&cli.StringFlag{Name: "mongodb-password", Usage: "mongodb server password", EnvVars: []string{"mongodb_password"}},
&cli.StringFlag{Name: "mongodb-name", Usage: "mongodb server database name", EnvVars: []string{"mongodb_name"}},
&cli.IntFlag{Name: "mongodb-port", Value: 27017, Usage: "mongodb server database name"},
},
Action: func(context *cli.Context) error {
//у нас у демона есть всего одна корневая "команда", эта функция отвечает за её обработку
//здесь мы создаём экземпляр будущего демона, передаём ему контекст с аргументами
d := daemon.NewDaemon(context)
//проводим инициализацию демона (позже рассмотрим, что там)
if err := d.Init(); err != nil {
log.Fatalln("daemon initialization error", err)
}
//и, наконец, запускаем демона
return d.Run()
},
}
//запускаем приложение cli2
_ = app.Run(os.Args)
}

В указанном примере используется гораздо больше аргументов, убрал их чтобы не засорять эфир. Общий смысл, думаю, понятен, мы указываем аргументы, которыми можно будет настроить нашего бота, передавая различные значения путём прямого указания в аргументах утилиты, либо при помощи переменных окружения.

Конфиг​

Пользоваться context от cli2 в приложении - неудобно, потому что обращаться к аргументам придётся по имени и это надо будет либо копипастить в коде, либо хранить в константах, например вот так выглядит получение имени окружения:

context.String("environment")
мы это дело обернули в структуру, в которую встроили контекст:

type UrfaveContext struct {
*cli.Context
}

func NewUrfaveContext(context *cli.Context) *UrfaveContext {
ret := &UrfaveContext{}
ret.Context = context
return ret
}

func (c *UrfaveContext) Environment() string {
return c.Context.String("environment")
}
и далее мы подаём в инстанс демона уже готовый конфиг:

d := daemon.NewDaemon(NewUrfaveContext(context))
при инициализации демона мы сохраняем конфиг в структуре и дальше этим пользоваться очень даже удобно:

type Daemon struct {
config interfaces.IConfig
}

func NewDaemon(config interfaces.IConfig) *Daemon {
return &Daemon{config}
}

func (d *Daemon) PrinEnv() {
fmt.Println(d.config.Environment())
}
здесь мы видим интерфейс IConfig, мы его используем осознанно, т.к. в будущем может появиться что-то более прикольное, чем cli2, а также кто-то может задействовать cobra вместо cli2, поэтому мы просто реализуем методы, указанные в интерфейсе, а на чём именно - решать каждому самостоятельно:

type IConfig interface {
Environment() string
}

Что дальше?​

Дальше нужно поговорить о подкапотном пространстве демона. Он у нас имеет следующие атрибуты:

type Daemon struct {
shutdown chan struct{}
engines []interfaces.IEngine
config interfaces.IConfig
db interfaces.IDatabase
}
  • shutdown - канал, в который шлётся struct{}{} при отлове сигнала завершения приложения, когда мы писали бота, мы еще не знали про то, как работать context.Context, поэтому писали как могли
  • engine - универсальный "движок", например telegram, viber, то есть менсенджеры, кроме того, движком может быть обычная горутина, которая что-то делает на фоне, а затем присылает в один из других движков результаты своей работы, в общем и целом, это - горутина, которая работает в фоне
  • config - мы уже знаем что это
  • db - объект базы данных, мы используем бд для хранения oauth2 авторизации от нашего корпоративного мессенджера, мы подаём этот объект в "конструктор" демона аналогично конфигу, это может быть монга, либо быть mysql, и др., главное, реализовать нужные методы:
type IDatabase interface {
GetToken() (*oauth2.Token, error) //читаем токен из базы
SetToken(t *oauth2.Token) error //кладём токен в базу
}
Интерфейс движка выглядит так

type IEngine interface {
AddShutdownChan(ch chan struct{})
Run(wg *sync.WaitGroup)
Reply(update IUpdate)
CheckAcl(update IUpdate, cmd ICommand) (result bool)
SetManager(engine IManager) IEngine
GetManager() IManager
SetName(name string) IEngine
GetName() string
SetDaemon(d IDaemon) IEngine
GetDaemon() IDaemon
GetConfig() IConfig
GetDB() IDatabase
}
Тут можно увидеть, что мы можем назначить движку имя, указать демона и базу данных, менеджера (скоро подойдём к этому), указать конфиг. Всё это нужно для того, чтобы можно было провязать движки между собой, чтобы они могли обращаться друг-к-другу по имени, например, у нас есть движок, который смотрит тикеты от саппорта, а потом обращается к движку нашего корпоративного мессенджера, чтобы послать сообщение в чат эксплуатации.

Менеджер​

Менеджер принимает сообщения от движков и отправляет их обрабатываться в модель. Изначально мы хотели сделать одного менеджера на все движки, но потом решили, что у каждого движка будет свой инстанс менеджера, чтобы избежать различных спецэффектов, поэтому мы инициализируем в "конструкторах" движков отдельного менеджера каждый раз отдельно:

type CorpMessenger struct {
name string
daemon interfaces.IDaemon
manager interfaces.IManager
}

func New(d interfaces.IDaemon, name string) interfaces.ICorpMessengerEngine {
a := &CorpMessenger{daemon: d, name: name}
a.manager = NewManager(a)
return a
}
Интерфейс менеджера выглядит следующим образом

type IManager interface {
SetInitialController(c IInitialController)
Route(update IUpdate)
SetEngine(e IEngine) IManager
GetEngine() IEngine
GetConfig() IConfig
GetDB() IDatabase
}
здесь мы видим метод для задания исходного контроллера, который будет обрабатывать сообщения от пользователей

Как обрабатываются сообщения​

Итак, мы научили принимать сообщения от наших пользователей, детали реализации тут раскрывать не буду, у каждого может быть своя реализации, смысл тут в том, что у нас есть набор байт от сообщения, id чата, имя пользователя, под это дело создали интерфейс

type IUpdate interface {
Reply(text string)
CheckAcl(cmd ICommand) (result bool)
GetText() (result string)
GetEngine() (result IEngine)
GetChatID() (result string)
GetMessageID() (result string)
GetReplyText() (result string)
GetUserName() (result string)
GetUserDescription() (result string)
}
приняв сообщение от пользователя, мы упаковываем его атрибуты в объект и отдаём менеджеру на роутинг

upd := update.NewUpdate(corpMsgObj, text, msgId, chanId, directId, userDescription,)
corpMsgObj.manager.Route(upd)
теперь наше сообщение передано менеджеру на обработку, здесь мы можем проверить правомерность отправки сообщения (acl), а дальше мы отправляем наше сообщение в обработчика (контроллер)

интерфейс контроллера выглядит следующим образом

type IController interface {
Call(cmd ICommand) (result string, err error) //входная точка
CanAnswer(c ICommand) (result bool) //метод для проверки возможности ответа контроллером для заданного текста
GetName() (result string) //отдаём имя контроллера
Validate(c ICommand) (result bool) //проверяем правильность заполнения команды контроллера
SetManager(m IManager)
GetManager() IManager
GetConfig() IConfig
GetDatabase() IDatabase
}

type IInitialController interface {
IController
ThrowManager()
}
Контроллер - это объект, который парсит строку, выделяя в ней заголовочную часть от данных, и далее передаёт данные на рендеринг в модельную часть, для такого разделения у нас есть интерфейс ICommand

type ICommand interface {
GetEntity() (result string) //отдать заголовок команды
SetEntity(val string) //записать заголовок команды
GetArgs() (result string) //отдать тело команды
SetArgs(val string) //записать тело команды
GetText() (result string) //отдать исходный текст команды
SetText(val string) //записать исходный текст команды
Parse() (entity string, args string, err error) //парсим команду
GetUserDescription() string
}
Чтобы понять, как это у нас используется, нужно показать какие команды мы умеем обрабаывать:

/task create foo тестируем создание тикета
это текстовый тикет, не нужно ничего делать
мы просто хотим убедиться, что бот успешно его создал

Что мы видим?

Во первых, мы договорились, что сообщения для бота будут начинаться со слеша, сегодня это выглядит немного странновато, т.к. бота можно отметить через @, но это история, нас это устраивает, мы это не трогаем. Может быть в будущем поддержим как-то иначе.

Далее мы видим инструкцию task, под это дело у нас есть type TaskController

Далее идёт create, под это у нас есть CreateController (всё, что связано с task, вынесено в отдельный package, чтобы не вводить дополнительно кучу префиксом в именах типов)

Далее идёт имя очереди в JIRA, в данном случае, для примера, foo

Дальше у нас идёт тема тикета (тестируем создание тикета)

И, наконец, идёт текст тикета.

Если наложить это на ICommand, то получится следующее:

type Command struct {
entity string //task
args string //create foo .............
text string //тут полный текст команды
}
Чтобы начать обрабатывать сообщение, у нас есть InitialController, который находится в вершине всей иерархии

type InitialController struct {
controllers []interfaces.IController
name string
manager interfaces.IManager
}

func NewInitialController(manager interfaces.IManager) interfaces.IInitialController {
ctrl := &InitialController{}
ctrl.name = "initial"
ctrl.AddController(task_controller.NewTaskController(manager))
return ctrl
}

/*
метод для прокидывания менеджера все контроллеры
вызывается после добавления всех контроллеров
*/
func (ctrl *InitialController) ThrowManager() {
for _, c := range ctrl.controllers {
c.SetManager(ctrl.manager)
}
}

/*
метод для поиска контроллера, который готов ответить на нашу команду
*/
func (ctrl *InitialController) FindController(command interfaces.ICommand) (result interfaces.IController, err error) {
for _, ctl := range ctrl.controllers {
if ctl.CanAnswer(command) {
result = ctl
}
}
if result == nil {
result = NewDummyController(ctrl.manager)
}
return result, err
}

/*
входная точка в обработку сообщения контроллером
*/
func (ctrl *InitialController) Call(cmd interfaces.ICommand) (result string, err error) {

controller, ctlErr := ctrl.FindController(cmd)
if ctlErr != nil {
return result, ctlErr
}
if !controller.Validate(cmd) {
return result, errors.New(controllers.EInvalidCommand)
}

return controller.Call(cmd)
}
Код будет идти по списку контроллеров и опрашивать их "ты можешь ответить на команды task?", и один из контроллеров ответить true

type TaskController struct {
name string
controllers []interfaces.IController
manager interfaces.IManager
}

func NewTaskController(m interfaces.IManager) *TaskController {
task := &TaskController{manager: m}
task.name = "task"
task.AddController(NewCreateController(m))
return task
}

/*
проверяем возможность ответить на команду
*/
func (ctrl *TaskController) CanAnswer(cmd interfaces.ICommand) (result bool) {
return cmd.GetEntity() == ctrl.name
}

/*
входная точка контроллера
*/
func (ctrl *TaskController) Call(cmd interfaces.ICommand) (result string, err error) {
if !ctrl.Validate(cmd) {
return result, errors.New(controllers.EInvalidCommand)
}
taskCmd, taskCmdErr := commands.NewCommand(cmd.GetArgs())
if taskCmdErr != nil {
return result, errors.New(controllers.EInvalidTaskCommand)
}
//идём по списку контроллеров и спрашиваем кто может ответить
controller, ctlErr := ctrl.FindController(taskCmd)
if ctlErr != nil {
return result, ctlErr
}
if !controller.Validate(taskCmd) {
return result, errors.New(controllers.EInvalidTaskCommand)
}

return controller.Call(taskCmd)
}
у него добавлены разные контроллеры, в т.ч. CreateController, здесь всё аналогично, код будет идти по списку контроллеров и спрашивать "ты можешь ответить на команду create?", и указанный контроллер ответит true

type CreateController struct {
args string
name string
manager interfaces.IManager
}

func NewCreateController(m interfaces.IManager) *CreateController {
ret := &CreateController{name: "create", manager: m}
return ret
}

func (ctrl *CreateController) CanAnswer(cmd interfaces.ICommand) (result bool) {
return cmd.GetEntity() == ctrl.name
}
И так далее, мы постепенно разбираем команду, отправляя команды во вложенные контроллеры. В какой-то момент мы придём в конечную точку, из которой мы передадим конкретную команду в модель на обработку.

func (ctrl *CreateController) Call(cmd interfaces.ICommand) (result string, err error) {
createCmd, _ := commands.NewTaskCreateCommand(cmd.GetText())
parseRes, parseErr := createCmd.Parse()
if parseErr != nil {
return result, parseErr
}
return task.Create(parseRes.Project, parseRes.Title, parseRes.Content, parseRes.Assignee, ctrl.GetConfig(), parseRes.Link)
}
в модель мы передаём имя очереди (проект), заголовок тикета, текст, конфиг и другие аргументы, в ответ получаем текст и ошибку, возвращаем это дело, и далее оно возвращается наверх по стеку вызовов.

 
Сверху