Серверная часть
Серверная часть плагина — программа на 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.API | p.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) |
Оба способа можно смешивать. В этом разделе используется тот, что короче для конкретной задачи.
*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, ""
}
...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 из пошагового примера:
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/....