Перейти к основному содержимому

Серверная часть

Серверная часть плагина — программа на Go, которую Loop запускает рядом с собой. Она получает от Loop события (хуки) и управляет Loop через API плагинов. Здесь собраны все основные приёмы; каждый пример можно скопировать в проект из шаблона.

Как это работает​

  • Серверная часть — отдельный процесс. Loop запускает его при включении плагина и общается с ним по внутреннему протоколу. Если плагин упал, Loop перезапустит его, а сам продолжит работать.
  • Хуки — функции вашей структуры Plugin с определёнными именами. Если функция с таким именем есть, Loop будет её вызывать; если нет — просто не будет. Ничего регистрировать не нужно.
  • Хуки вызываются параллельно. Пока обрабатывается одно сообщение, может прийти второе. Если несколько хуков меняют общие данные в структуре Plugin, защищайте их мьютексом (sync.Mutex) — так же, как шаблон защищает настройки.
  • API плагинов — p.API. Через него плагин читает и меняет данные Loop: пользователей, каналы, сообщения, файлы.

Жизненный цикл​

Администратор включает плагин (или сервер Loop перезапускается)
│
▼
OnConfigurationChange ← загружаем настройки
│
▼
OnActivate ← создаём ботов, регистрируем команды, запускаем фоновые задачи
│
▼
...плагин работает: хуки, HTTP-запросы, команды...
│ (администратор сохранил настройки → снова OnConfigurationChange)
▼
OnDeactivate ← останавливаем фоновые задачи

Если OnActivate вернёт ошибку, плагин не запустится: в Системной консоли под ним будет надпись Не удалось запустить этот плагин., а текст ошибки — в логах сервера. Проверяйте в OnActivate всё, без чего плагин не может работать (например, что заполнены обязательные настройки), и возвращайте понятную ошибку.

p.API и p.sdk​

В шаблоне к Loop можно обращаться двумя способами:

p.APIp.sdk
Что этоБазовый интерфейс plugin.APIОбёртка pluginapi.Client над тем же API
ОшибкиБольшинство методов возвращают *model.AppErrorОбычный error
Удобства—Готовые помощники: Bot.EnsureBot, KV.Get с автоматическим разбором JSON, KV.SetAtomicWithRetries, кластерные задачи
Примерp.API.GetUser(id)p.sdk.User.Get(id)

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

warning
Внимание к *model.AppError

Методы p.API возвращают указатель *model.AppError. Не присваивайте его переменной типа error и не сравнивайте с nil после такого присваивания: в Go интерфейс с пустым указателем внутри не равен nil, и проверка if err != nil сработает, даже когда ошибки нет. Пишите так:

user, appErr := p.API.GetUser(userID)
if appErr != nil {
return appErr
}

Настройки​

Настройки описываются в манифесте, а в коде — полями структуры Configuration в server/plugin/configuration.go:

type Configuration struct {
ServiceURL string
MaxItems int
Enabled bool
}

Читайте текущие значения через p.GetConfiguration() — в момент, когда они нужны:

cfg := p.GetConfiguration()
if !cfg.Enabled {
return
}

Не сохраняйте настройки в свои переменные при запуске: администратор может поменять их в любой момент, и тогда OnConfigurationChange загрузит новые значения, а ваша копия останется старой.

Проверка настроек​

Чтобы не дать плагину работать с неправильными настройками, проверяйте их в OnConfigurationChange. Если функция вернёт ошибку, Loop запишет её в лог, а старые настройки останутся действовать:

func (p *Plugin) OnConfigurationChange() error {
var configuration = new(Configuration)
if err := p.API.LoadPluginConfiguration(configuration); err != nil {
return errors.Wrap(err, "failed to load plugin configuration")
}

if configuration.ServiceURL != "" && !strings.HasPrefix(configuration.ServiceURL, "https://") {
return errors.New("адрес сервиса должен начинаться с https://")
}

p.SetConfiguration(configuration)
return nil
}
подсказка

Если структура Configuration содержит срезы или словари ([]string, map[string]string), доработайте функцию Clone в configuration.go, чтобы она копировала их содержимое, а не только ссылки. Для простых полей (строки, числа, bool) ничего менять не нужно.

Хуки​

