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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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

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

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

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

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

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

	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)
}

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

Заключение

Это моя первая статья, прошу не судить строго, насколько мне известно, я позже могу поправить и доработать эту статью. Если у кого-то она вызывала интерес или недоумение - прошу в комментарии, с удовольствием отвечу на любые, спокойно приму критику и предложения, а также смогу раскрыть частично реализацию, если что-то заинтересовало, не понятно.

Всем peace :)

Источник: https://habr.com/ru/post/649413/


Интересные статьи

Интересные статьи

Представьте картину. Вы живёте в той точке планеты, в которой хотите в данный момент. Вы уверены в сохранности своего капитала, что всё лежит в надежных банках, и никто это у вас не отнимет. Ваша прив...
Компании, которые хотят улучшить качество обслуживания клиентов и расширить свои возможности в современном, мире, все больше полагаются на технологии NLP. Одним из наибол...
Чат-боты стали неотъемлемой частью продвижения любого бизнеса, который присутствует в социальных сетях. Это помогает автоматизировать многие процессы, на которые раньше у...
Я давно знаком с Битрикс24, ещё дольше с 1С-Битрикс и, конечно же, неоднократно имел дела с интернет-магазинами которые работают на нём. Да, конечно это дорого, долго, местами неуклюже...
5 августа 2020 разработчики Google анонсировали новое CSS-свойство content-visibility в версии Chromium 85. Оно должно существенно повлиять на скорость первой загрузки и первой отрисо...