Все хуки, которые есть в Loop. Чтобы использовать хук, добавьте в структуру Plugin функцию с таким же именем и сигнатурой. Сигнатуры и подробные описания — в справочнике plugin.Hooks.

Запуск, остановка, настройки​

ХукКогда вызывается
OnActivate() errorПлагин включили или сервер перезапустился
OnDeactivate() errorПлагин выключают
OnConfigurationChange() errorИзменились настройки плагина или сервера. Вызывается и перед OnActivate
OnInstall(c, event) errorПлагин установили через магазин плагинов. В event.UserId — кто установил
ConfigurationWillBeSaved(newCfg) (*model.Config, error)Администратор сохраняет конфигурацию сервера. Можно изменить её или отклонить, вернув ошибку

Сообщения​

ХукКогда вызываетсяЧто может плагин
MessageWillBePosted(c, post) (*model.Post, string)Сообщение вот-вот опубликуютИзменить сообщение (вернуть изменённый post) или отклонить (вернуть nil и причину)
MessageWillBeUpdated(c, newPost, oldPost) (*model.Post, string)Сообщение вот-вот отредактируютТо же для редактирования
MessageHasBeenPosted(c, post)Сообщение опубликованоОтреагировать: ответить, поставить реакцию, отправить куда-то
MessageHasBeenUpdated(c, newPost, oldPost)Сообщение отредактированоОтреагировать
MessageHasBeenDeleted(c, post)Сообщение удаленоОтреагировать
MessagesWillBeConsumed(posts) []*model.PostСообщения отдаются пользователю (при загрузке канала, поиске и т. д.)Изменить то, что увидит пользователь, не меняя сохранённое. Вызывается очень часто — только быстрые операции
ReactionHasBeenAdded(c, reaction)Поставили реакциюОтреагировать
ReactionHasBeenRemoved(c, reaction)Убрали реакциюОтреагировать
NotificationWillBePushed(notification, userID) (*model.PushNotification, string)Loop отправляет push-уведомление на телефонИзменить или отменить уведомление

Пользователи, каналы, команды​

ХукКогда вызывается
UserHasBeenCreated(c, user)Создан пользователь
UserWillLogIn(c, user) stringПользователь входит. Верните непустую строку, чтобы запретить вход (строка станет текстом ошибки)
UserHasLoggedIn(c, user)Пользователь вошёл
UserHasBeenDeactivated(c, user)Пользователя деактивировали
ChannelHasBeenCreated(c, channel)Создан канал
UserHasJoinedChannel(c, channelMember, actor)Пользователь вошёл в канал. actor — кто добавил (или nil, если вошёл сам)
UserHasLeftChannel(c, channelMember, actor)Пользователь вышел из канала
UserHasJoinedTeam(c, teamMember, actor)Пользователь вошёл в команду
UserHasLeftTeam(c, teamMember, actor)Пользователь вышел из команды
PreferencesHaveChanged(c, preferences)Пользователь изменил свои настройки

Команды, HTTP, файлы​

ХукКогда вызывается
ExecuteCommand(c, args) (*model.CommandResponse, *model.AppError)Вызвана slash-команда плагина. См. Slash-команды
ServeHTTP(c, w, r)Пришёл HTTP-запрос на /plugins/<ID плагина>/.... В шаблоне уже реализован. См. Своё HTTP API
FileWillBeUploaded(c, info, file, output) (*model.FileInfo, string)Пользователь загружает файл. Можно изменить файл (записать новое содержимое в output) или отклонить загрузку

Служебные​

ХукКогда вызывается
OnWebSocketConnect(webConnID, userID)Пользователь подключился по WebSocket (открыл Loop)
OnWebSocketDisconnect(webConnID, userID)Пользователь отключился
WebSocketMessageHasBeenPosted(webConnID, userID, req)Клиент прислал по WebSocket сообщение, адресованное плагину
OnPluginClusterEvent(c, ev)Пришло событие от копии плагина на другом сервере кластера. См. Кластер
RunDataRetention(nowTime, batchSize) (int64, error)Сработала политика хранения данных — удалите устаревшие данные плагина
OnSendDailyTelemetry()Раз в сутки, когда Loop отправляет статистику
ServeMetrics(c, w, r)Запрос метрик плагина для Prometheus
GenerateSupportData(c) ([]*model.FileData, error)Администратор собирает пакет данных для поддержки — добавьте в него файлы плагина
OnCloudLimitsUpdated(limits), OnSharedChannels*Облачные лимиты и общие каналы между серверами. В обычных установках Loop не используются

Пример: запрет слов​

MessageWillBePosted может остановить публикацию. Пользователь увидит сообщение об ошибке, а само сообщение не сохранится:

func (p *Plugin) MessageWillBePosted(_ *plugin.Context, post *model.Post) (*model.Post, string) {
if strings.Contains(strings.ToLower(post.Message), "пароль:") {
return nil, "Не публикуйте пароли в каналах"
}
return post, ""
}

А может изменить сообщение — например, добавить подпись:

func (p *Plugin) MessageWillBePosted(_ *plugin.Context, post *model.Post) (*model.Post, string) {
if post.ChannelId == p.announcementsChannelID {
post.Message += "\n\n_Сообщение модерируется._"
}
return post, ""
}
warning
Будьте осторожны с хуками ...Will...

Хуки MessageWillBePosted, MessageWillBeUpdated, MessagesWillBeConsumed, FileWillBeUploaded стоят на пути каждого действия: пока они не ответят, пользователь ждёт. Долгие операции в них замедлят весь Loop. Если нужен запрос во внешний сервис — используйте хук ...Has Been..., который вызывается уже после действия.

Пример: приветствие в канале​

func (p *Plugin) UserHasJoinedChannel(_ *plugin.Context, member *model.ChannelMember, _ *model.User) {
channel, appErr := p.API.GetChannel(member.ChannelId)
if appErr != nil || channel.Name != "town-square" {
return
}
p.API.SendEphemeralPost(member.UserId, &model.Post{
ChannelId: member.ChannelId,
UserId: p.botUserID,
Message: "Добро пожаловать! Правила канала — в закреплённых сообщениях.",
})
}

Работа с данными Loop​

Через p.API доступно почти всё, что можно сделать через REST API. Самые нужные методы:

ЧтоМетоды
ПользователиGetUser, GetUserByUsername, GetUserByEmail, GetUsersByUsernames, GetUsersInChannel, GetUsersInTeam, SearchUsers, UpdateUser, GetUserStatus, UpdateUserStatus
КаналыGetChannel, GetChannelByName, GetChannelByNameForTeamName, GetDirectChannel, GetGroupChannel, GetPublicChannelsForTeam, GetChannelsForTeamForUser, CreateChannel, UpdateChannel, AddChannelMember, DeleteChannelMember, GetChannelMember, GetChannelMembers
Команды (teams)GetTeam, GetTeamByName, GetTeams, GetTeamsForUser, CreateTeamMember, GetTeamMember, GetTeamMembers
СообщенияCreatePost, UpdatePost, DeletePost, GetPost, GetPostThread, GetPostsSince, SearchPostsInTeam, SendEphemeralPost, UpdateEphemeralPost, DeleteEphemeralPost
РеакцииAddReaction, RemoveReaction, GetReactions
ФайлыUploadFile, GetFile, GetFileInfo, CopyFileInfos, GetFileLink
ПраваHasPermissionTo, HasPermissionToTeam, HasPermissionToChannel
БотыCreateBot, GetBot, GetBots, PatchBot, UpdateBotActive (удобнее — p.sdk.Bot.EnsureBot)
Slash-командыRegisterCommand, UnregisterCommand
ХранилищеKVSet, KVGet, KVDelete, KVSetWithExpiry, KVCompareAndSet, KVList (удобнее — p.sdk.KV)
СлужебноеGetConfig, GetLicense, GetServerVersion, GetBundlePath, GetPluginConfig, SavePluginConfig, PublishWebSocketEvent, OpenInteractiveDialog, LogDebug/LogInfo/LogWarn/LogError

Полный список с описаниями — в справочнике plugin.API.

Какая версия справочника правильная

Ссылки ведут на версию v0.1.6 пакета Mattermost — в ней ровно тот набор методов и хуков, что есть в Loop. В описании метода может встретиться строка Minimum server version: X.Y — всё, что есть на этой странице, в Loop работает. В справочнике более новых версий есть методы, которых в Loop нет, — см. Совместимость.

Сообщения​

Опубликовать сообщение​

post, appErr := p.API.CreatePost(&model.Post{
UserId: p.botUserID, // автор — бот плагина
ChannelId: channelID,
Message: "Сборка **#142** прошла успешно :white_check_mark:",
})
  • Поле Message поддерживает Markdown.
  • Чтобы ответить в тред, укажите RootId — ID первого сообщения треда.
  • Автором можно указать любого пользователя, но лучше — бота плагина: так пользователи видят, что сообщение автоматическое.

Личное сообщение пользователю​

Личная переписка — это тоже канал. Получите его ID и публикуйте как обычно:

func (p *Plugin) sendDM(userID, message string) error {
channel, appErr := p.API.GetDirectChannel(p.botUserID, userID)
if appErr != nil {
return appErr
}
_, appErr = p.API.CreatePost(&model.Post{
UserId: p.botUserID,
ChannelId: channel.Id,
Message: message,
})
if appErr != nil {
return appErr
}
return nil
}

Временное сообщение (видно только одному)​

«Эфемерное» сообщение видит только указанный пользователь, и оно исчезает после перезагрузки страницы. Удобно для подсказок и ошибок:

p.API.SendEphemeralPost(userID, &model.Post{
UserId: p.botUserID,
ChannelId: channelID,
Message: "Команда выполнена, но вы не подписаны на этот проект.",
})

Оформление и кнопки​

Для цветных карточек, полей и кнопок используйте вложения — те же, что в вебхуках. В плагине их добавляют так:

post := &model.Post{
UserId: p.botUserID,
ChannelId: channelID,
}
model.ParseSlackAttachment(post, []*model.SlackAttachment{{
Color: "#2ea44f",
Title: "Заявка на отпуск",
Text: "Иван Петров, 10–24 июля",
Actions: []*model.PostAction{{
Id: "approve",
Name: "Согласовать",
Type: model.PostActionTypeButton,
Integration: &model.PostActionIntegration{
// Путь к маршруту самого плагина — Loop передаст нажатие прямо в плагин
URL: "/plugins/ru.mycompany.hello/actions/approve",
Context: map[string]any{"request_id": "42"},
},
}},
}})
_, appErr := p.API.CreatePost(post)

Когда пользователь нажмёт кнопку, Loop отправит в плагин POST /actions/approve с JSON model.PostActionIntegrationRequest: кто нажал (user_id), в каком сообщении (post_id) и ваш context. Заголовок Mattermost-User-Id тоже будет выставлен:

p.router.HandleFunc("/actions/approve", p.handleApprove).Methods("POST")

func (p *Plugin) handleApprove(w http.ResponseWriter, r *http.Request) {
var req model.PostActionIntegrationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
requestID, _ := req.Context["request_id"].(string)
// ...согласовываем заявку requestID...

w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(model.PostActionIntegrationResponse{
EphemeralText: "Заявка " + requestID + " согласована",
})
}

В ответе можно вернуть Update — новую версию сообщения (например, без кнопок), и EphemeralText — временный ответ нажавшему. Этот ответ Loop публикует от имени «Система» в тред сообщения с кнопкой, поэтому в самом канале его видно, только если треды не свёрнуты. Если ответ должен быть заметен сразу, лучше вернуть Update с изменённым текстом сообщения.

Свой тип сообщения​

Если задать сообщению Type, начинающийся с custom_, клиентская часть плагина сможет нарисовать его по-своему (см. Клиентская часть › Свой вид сообщений). Данные для отрисовки кладите в Props:

post := &model.Post{
UserId: p.botUserID,
ChannelId: channelID,
Type: "custom_hello_poll",
Message: "Опрос: где обедаем?", // этот текст увидят там, где нет клиентской части (мобильное приложение, уведомления)
}
post.AddProp("options", []string{"Столовая", "Пицца", "Суши"})

Боты​

Бот — отдельная учётная запись с меткой БОТ. Плагин может создавать ботов, даже если в Loop выключено создание бот-аккаунтов для пользователей.

botUserID, err := p.sdk.Bot.EnsureBot(&model.Bot{
Username: "hello-bot", // 3–22 символа: строчные латинские буквы, цифры, . - _
DisplayName: "Hello Bot",
Description: "Бот плагина Hello",
}, pluginapi.ProfileImagePath("assets/bot.png")) // необязательно: аватар из папки assets
  • Вызывайте в OnActivate. EnsureBot создаёт бота один раз, а дальше возвращает ID уже созданного.
  • Сохраните botUserID в поле структуры Plugin — он понадобится для публикации сообщений.
  • Чтобы бот мог писать в канал, ему не нужно быть его участником: CreatePost от имени плагина работает в любом канале.

Slash-команды​

Команда регистрируется в OnActivate и обрабатывается в хуке ExecuteCommand. Полный пример — в пошаговом примере.

p.API.RegisterCommand(&model.Command{
Trigger: "todo", // вызывается как /todo
AutoComplete: true,
AutoCompleteDesc: "Список дел",
AutoCompleteHint: "[add|list|done] [текст]",
})

Команда, зарегистрированная плагином, доступна во всех командах (teams) Loop. Если плагин регистрирует несколько команд, в ExecuteCommand различайте их по args.Command:

func (p *Plugin) ExecuteCommand(_ *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
fields := strings.Fields(args.Command) // "/todo add купить молоко" → ["/todo", "add", "купить", "молоко"]
if len(fields) < 2 {
return p.help(), nil
}

switch fields[1] {
case "add":
text := strings.Join(fields[2:], " ")
// ...сохраняем...
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "Добавлено: " + text,
}, nil
case "list":
// ...
}
return p.help(), nil
}

func (p *Plugin) help() *model.CommandResponse {
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "Использование: `/todo add <текст>`, `/todo list`, `/todo done <номер>`",
}
}
ResponseTypeКто увидит Text
model.CommandResponseTypeEphemeralТолько вызвавший, как временное сообщение
model.CommandResponseTypeInChannelВсе в канале, сообщение от имени вызвавшего

Если вернуть пустой &model.CommandResponse{}, Loop ничего не опубликует — удобно, когда плагин сам публикует ответ через CreatePost.

В args также есть ChannelId, TeamId, RootId (если команда вызвана в треде), UserId и TriggerId — он нужен, чтобы открыть интерактивный диалог.

Своё HTTP API​

Все запросы на https://your-loop-server.ru/plugins/<ID плагина>/<путь> Loop передаёт в хук ServeHTTP плагина. В шаблоне запросы разбирает роутер gorilla/mux, а маршруты добавляются в InitApi в server/plugin/api.go:

p.router.HandleFunc("/items", p.handleListItems).Methods("GET")
p.router.HandleFunc("/items", p.handleCreateItem).Methods("POST")
p.router.HandleFunc("/items/{id}", p.handleGetItem).Methods("GET")

Кто прислал запрос​

Если запрос пришёл от вошедшего пользователя (из клиентской части плагина, с токеном или по кнопке в сообщении), Loop добавит заголовок Mattermost-User-Id с его ID. Подделать этот заголовок снаружи нельзя — Loop удаляет его из входящих запросов.

Шаблон уже отклоняет все запросы без этого заголовка с кодом 401. Внутри обработчика ID берётся так:

func (p *Plugin) handleCreateItem(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("Mattermost-User-Id")
// ...
}

Проверка прав​

«Вошёл в Loop» не значит «имеет право». Проверяйте права перед действием:

if !p.API.HasPermissionTo(userID, model.PermissionManageSystem) {
http.Error(w, "только для системных администраторов", http.StatusForbidden)
return
}

if !p.API.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) {
http.Error(w, "нет доступа к каналу", http.StatusForbidden)
return
}

Запросы от внешних сервисов​

Если внешний сервис (GitHub, CRM, мониторинг) должен присылать в плагин вебхуки, у таких запросов нет пользователя, и проверка шаблона их отклонит. Разделите маршруты: публичные — без проверки, остальные — через подроутер с проверкой.

func (p *Plugin) InitApi() {
p.router = mux.NewRouter()

// Публичный маршрут: проверяем секрет сами
p.router.HandleFunc("/webhook", p.handleWebhook).Methods("POST")

// Всё остальное — только для вошедших пользователей
api := p.router.PathPrefix("/api").Subrouter()
api.Use(p.requireUser)
api.HandleFunc("/items", p.handleListItems).Methods("GET")
}

func (p *Plugin) requireUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Mattermost-User-Id") == "" {
http.Error(w, "Not authorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
secret := p.GetConfiguration().WebhookSecret
if secret == "" || subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("secret")), []byte(secret)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
// ...обрабатываем...
}

Секрет удобно хранить в настройке типа generated (см. Манифест). Адрес для внешнего сервиса: https://your-loop-server.ru/plugins/ru.mycompany.hello/webhook?secret=....

Хранение данных​

У каждого плагина есть своё KV-хранилище (key-value, «ключ → значение»). Оно лежит в базе данных Loop, переживает перезапуски и общее для всех серверов кластера. Данные разных плагинов не пересекаются.

type Subscription struct {
ChannelID string
Project string
}

// Сохранить (значение превращается в JSON автоматически)
_, err := p.sdk.KV.Set("sub_"+channelID, Subscription{ChannelID: channelID, Project: "LOOP"})

// Прочитать. Если ключа нет — ошибки не будет, структура останется пустой
var sub Subscription
err = p.sdk.KV.Get("sub_"+channelID, &sub)

// Удалить
err = p.sdk.KV.Delete("sub_" + channelID)

// С ограничением срока жизни: ключ исчезнет сам через час
_, err = p.sdk.KV.Set("cache_"+userID, data, pluginapi.SetExpiry(time.Hour))

// Все ключи (постранично, по 100)
keys, err := p.sdk.KV.ListKeys(0, 100)

Правила, которые избавят от проблем:

  • Ключи — до 150 символов. Используйте префиксы по смыслу (sub_, user_, cache_), чтобы потом легко найти нужное.
  • Значения держите небольшими: хранилище рассчитано на настройки, связи и небольшие списки, а не на мегабайты. Для файлов используйте UploadFile.
  • Одновременные изменения. Если одно значение могут менять параллельно (счётчики, списки), используйте p.sdk.KV.SetAtomicWithRetries — пример в пошаговом примере.
  • Схема данных меняется — плагин новой версии может прочитать данные, сохранённые старой. Добавляйте поля так, чтобы пустое значение было допустимым.

События в браузер (WebSocket)​

Чтобы клиентская часть плагина узнала о чём-то сразу, а не при следующем запросе, отправьте событие:

p.API.PublishWebSocketEvent(
"item_created", // имя события
map[string]any{"id": item.ID, "title": item.Title}, // данные
&model.WebsocketBroadcast{UserId: userID}, // кому
)
WebsocketBroadcastКто получит
{}Все подключённые пользователи
{UserId: "..."}Один пользователь (во всех его вкладках и приложениях)
{ChannelId: "..."}Участники канала
{TeamId: "..."}Участники команды

В браузер событие придёт с именем custom_<ID плагина>_<имя события>, например custom_ru.mycompany.hello_item_created. Как его поймать — в Клиентская часть › События от сервера.

Данные передавайте простыми типами: строки, числа, bool, срезы и словари из них.

Диалоги​

Плагин может открыть у пользователя окно с формой — интерактивный диалог. Для этого нужен TriggerId, который приходит в ExecuteCommand и в запросах от кнопок сообщений:

p.API.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: args.TriggerId,
URL: "/plugins/ru.mycompany.hello/dialog/submit",
Dialog: model.Dialog{
Title: "Новая задача",
SubmitLabel: "Создать",
Elements: []model.DialogElement{
{DisplayName: "Название", Name: "title", Type: "text"},
{DisplayName: "Описание", Name: "description", Type: "textarea", Optional: true},
},
},
})

Когда пользователь нажмёт Создать, Loop пришлёт в плагин POST /dialog/submit с model.SubmitDialogRequest; значения полей — в Submission.

Запросы во внешние сервисы​

Серверная часть — обычная программа на Go, так что пользуйтесь стандартным net/http. Обязательно задавайте таймаут, иначе зависший внешний сервис «подвесит» и ваш плагин:

var httpClient = &http.Client{Timeout: 10 * time.Second}

resp, err := httpClient.Get(p.GetConfiguration().ServiceURL + "/api/status")
if err != nil {
return err
}
defer resp.Body.Close()

Функция utils.MakeRequest из шаблона использует клиент без таймаута — для рабочих плагинов лучше заведите свой, как выше.

Фоновые задачи​

Задача, которая должна выполняться регулярно (раз в минуту проверить почту, раз в день отправить отчёт), запускается в OnActivate и останавливается в OnDeactivate. Используйте cluster.Schedule из пакета pluginapi/cluster: он гарантирует, что в кластере из нескольких серверов задача выполнится один раз, а не на каждом сервере.

import "github.com/mattermost/mattermost/server/public/pluginapi/cluster"

// в структуре Plugin:
// reportJob *cluster.Job

// в OnActivate:
job, err := cluster.Schedule(
p.API,
"daily_report", // уникальное имя задачи
cluster.MakeWaitForRoundedInterval(24*time.Hour), // раз в сутки
p.sendDailyReport, // функция без аргументов
)
if err != nil {
return errors.Wrap(err, "не удалось запланировать отчёт")
}
p.reportJob = job

// в OnDeactivate:
if p.reportJob != nil {
_ = p.reportJob.Close()
}

cluster.MakeWaitForInterval(5*time.Minute) — «каждые 5 минут от последнего запуска», MakeWaitForRoundedInterval(time.Hour) — «в начале каждого часа».

Кластер​

Крупные установки Loop работают на нескольких серверах сразу. Тогда копия плагина запущена на каждом сервере. Что это значит на практике:

  • Данные в памяти (поля структуры Plugin) у каждой копии свои. Всё, что должно быть общим, храните в KV-хранилище.
  • Хуки о событиях (MessageHasBeenPosted и т. п.) вызываются на одном сервере — на том, который обработал действие. Дублей не будет.
  • Фоновые задачи запускайте через cluster.Schedule (см. выше), а для «только один сервер в каждый момент» используйте cluster.NewMutex.
  • Чтобы сообщить другим копиям о событии (например, сбросить кеш), используйте p.API.PublishPluginClusterEvent и хук OnPluginClusterEvent.

Логи​

p.API.LogDebug("получили вебхук", "project", project, "size", len(body))
p.API.LogInfo("подписка создана", "channel_id", channelID)
p.API.LogWarn("внешний сервис ответил медленно", "duration", d.String())
p.API.LogError("не удалось отправить сообщение", "error", err.Error())

После текста идут пары «ключ, значение» — их удобно искать в логах. Сообщения попадают в лог сервера Loop с пометкой ID плагина. Где их найти — в Отладка › Логи.

Не пишите в логи пароли, токены и содержимое личных сообщений.

Тесты​

Для модульных тестов есть заглушка API — plugintest.API. Она позволяет проверить логику плагина без сервера Loop: вы заранее говорите, что должен вернуть каждый метод, а в конце проверяете, что нужные методы были вызваны. Пример теста для команды /hello из пошагового примера:

server/plugin/command_test.go
package plugin

import (
"testing"

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest"
"github.com/mattermost/mattermost/server/public/pluginapi"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestHelloUsesUsername(t *testing.T) {
api := &plugintest.API{}

// Главное, что проверяем: имя берётся у пользователя и попадает в сообщение
api.On("GetUser", "user1").Return(&model.User{Username: "anna"}, nil)
api.On("CreatePost", mock.MatchedBy(func(post *model.Post) bool {
return post.Message == "Привет, anna!"
})).Return(&model.Post{}, nil)

// Остальные вызовы нам не важны — разрешаем их с любыми аргументами
api.On("KVGet", mock.Anything).Return(nil, nil)
api.On("KVSetWithOptions", mock.Anything, mock.Anything, mock.Anything).Return(true, nil)
api.On("PublishWebSocketEvent", mock.Anything, mock.Anything, mock.Anything).Return()

p := &Plugin{}
p.SetAPI(api)
p.sdk = pluginapi.NewClient(api, nil)
p.configuration = &Configuration{Greeting: "Привет, {name}!"}

_, appErr := p.ExecuteCommand(nil, &model.CommandArgs{UserId: "user1", Command: "/hello"})
require.Nil(t, appErr)
api.AssertExpectations(t)
}

Перед первым запуском добавьте библиотеку для тестов: go get github.com/stretchr/testify/mock github.com/stretchr/testify/require (именно эти два пакета — иначе Go не запишет все нужные зависимости и тест не соберётся). Запуск — make test или go test ./server/....